@memtensor/memos-local-openclaw-plugin 1.0.4-beta.8 → 1.0.4

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.
Files changed (99) hide show
  1. package/.env.example +7 -0
  2. package/README.md +94 -27
  3. package/dist/capture/index.js +3 -1
  4. package/dist/capture/index.js.map +1 -1
  5. package/dist/client/connector.d.ts +5 -0
  6. package/dist/client/connector.d.ts.map +1 -1
  7. package/dist/client/connector.js +132 -10
  8. package/dist/client/connector.js.map +1 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +2 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/hub/server.d.ts +2 -0
  13. package/dist/hub/server.d.ts.map +1 -1
  14. package/dist/hub/server.js +251 -38
  15. package/dist/hub/server.js.map +1 -1
  16. package/dist/hub/user-manager.d.ts +9 -0
  17. package/dist/hub/user-manager.d.ts.map +1 -1
  18. package/dist/hub/user-manager.js +26 -2
  19. package/dist/hub/user-manager.js.map +1 -1
  20. package/dist/ingest/chunker.d.ts +2 -1
  21. package/dist/ingest/chunker.d.ts.map +1 -1
  22. package/dist/ingest/chunker.js +14 -10
  23. package/dist/ingest/chunker.js.map +1 -1
  24. package/dist/ingest/providers/index.js +2 -2
  25. package/dist/ingest/providers/index.js.map +1 -1
  26. package/dist/recall/engine.d.ts.map +1 -1
  27. package/dist/recall/engine.js +96 -1
  28. package/dist/recall/engine.js.map +1 -1
  29. package/dist/shared/llm-call.d.ts.map +1 -1
  30. package/dist/shared/llm-call.js +2 -1
  31. package/dist/shared/llm-call.js.map +1 -1
  32. package/dist/sharing/types.d.ts +1 -1
  33. package/dist/sharing/types.d.ts.map +1 -1
  34. package/dist/skill/evolver.d.ts +2 -0
  35. package/dist/skill/evolver.d.ts.map +1 -1
  36. package/dist/skill/evolver.js +56 -5
  37. package/dist/skill/evolver.js.map +1 -1
  38. package/dist/skill/generator.d.ts +2 -0
  39. package/dist/skill/generator.d.ts.map +1 -1
  40. package/dist/skill/generator.js +45 -3
  41. package/dist/skill/generator.js.map +1 -1
  42. package/dist/skill/installer.d.ts +26 -0
  43. package/dist/skill/installer.d.ts.map +1 -1
  44. package/dist/skill/installer.js +80 -4
  45. package/dist/skill/installer.js.map +1 -1
  46. package/dist/skill/upgrader.d.ts +2 -0
  47. package/dist/skill/upgrader.d.ts.map +1 -1
  48. package/dist/skill/upgrader.js +139 -1
  49. package/dist/skill/upgrader.js.map +1 -1
  50. package/dist/skill/validator.d.ts +3 -0
  51. package/dist/skill/validator.d.ts.map +1 -1
  52. package/dist/skill/validator.js +75 -0
  53. package/dist/skill/validator.js.map +1 -1
  54. package/dist/storage/sqlite.d.ts +58 -0
  55. package/dist/storage/sqlite.d.ts.map +1 -1
  56. package/dist/storage/sqlite.js +295 -35
  57. package/dist/storage/sqlite.js.map +1 -1
  58. package/dist/telemetry.d.ts.map +1 -1
  59. package/dist/telemetry.js +27 -8
  60. package/dist/telemetry.js.map +1 -1
  61. package/dist/types.d.ts +10 -0
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js +4 -0
  64. package/dist/types.js.map +1 -1
  65. package/dist/viewer/html.d.ts.map +1 -1
  66. package/dist/viewer/html.js +796 -289
  67. package/dist/viewer/html.js.map +1 -1
  68. package/dist/viewer/server.d.ts +11 -0
  69. package/dist/viewer/server.d.ts.map +1 -1
  70. package/dist/viewer/server.js +456 -92
  71. package/dist/viewer/server.js.map +1 -1
  72. package/index.ts +411 -52
  73. package/openclaw.plugin.json +1 -1
  74. package/package.json +2 -1
  75. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  76. package/prebuilds/darwin-x64/better_sqlite3.node +0 -0
  77. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  78. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  79. package/src/capture/index.ts +4 -1
  80. package/src/client/connector.ts +136 -10
  81. package/src/config.ts +2 -1
  82. package/src/hub/server.ts +246 -38
  83. package/src/hub/user-manager.ts +42 -6
  84. package/src/ingest/chunker.ts +19 -13
  85. package/src/ingest/providers/index.ts +2 -2
  86. package/src/recall/engine.ts +89 -1
  87. package/src/shared/llm-call.ts +2 -1
  88. package/src/sharing/types.ts +1 -1
  89. package/src/skill/evolver.ts +58 -6
  90. package/src/skill/generator.ts +44 -5
  91. package/src/skill/installer.ts +107 -4
  92. package/src/skill/upgrader.ts +139 -1
  93. package/src/skill/validator.ts +79 -0
  94. package/src/storage/sqlite.ts +326 -40
  95. package/src/telemetry.ts +27 -9
  96. package/src/types.ts +11 -0
  97. package/src/viewer/html.ts +796 -289
  98. package/src/viewer/server.ts +430 -89
  99. package/telemetry.credentials.json +5 -0
@@ -72,6 +72,8 @@ class ViewerServer {
72
72
  authFile;
73
73
  auth;
74
74
  ctx;
75
+ cookieName;
76
+ defaultHubPort;
75
77
  static SESSION_TTL = 24 * 60 * 60 * 1000;
76
78
  static PLUGIN_VERSION = (() => {
77
79
  try {
@@ -105,16 +107,31 @@ class ViewerServer {
105
107
  this.ctx = opts.ctx;
106
108
  this.authFile = node_path_1.default.join(opts.dataDir, "viewer-auth.json");
107
109
  this.auth = { passwordHash: null, sessions: new Map() };
110
+ this.cookieName = `memos_token_${opts.port}`;
111
+ this.defaultHubPort = opts.defaultHubPort ?? 18800;
108
112
  this.resetToken = node_crypto_1.default.randomBytes(16).toString("hex");
109
113
  this.loadAuth();
110
114
  }
115
+ getHubPort() {
116
+ const configured = this.ctx?.config?.sharing?.hub?.port;
117
+ if (configured && configured !== 18800)
118
+ return configured;
119
+ return this.defaultHubPort;
120
+ }
111
121
  start() {
122
+ const MAX_PORT_RETRIES = 5;
112
123
  return new Promise((resolve, reject) => {
124
+ let retries = 0;
113
125
  this.server = node_http_1.default.createServer((req, res) => this.handleRequest(req, res));
114
126
  this.server.on("error", (err) => {
115
- if (err.code === "EADDRINUSE") {
116
- this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
117
- this.server.listen(this.port + 1, "0.0.0.0");
127
+ if (err.code === "EADDRINUSE" && retries < MAX_PORT_RETRIES) {
128
+ retries++;
129
+ const nextPort = this.port + retries;
130
+ this.log.warn(`Viewer port ${this.port + retries - 1} in use, trying ${nextPort}`);
131
+ this.server.listen(nextPort, "0.0.0.0");
132
+ }
133
+ else if (err.code === "EADDRINUSE") {
134
+ reject(new Error(`Viewer failed to find open port after ${MAX_PORT_RETRIES} retries (tried ${this.port}–${this.port + MAX_PORT_RETRIES})`));
118
135
  }
119
136
  else {
120
137
  reject(err);
@@ -193,7 +210,8 @@ class ViewerServer {
193
210
  }
194
211
  isValidSession(req) {
195
212
  const cookie = req.headers.cookie ?? "";
196
- const match = cookie.match(/memos_token=([a-f0-9]+)/);
213
+ const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
214
+ const match = cookie.match(re);
197
215
  if (!match)
198
216
  return false;
199
217
  const expiry = this.auth.sessions.get(match[1]);
@@ -313,6 +331,8 @@ class ViewerServer {
313
331
  this.handleSharingChangeRole(req, res);
314
332
  else if (p === "/api/sharing/retry-join" && req.method === "POST")
315
333
  this.handleRetryJoin(req, res);
334
+ else if (p === "/api/sharing/leave" && req.method === "POST")
335
+ this.handleLeaveTeam(req, res);
316
336
  else if (p === "/api/sharing/search/memories" && req.method === "POST")
317
337
  this.handleSharingMemorySearch(req, res);
318
338
  else if (p === "/api/sharing/memories/list" && req.method === "GET")
@@ -353,6 +373,8 @@ class ViewerServer {
353
373
  this.handleSharingNotificationsRead(req, res);
354
374
  else if (p === "/api/sharing/notifications/clear" && req.method === "POST")
355
375
  this.handleSharingNotificationsClear(req, res);
376
+ else if (p === "/api/sharing/sync-hub-removal" && req.method === "POST")
377
+ this.handleSyncHubRemoval(req, res);
356
378
  else if (p === "/api/notifications/stream" && req.method === "GET")
357
379
  this.handleNotifSSE(req, res);
358
380
  else if (p === "/api/admin/shared-tasks" && req.method === "GET")
@@ -440,7 +462,7 @@ class ViewerServer {
440
462
  const token = this.createSession();
441
463
  res.writeHead(200, {
442
464
  "Content-Type": "application/json",
443
- "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
465
+ "Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
444
466
  });
445
467
  res.end(JSON.stringify({ ok: true, message: "Password set successfully" }));
446
468
  }
@@ -462,7 +484,7 @@ class ViewerServer {
462
484
  const token = this.createSession();
463
485
  res.writeHead(200, {
464
486
  "Content-Type": "application/json",
465
- "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
487
+ "Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
466
488
  });
467
489
  res.end(JSON.stringify({ ok: true }));
468
490
  }
@@ -474,12 +496,13 @@ class ViewerServer {
474
496
  }
475
497
  handleLogout(req, res) {
476
498
  const cookie = req.headers.cookie ?? "";
477
- const match = cookie.match(/memos_token=([a-f0-9]+)/);
499
+ const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
500
+ const match = cookie.match(re);
478
501
  if (match)
479
502
  this.auth.sessions.delete(match[1]);
480
503
  res.writeHead(200, {
481
504
  "Content-Type": "application/json",
482
- "Set-Cookie": "memos_token=; Path=/; HttpOnly; Max-Age=0",
505
+ "Set-Cookie": `${this.cookieName}=; Path=/; HttpOnly; Max-Age=0`,
483
506
  });
484
507
  res.end(JSON.stringify({ ok: true }));
485
508
  }
@@ -505,7 +528,7 @@ class ViewerServer {
505
528
  const sessionToken = this.createSession();
506
529
  res.writeHead(200, {
507
530
  "Content-Type": "application/json",
508
- "Set-Cookie": `memos_token=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
531
+ "Set-Cookie": `${this.cookieName}=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
509
532
  });
510
533
  res.end(JSON.stringify({ ok: true, message: "Password reset successfully" }));
511
534
  }
@@ -543,9 +566,8 @@ class ViewerServer {
543
566
  params.push(role);
544
567
  }
545
568
  if (owner && owner.startsWith("agent:")) {
546
- const agentPrefix = owner + ":";
547
- conditions.push("(owner = ? OR (owner = 'public' AND session_key LIKE ?))");
548
- params.push(owner, agentPrefix + "%");
569
+ conditions.push("(owner = ? OR owner = 'public')");
570
+ params.push(owner);
549
571
  }
550
572
  else if (owner) {
551
573
  conditions.push("owner = ?");
@@ -561,7 +583,7 @@ class ViewerServer {
561
583
  }
562
584
  const where = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
563
585
  const totalRow = db.prepare("SELECT COUNT(*) as count FROM chunks" + where).get(...params);
564
- const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
586
+ const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY CASE WHEN dedup_status IN ('duplicate','merged') THEN 1 ELSE 0 END ASC, created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
565
587
  const findMergeSources = db.prepare("SELECT id, summary, role FROM chunks WHERE dedup_target = ? AND (dedup_status = 'merged' OR dedup_status = 'duplicate')");
566
588
  const chunkIds = rawMemories.map((m) => m.id);
567
589
  const sharingMap = new Map();
@@ -572,6 +594,12 @@ class ViewerServer {
572
594
  const sharedRows = db.prepare(`SELECT source_chunk_id, visibility, group_id FROM hub_memories WHERE source_chunk_id IN (${placeholders})`).all(...chunkIds);
573
595
  for (const r of sharedRows)
574
596
  sharingMap.set(r.source_chunk_id, r);
597
+ const teamMetaRows = db.prepare(`SELECT chunk_id, visibility, group_id FROM team_shared_chunks WHERE chunk_id IN (${placeholders})`).all(...chunkIds);
598
+ for (const r of teamMetaRows) {
599
+ if (!sharingMap.has(r.chunk_id)) {
600
+ sharingMap.set(r.chunk_id, { visibility: r.visibility, group_id: r.group_id });
601
+ }
602
+ }
575
603
  const localRows = db.prepare(`SELECT chunk_id, original_owner, shared_at FROM local_shared_memories WHERE chunk_id IN (${placeholders})`).all(...chunkIds);
576
604
  for (const r of localRows)
577
605
  localShareMap.set(r.chunk_id, r);
@@ -728,9 +756,8 @@ class ViewerServer {
728
756
  let sessionQuery;
729
757
  let sessionParams;
730
758
  if (ownerFilter && ownerFilter.startsWith("agent:")) {
731
- const agentPrefix = ownerFilter + ":";
732
- sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE (owner = ? OR (owner = 'public' AND session_key LIKE ?)) GROUP BY session_key ORDER BY latest DESC";
733
- sessionParams = [ownerFilter, agentPrefix + "%"];
759
+ sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE (owner = ? OR owner = 'public') GROUP BY session_key ORDER BY latest DESC";
760
+ sessionParams = [ownerFilter];
734
761
  }
735
762
  else if (ownerFilter) {
736
763
  sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE owner = ? GROUP BY session_key ORDER BY latest DESC";
@@ -758,6 +785,13 @@ class ViewerServer {
758
785
  owners = ownerRows.map((o) => o.owner);
759
786
  }
760
787
  catch { /* column may not exist yet */ }
788
+ let currentAgentOwner = "agent:main";
789
+ try {
790
+ const latest = db.prepare("SELECT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY created_at DESC LIMIT 1").get();
791
+ if (latest?.owner)
792
+ currentAgentOwner = latest.owner;
793
+ }
794
+ catch { /* best-effort */ }
761
795
  this.jsonResponse(res, {
762
796
  totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embCount,
763
797
  totalSkills: skillCount,
@@ -766,6 +800,7 @@ class ViewerServer {
766
800
  timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
767
801
  sessions: sessionList,
768
802
  owners,
803
+ currentAgentOwner,
769
804
  });
770
805
  }
771
806
  catch (e) {
@@ -1154,7 +1189,7 @@ class ViewerServer {
1154
1189
  }
1155
1190
  });
1156
1191
  }
1157
- handleSkillDelete(res, urlPath) {
1192
+ async handleSkillDelete(res, urlPath) {
1158
1193
  const skillId = urlPath.replace("/api/skill/", "");
1159
1194
  const skill = this.store.getSkill(skillId);
1160
1195
  if (!skill) {
@@ -1162,7 +1197,18 @@ class ViewerServer {
1162
1197
  res.end(JSON.stringify({ error: "Skill not found" }));
1163
1198
  return;
1164
1199
  }
1165
- // Remove skill directory from disk
1200
+ try {
1201
+ const hub = this.resolveHubConnection();
1202
+ if (hub) {
1203
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/skills/unpublish", {
1204
+ method: "POST",
1205
+ body: JSON.stringify({ sourceSkillId: skillId }),
1206
+ }).catch(() => { });
1207
+ }
1208
+ const db = this.store.db;
1209
+ db.prepare("DELETE FROM hub_skills WHERE source_skill_id = ?").run(skillId);
1210
+ }
1211
+ catch (_) { }
1166
1212
  try {
1167
1213
  if (skill.dirPath && node_fs_1.default.existsSync(skill.dirPath)) {
1168
1214
  node_fs_1.default.rmSync(skill.dirPath, { recursive: true, force: true });
@@ -1331,8 +1377,6 @@ class ViewerServer {
1331
1377
  }
1332
1378
  let hubSynced = false;
1333
1379
  if (scope === "team") {
1334
- if (!isLocalShared)
1335
- this.store.markMemorySharedLocally(chunkId);
1336
1380
  if (!isTeamShared) {
1337
1381
  const hubClient = await this.resolveHubClientAware();
1338
1382
  const refreshedChunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId);
@@ -1340,18 +1384,29 @@ class ViewerServer {
1340
1384
  method: "POST",
1341
1385
  body: JSON.stringify({ memory: { sourceChunkId: refreshedChunk.id, role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary, kind: refreshedChunk.kind, groupId: null, visibility: "public" } }),
1342
1386
  });
1343
- if (hubClient.userId) {
1387
+ if (!isLocalShared)
1388
+ this.store.markMemorySharedLocally(chunkId);
1389
+ const memoryId = String(response?.memoryId ?? "");
1390
+ const isHubRole = this.ctx?.config?.sharing?.role === "hub";
1391
+ if (hubClient.userId && isHubRole) {
1344
1392
  const existing = this.store.getHubMemoryBySource(hubClient.userId, chunkId);
1345
1393
  this.store.upsertHubMemory({
1346
- id: response?.memoryId ?? existing?.id ?? node_crypto_1.default.randomUUID(),
1394
+ id: memoryId || existing?.id || node_crypto_1.default.randomUUID(),
1347
1395
  sourceChunkId: chunkId, sourceUserId: hubClient.userId,
1348
1396
  role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary ?? "",
1349
1397
  kind: refreshedChunk.kind, groupId: null, visibility: "public",
1350
1398
  createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1351
1399
  });
1352
1400
  }
1401
+ else if (hubClient.userId) {
1402
+ this.store.upsertTeamSharedChunk(chunkId, { hubMemoryId: memoryId, visibility: "public", groupId: null });
1403
+ }
1353
1404
  hubSynced = true;
1354
1405
  }
1406
+ else {
1407
+ if (!isLocalShared)
1408
+ this.store.markMemorySharedLocally(chunkId);
1409
+ }
1355
1410
  }
1356
1411
  else if (scope === "local") {
1357
1412
  if (isTeamShared) {
@@ -1362,6 +1417,7 @@ class ViewerServer {
1362
1417
  });
1363
1418
  if (hubClient.userId)
1364
1419
  this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1420
+ this.store.deleteTeamSharedChunk(chunkId);
1365
1421
  hubSynced = true;
1366
1422
  }
1367
1423
  catch (err) {
@@ -1380,6 +1436,7 @@ class ViewerServer {
1380
1436
  });
1381
1437
  if (hubClient.userId)
1382
1438
  this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1439
+ this.store.deleteTeamSharedChunk(chunkId);
1383
1440
  hubSynced = true;
1384
1441
  }
1385
1442
  catch (err) {
@@ -1419,14 +1476,6 @@ class ViewerServer {
1419
1476
  return this.jsonResponse(res, { ok: true, scope, changed: false });
1420
1477
  }
1421
1478
  let hubSynced = false;
1422
- if (scope === "local" || scope === "team") {
1423
- if (!isLocalShared) {
1424
- const originalOwner = task.owner;
1425
- const db = this.store.db;
1426
- db.prepare("INSERT INTO local_shared_tasks (task_id, hub_task_id, original_owner, shared_at) VALUES (?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET original_owner = excluded.original_owner, shared_at = excluded.shared_at").run(taskId, "", originalOwner, Date.now());
1427
- db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1428
- }
1429
- }
1430
1479
  if (scope === "team") {
1431
1480
  if (!isTeamShared) {
1432
1481
  const chunks = this.store.getChunksByTask(taskId);
@@ -1450,6 +1499,20 @@ class ViewerServer {
1450
1499
  }
1451
1500
  hubSynced = true;
1452
1501
  }
1502
+ if (!isLocalShared) {
1503
+ const originalOwner = task.owner;
1504
+ const db = this.store.db;
1505
+ db.prepare("INSERT INTO local_shared_tasks (task_id, hub_task_id, original_owner, shared_at) VALUES (?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET original_owner = excluded.original_owner, shared_at = excluded.shared_at").run(taskId, "", originalOwner, Date.now());
1506
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1507
+ }
1508
+ }
1509
+ if (scope === "local") {
1510
+ if (!isLocalShared) {
1511
+ const originalOwner = task.owner;
1512
+ const db = this.store.db;
1513
+ db.prepare("INSERT INTO local_shared_tasks (task_id, hub_task_id, original_owner, shared_at) VALUES (?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET original_owner = excluded.original_owner, shared_at = excluded.shared_at").run(taskId, "", originalOwner, Date.now());
1514
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1515
+ }
1453
1516
  }
1454
1517
  if (scope === "local" && isTeamShared) {
1455
1518
  try {
@@ -1520,10 +1583,6 @@ class ViewerServer {
1520
1583
  return this.jsonResponse(res, { ok: true, scope, changed: false });
1521
1584
  }
1522
1585
  let hubSynced = false;
1523
- if (scope === "local" || scope === "team") {
1524
- if (!isLocalShared)
1525
- this.store.setSkillVisibility(skillId, "public");
1526
- }
1527
1586
  if (scope === "team") {
1528
1587
  if (!isTeamShared) {
1529
1588
  const bundle = (0, skill_sync_1.buildSkillBundleForHub)(this.store, skillId);
@@ -1545,6 +1604,12 @@ class ViewerServer {
1545
1604
  }
1546
1605
  hubSynced = true;
1547
1606
  }
1607
+ if (!isLocalShared)
1608
+ this.store.setSkillVisibility(skillId, "public");
1609
+ }
1610
+ if (scope === "local") {
1611
+ if (!isLocalShared)
1612
+ this.store.setSkillVisibility(skillId, "public");
1548
1613
  }
1549
1614
  if (scope === "local" && isTeamShared) {
1550
1615
  try {
@@ -1587,7 +1652,18 @@ class ViewerServer {
1587
1652
  }
1588
1653
  getHubMemoryForChunk(chunkId) {
1589
1654
  const db = this.store.db;
1590
- return db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
1655
+ const hub = db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
1656
+ if (hub)
1657
+ return hub;
1658
+ const ts = this.store.getTeamSharedChunk(chunkId);
1659
+ if (ts) {
1660
+ return {
1661
+ source_chunk_id: chunkId,
1662
+ visibility: ts.visibility,
1663
+ group_id: ts.groupId,
1664
+ };
1665
+ }
1666
+ return undefined;
1591
1667
  }
1592
1668
  getHubTaskForLocal(taskId) {
1593
1669
  const db = this.store.db;
@@ -1633,6 +1709,8 @@ class ViewerServer {
1633
1709
  // ─── Helpers ───
1634
1710
  // ─── Config API ───
1635
1711
  getOpenClawConfigPath() {
1712
+ if (process.env.OPENCLAW_CONFIG_PATH)
1713
+ return process.env.OPENCLAW_CONFIG_PATH;
1636
1714
  const home = process.env.HOME || process.env.USERPROFILE || "";
1637
1715
  const ocHome = process.env.OPENCLAW_STATE_DIR || node_path_1.default.join(home, ".openclaw");
1638
1716
  return node_path_1.default.join(ocHome, "openclaw.json");
@@ -1715,7 +1793,18 @@ class ViewerServer {
1715
1793
  base.connection.apiVersion = info?.apiVersion ?? null;
1716
1794
  }
1717
1795
  catch { /* ignore */ }
1718
- this.jsonResponse(res, base);
1796
+ const hubStats = { totalMembers: 0, onlineMembers: 0, pendingMembers: 0 };
1797
+ try {
1798
+ const activeUsers = this.store.listHubUsers("active");
1799
+ const pendingUsers = this.store.listHubUsers("pending");
1800
+ const now = Date.now();
1801
+ const OFFLINE_THRESHOLD = 120_000;
1802
+ hubStats.totalMembers = activeUsers.length;
1803
+ hubStats.onlineMembers = activeUsers.filter(u => u.lastActiveAt && (now - u.lastActiveAt < OFFLINE_THRESHOLD)).length;
1804
+ hubStats.pendingMembers = pendingUsers.length;
1805
+ }
1806
+ catch { /* best-effort */ }
1807
+ this.jsonResponse(res, { ...base, hubStats });
1719
1808
  return;
1720
1809
  }
1721
1810
  const hasPendingConnection = Boolean(persisted?.hubUrl && persisted?.userId && !persisted?.userToken);
@@ -1732,6 +1821,9 @@ class ViewerServer {
1732
1821
  if (status.user?.status === "rejected") {
1733
1822
  output.connection.rejected = true;
1734
1823
  }
1824
+ if (status.user?.status === "removed") {
1825
+ output.connection.removed = true;
1826
+ }
1735
1827
  if (status.connected && status.hubUrl) {
1736
1828
  try {
1737
1829
  const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null);
@@ -1865,7 +1957,16 @@ class ViewerServer {
1865
1957
  this.jsonResponse(res, { ok: true, result });
1866
1958
  }
1867
1959
  catch (err) {
1868
- this.jsonResponse(res, { ok: false, error: String(err) });
1960
+ const errStr = String(err);
1961
+ if (errStr.includes("username_taken")) {
1962
+ this.jsonResponse(res, { ok: false, error: "username_taken" });
1963
+ }
1964
+ else if (errStr.includes("invalid_params")) {
1965
+ this.jsonResponse(res, { ok: false, error: "invalid_params" });
1966
+ }
1967
+ else {
1968
+ this.jsonResponse(res, { ok: false, error: errStr });
1969
+ }
1869
1970
  }
1870
1971
  });
1871
1972
  }
@@ -1888,10 +1989,13 @@ class ViewerServer {
1888
1989
  const nickname = sharing.client?.nickname;
1889
1990
  const username = nickname || os.userInfo().username || "user";
1890
1991
  const hostname = os.hostname() || "unknown";
1992
+ const persisted = this.store.getClientHubConnection();
1993
+ const existingIdentityKey = persisted?.identityKey || "";
1891
1994
  const result = await (0, hub_1.hubRequestJson)(hubUrl, "", "/api/v1/hub/join", {
1892
1995
  method: "POST",
1893
- body: JSON.stringify({ teamToken, username, deviceName: hostname }),
1996
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true, identityKey: existingIdentityKey }),
1894
1997
  });
1998
+ const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
1895
1999
  this.store.setClientHubConnection({
1896
2000
  hubUrl,
1897
2001
  userId: String(result.userId || ""),
@@ -1899,6 +2003,8 @@ class ViewerServer {
1899
2003
  userToken: result.userToken || "",
1900
2004
  role: "member",
1901
2005
  connectedAt: Date.now(),
2006
+ identityKey: returnedIdentityKey,
2007
+ lastKnownStatus: result.status || "",
1902
2008
  });
1903
2009
  this.jsonResponse(res, { ok: true, status: result.status || "pending" });
1904
2010
  }
@@ -2194,14 +2300,14 @@ class ViewerServer {
2194
2300
  },
2195
2301
  }),
2196
2302
  });
2197
- const hubUserId = hubClient.userId;
2198
- if (hubUserId) {
2303
+ const mid = String(response?.memoryId ?? "");
2304
+ if (hubClient.userId && this.ctx?.config?.sharing?.role === "hub") {
2199
2305
  const now = Date.now();
2200
- const existing = this.store.getHubMemoryBySource(hubUserId, chunk.id);
2306
+ const existing = this.store.getHubMemoryBySource(hubClient.userId, chunk.id);
2201
2307
  this.store.upsertHubMemory({
2202
- id: response?.memoryId ?? existing?.id ?? node_crypto_1.default.randomUUID(),
2308
+ id: mid || existing?.id || node_crypto_1.default.randomUUID(),
2203
2309
  sourceChunkId: chunk.id,
2204
- sourceUserId: hubUserId,
2310
+ sourceUserId: hubClient.userId,
2205
2311
  role: chunk.role,
2206
2312
  content: chunk.content,
2207
2313
  summary: chunk.summary ?? "",
@@ -2212,6 +2318,9 @@ class ViewerServer {
2212
2318
  updatedAt: now,
2213
2319
  });
2214
2320
  }
2321
+ else if (hubClient.userId) {
2322
+ this.store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: mid, visibility, groupId });
2323
+ }
2215
2324
  this.jsonResponse(res, { ok: true, chunkId, visibility, response });
2216
2325
  }
2217
2326
  catch (err) {
@@ -2234,6 +2343,7 @@ class ViewerServer {
2234
2343
  const hubUserId = hubClient.userId;
2235
2344
  if (hubUserId)
2236
2345
  this.store.deleteHubMemoryBySource(hubUserId, chunkId);
2346
+ this.store.deleteTeamSharedChunk(chunkId);
2237
2347
  this.jsonResponse(res, { ok: true, chunkId });
2238
2348
  }
2239
2349
  catch (err) {
@@ -2336,7 +2446,7 @@ class ViewerServer {
2336
2446
  // Hub 模式:连接自己,用 bootstrap admin token
2337
2447
  const sharing = this.ctx.config.sharing;
2338
2448
  if (sharing?.role === "hub") {
2339
- const hubPort = sharing.hub?.port ?? 18800;
2449
+ const hubPort = this.getHubPort();
2340
2450
  const hubUrl = `http://127.0.0.1:${hubPort}`;
2341
2451
  try {
2342
2452
  const authPath = node_path_1.default.join(this.dataDir, "hub-auth.json");
@@ -2578,6 +2688,32 @@ class ViewerServer {
2578
2688
  }
2579
2689
  });
2580
2690
  }
2691
+ /** Badge-only: clear Client team-share UI metadata when Hub admin removes that memory. Does NOT touch chunks, embeddings, or hub_memories (recall paths). */
2692
+ handleSyncHubRemoval(req, res) {
2693
+ this.readBody(req, (body) => {
2694
+ try {
2695
+ const parsed = JSON.parse(body || "{}");
2696
+ const sourceChunkId = String(parsed.sourceChunkId || "");
2697
+ const memoryIdFromNotif = parsed.memoryId != null && parsed.memoryId !== "" ? String(parsed.memoryId) : "";
2698
+ if (!sourceChunkId)
2699
+ return this.jsonResponse(res, { ok: false, error: "missing_source_chunk_id" }, 400);
2700
+ // Admin removal notifications stay in the feed; if the user re-shared, team_shared_chunks has a new hub_memory_id.
2701
+ // Only clear the badge when this notification refers to the same Hub row we still track (or no id — legacy).
2702
+ if (memoryIdFromNotif) {
2703
+ const current = this.store.getTeamSharedChunk(sourceChunkId);
2704
+ const curId = current?.hubMemoryId ? String(current.hubMemoryId) : "";
2705
+ if (curId && curId !== memoryIdFromNotif) {
2706
+ return this.jsonResponse(res, { ok: true, sourceChunkId, skipped: true, reason: "stale_notification_re_shared" });
2707
+ }
2708
+ }
2709
+ this.store.deleteTeamSharedChunk(sourceChunkId);
2710
+ this.jsonResponse(res, { ok: true, sourceChunkId });
2711
+ }
2712
+ catch (e) {
2713
+ this.jsonResponse(res, { ok: false, error: String(e) }, 500);
2714
+ }
2715
+ });
2716
+ }
2581
2717
  handleNotifSSE(req, res) {
2582
2718
  res.writeHead(200, {
2583
2719
  "Content-Type": "text/event-stream",
@@ -2589,6 +2725,8 @@ class ViewerServer {
2589
2725
  this.notifSSEClients.push(res);
2590
2726
  if (!this.notifPollTimer)
2591
2727
  this.startNotifPoll();
2728
+ else
2729
+ this.notifPollImmediate();
2592
2730
  req.on("close", () => {
2593
2731
  this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
2594
2732
  if (this.notifSSEClients.length === 0)
@@ -2632,6 +2770,20 @@ class ViewerServer {
2632
2770
  this.notifPollTimer = undefined;
2633
2771
  }
2634
2772
  }
2773
+ notifPollImmediate() {
2774
+ const hub = this.resolveHubConnection();
2775
+ if (!hub)
2776
+ return;
2777
+ (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")
2778
+ .then((data) => {
2779
+ const count = data?.unreadCount ?? 0;
2780
+ if (count !== this.lastKnownNotifCount) {
2781
+ this.lastKnownNotifCount = count;
2782
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2783
+ }
2784
+ })
2785
+ .catch(() => { });
2786
+ }
2635
2787
  startHubHeartbeat() {
2636
2788
  this.stopHubHeartbeat();
2637
2789
  const sendHeartbeat = async () => {
@@ -2733,7 +2885,10 @@ class ViewerServer {
2733
2885
  if (!entry.config)
2734
2886
  entry.config = {};
2735
2887
  const config = entry.config;
2736
- const oldSharingRole = config.sharing?.role;
2888
+ const oldSharing = config.sharing;
2889
+ const oldSharingRole = oldSharing?.role;
2890
+ const oldSharingEnabled = Boolean(oldSharing?.enabled);
2891
+ const oldClientHubAddress = String(oldSharing?.client?.hubAddress || "");
2737
2892
  if (newCfg.embedding)
2738
2893
  config.embedding = newCfg.embedding;
2739
2894
  if (newCfg.summarizer)
@@ -2753,12 +2908,14 @@ class ViewerServer {
2753
2908
  if (merged.role === "client" && merged.client) {
2754
2909
  const clientCfg = merged.client;
2755
2910
  const addr = String(clientCfg.hubAddress || "");
2756
- if (addr) {
2911
+ if (addr && oldSharingRole === "hub" && oldSharingEnabled) {
2912
+ const selfHubPort = oldSharing?.hub?.port ?? 18800;
2757
2913
  const localIPs = this.getLocalIPs();
2758
2914
  localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2759
2915
  try {
2760
2916
  const u = new URL(addr.startsWith("http") ? addr : `http://${addr}`);
2761
- if (localIPs.includes(u.hostname)) {
2917
+ const targetPort = u.port || (u.protocol === "https:" ? "443" : "80");
2918
+ if (localIPs.includes(u.hostname) && targetPort === String(selfHubPort)) {
2762
2919
  res.writeHead(400, { "Content-Type": "application/json" });
2763
2920
  res.end(JSON.stringify({ error: "cannot_join_self" }));
2764
2921
  return;
@@ -2767,16 +2924,40 @@ class ViewerServer {
2767
2924
  catch { }
2768
2925
  }
2769
2926
  }
2770
- // When switching away from client mode, notify Hub that we're leaving
2771
2927
  const newRole = merged.role;
2772
- if (oldSharingRole === "client" && newRole !== "client") {
2773
- this.notifyHubLeave();
2928
+ const newEnabled = Boolean(merged.enabled);
2929
+ // Detect disabling sharing or switching away from hub mode
2930
+ const wasHub = oldSharingEnabled && oldSharingRole === "hub";
2931
+ const isHub = newEnabled && newRole === "hub";
2932
+ if (wasHub && !isHub) {
2933
+ await this.notifyHubShutdown();
2934
+ this.stopHubHeartbeat();
2935
+ this.log.info("Hub shutting down: notified connected clients");
2936
+ }
2937
+ // Detect disabling sharing or switching away from client mode
2938
+ const wasClient = oldSharingEnabled && oldSharingRole === "client";
2939
+ const isClient = newEnabled && newRole === "client";
2940
+ if (wasClient && !isClient) {
2941
+ await this.withdrawOrLeaveHub();
2942
+ this.store.clearClientHubConnection();
2943
+ this.log.info("Client hub connection cleared (sharing disabled or role changed)");
2944
+ }
2945
+ if (wasClient && isClient) {
2946
+ const newClientAddr = String(merged.client?.hubAddress || "");
2947
+ if (newClientAddr && oldClientHubAddress && (0, hub_1.normalizeHubUrl)(newClientAddr) !== (0, hub_1.normalizeHubUrl)(oldClientHubAddress)) {
2948
+ this.notifyHubLeave();
2949
+ const oldConn = this.store.getClientHubConnection();
2950
+ if (oldConn) {
2951
+ this.store.setClientHubConnection({ ...oldConn, hubUrl: (0, hub_1.normalizeHubUrl)(newClientAddr), userToken: "", lastKnownStatus: "hub_changed" });
2952
+ }
2953
+ this.log.info("Client hub connection token cleared (switched to different Hub), identity preserved");
2954
+ }
2774
2955
  }
2775
2956
  if (merged.role === "hub") {
2776
2957
  merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2777
2958
  }
2778
2959
  else if (merged.role === "client") {
2779
- merged.hub = { port: 18800, teamName: "", teamToken: "" };
2960
+ merged.hub = { teamName: "", teamToken: "" };
2780
2961
  }
2781
2962
  config.sharing = merged;
2782
2963
  }
@@ -2784,7 +2965,29 @@ class ViewerServer {
2784
2965
  node_fs_1.default.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
2785
2966
  this.log.info("Plugin config updated via Viewer");
2786
2967
  this.stopHubHeartbeat();
2787
- this.jsonResponse(res, { ok: true });
2968
+ // When switching to client mode or re-enabling sharing as client, send join request
2969
+ const finalSharing = config.sharing;
2970
+ const nowClient = Boolean(finalSharing?.enabled) && finalSharing?.role === "client";
2971
+ const previouslyClient = oldSharingEnabled && oldSharingRole === "client";
2972
+ let joinStatus;
2973
+ if (nowClient && !previouslyClient) {
2974
+ try {
2975
+ joinStatus = await this.autoJoinOnSave(finalSharing);
2976
+ }
2977
+ catch (e) {
2978
+ this.log.warn(`Auto-join on save failed: ${e}`);
2979
+ }
2980
+ }
2981
+ this.jsonResponse(res, { ok: true, joinStatus, restart: true });
2982
+ setTimeout(() => {
2983
+ this.log.info("config-save: triggering gateway restart via SIGUSR1...");
2984
+ try {
2985
+ process.kill(process.pid, "SIGUSR1");
2986
+ }
2987
+ catch (sig) {
2988
+ this.log.warn(`SIGUSR1 failed: ${sig}`);
2989
+ }
2990
+ }, 500);
2788
2991
  }
2789
2992
  catch (e) {
2790
2993
  this.log.warn(`handleSaveConfig error: ${e}`);
@@ -2793,6 +2996,77 @@ class ViewerServer {
2793
2996
  }
2794
2997
  });
2795
2998
  }
2999
+ async autoJoinOnSave(sharing) {
3000
+ const clientCfg = sharing.client;
3001
+ const hubAddress = String(clientCfg?.hubAddress || "");
3002
+ const teamToken = String(clientCfg?.teamToken || "");
3003
+ if (!hubAddress || !teamToken)
3004
+ return undefined;
3005
+ const hubUrl = (0, hub_1.normalizeHubUrl)(hubAddress);
3006
+ const os = await Promise.resolve().then(() => __importStar(require("os")));
3007
+ const nickname = String(clientCfg?.nickname || "");
3008
+ const username = nickname || os.userInfo().username || "user";
3009
+ const hostname = os.hostname() || "unknown";
3010
+ const persisted = this.store.getClientHubConnection();
3011
+ const existingIdentityKey = persisted?.identityKey || "";
3012
+ const result = await (0, hub_1.hubRequestJson)(hubUrl, "", "/api/v1/hub/join", {
3013
+ method: "POST",
3014
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, identityKey: existingIdentityKey }),
3015
+ });
3016
+ const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
3017
+ this.store.setClientHubConnection({
3018
+ hubUrl,
3019
+ userId: String(result.userId || ""),
3020
+ username,
3021
+ userToken: result.userToken || "",
3022
+ role: "member",
3023
+ connectedAt: Date.now(),
3024
+ identityKey: returnedIdentityKey,
3025
+ lastKnownStatus: result.status || "",
3026
+ });
3027
+ this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
3028
+ if (result.userToken) {
3029
+ this.startHubHeartbeat();
3030
+ }
3031
+ return result.status;
3032
+ }
3033
+ handleLeaveTeam(_req, res) {
3034
+ this.readBody(_req, async () => {
3035
+ try {
3036
+ await this.withdrawOrLeaveHub();
3037
+ this.store.clearClientHubConnection();
3038
+ const configPath = this.getOpenClawConfigPath();
3039
+ if (configPath && node_fs_1.default.existsSync(configPath)) {
3040
+ const raw = JSON.parse(node_fs_1.default.readFileSync(configPath, "utf8"));
3041
+ const pluginKey = Object.keys(raw.plugins?.entries ?? {}).find(k => k.includes("memos-local"));
3042
+ if (pluginKey) {
3043
+ const cfg = raw.plugins.entries[pluginKey].config ?? {};
3044
+ if (cfg.sharing) {
3045
+ cfg.sharing.enabled = false;
3046
+ cfg.sharing.client = { hubAddress: "", userToken: "", teamToken: "" };
3047
+ }
3048
+ raw.plugins.entries[pluginKey].config = cfg;
3049
+ node_fs_1.default.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n");
3050
+ this.log.info("handleLeaveTeam: config updated, sharing disabled");
3051
+ }
3052
+ }
3053
+ this.jsonResponse(res, { ok: true, restart: true });
3054
+ setTimeout(() => {
3055
+ this.log.info("handleLeaveTeam: triggering gateway restart via SIGUSR1...");
3056
+ try {
3057
+ process.kill(process.pid, "SIGUSR1");
3058
+ }
3059
+ catch (sig) {
3060
+ this.log.warn(`SIGUSR1 failed: ${sig}`);
3061
+ }
3062
+ }, 500);
3063
+ }
3064
+ catch (e) {
3065
+ this.log.warn(`handleLeaveTeam error: ${e}`);
3066
+ this.jsonResponse(res, { ok: false, error: String(e) });
3067
+ }
3068
+ });
3069
+ }
2796
3070
  async notifyHubLeave() {
2797
3071
  try {
2798
3072
  const hub = this.resolveHubConnection();
@@ -2811,6 +3085,80 @@ class ViewerServer {
2811
3085
  this.log.warn(`Failed to notify Hub of leave: ${e}`);
2812
3086
  }
2813
3087
  }
3088
+ async withdrawOrLeaveHub() {
3089
+ try {
3090
+ const persisted = this.store.getClientHubConnection();
3091
+ const sharing = this.ctx?.config?.sharing;
3092
+ if (persisted?.userToken && persisted?.hubUrl) {
3093
+ await (0, hub_1.hubRequestJson)(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
3094
+ this.log.info("Notified Hub of voluntary leave (had token)");
3095
+ return;
3096
+ }
3097
+ const hub = this.resolveHubConnection();
3098
+ if (hub?.userToken) {
3099
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
3100
+ this.log.info("Notified Hub of voluntary leave (resolved connection)");
3101
+ return;
3102
+ }
3103
+ const hubUrl = persisted?.hubUrl || (sharing?.client?.hubAddress ? (0, hub_1.normalizeHubUrl)(sharing.client.hubAddress) : null);
3104
+ const userId = persisted?.userId;
3105
+ const teamToken = sharing?.client?.teamToken;
3106
+ if (hubUrl && userId && teamToken) {
3107
+ const withdrawUrl = `${(0, hub_1.normalizeHubUrl)(hubUrl)}/api/v1/hub/withdraw-pending`;
3108
+ await fetch(withdrawUrl, {
3109
+ method: "POST",
3110
+ headers: { "content-type": "application/json" },
3111
+ body: JSON.stringify({ teamToken, userId }),
3112
+ });
3113
+ this.log.info("Withdrew pending application from Hub");
3114
+ return;
3115
+ }
3116
+ this.log.info("No hub connection to clean up (no token, no pending)");
3117
+ }
3118
+ catch (e) {
3119
+ this.log.warn(`Failed to withdraw/leave Hub: ${e}`);
3120
+ }
3121
+ }
3122
+ async notifyHubShutdown() {
3123
+ try {
3124
+ const sharing = this.ctx?.config.sharing;
3125
+ if (!sharing || sharing.role !== "hub")
3126
+ return;
3127
+ const hubPort = this.getHubPort();
3128
+ const authPath = node_path_1.default.join(this.dataDir, "hub-auth.json");
3129
+ let adminToken;
3130
+ try {
3131
+ const authData = JSON.parse(node_fs_1.default.readFileSync(authPath, "utf8"));
3132
+ adminToken = authData?.bootstrapAdminToken;
3133
+ }
3134
+ catch {
3135
+ return;
3136
+ }
3137
+ if (!adminToken)
3138
+ return;
3139
+ const users = this.store.listHubUsers("active");
3140
+ const { v4: uuidv4 } = require("uuid");
3141
+ for (const u of users) {
3142
+ try {
3143
+ this.store.insertHubNotification({
3144
+ id: uuidv4(),
3145
+ userId: u.id,
3146
+ type: "hub_shutdown",
3147
+ resource: "hub",
3148
+ title: "Hub is shutting down",
3149
+ message: "The Hub server is shutting down. You may be disconnected.",
3150
+ });
3151
+ }
3152
+ catch (e) {
3153
+ this.log.warn(`Failed to insert shutdown notification for user ${u.id}: ${e}`);
3154
+ }
3155
+ }
3156
+ this.log.info(`Hub shutdown: notified ${users.length} approved user(s)`);
3157
+ }
3158
+ catch (e) {
3159
+ this.log.warn(`notifyHubShutdown error: ${e}`);
3160
+ }
3161
+ }
2814
3162
  handleUpdateUsername(req, res) {
2815
3163
  this.readBody(req, async (body) => {
2816
3164
  if (!this.ctx)
@@ -2841,10 +3189,10 @@ class ViewerServer {
2841
3189
  }
2842
3190
  }
2843
3191
  else {
2844
- const persisted = this.store.getClientHubConnection();
2845
- if (persisted) {
3192
+ const persistedConn = this.store.getClientHubConnection();
3193
+ if (persistedConn) {
2846
3194
  this.store.setClientHubConnection({
2847
- ...persisted,
3195
+ ...persistedConn,
2848
3196
  username: result.username,
2849
3197
  userToken: result.userToken,
2850
3198
  });
@@ -2871,12 +3219,17 @@ class ViewerServer {
2871
3219
  return;
2872
3220
  }
2873
3221
  try {
2874
- const localIPs = this.getLocalIPs();
2875
- localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2876
- const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
2877
- if (localIPs.includes(parsed.hostname)) {
2878
- this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
2879
- return;
3222
+ const sharing = this.ctx?.config?.sharing;
3223
+ if (sharing?.enabled && sharing.role === "hub") {
3224
+ const selfHubPort = this.getHubPort();
3225
+ const localIPs = this.getLocalIPs();
3226
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
3227
+ const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
3228
+ const targetPort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
3229
+ if (localIPs.includes(parsed.hostname) && targetPort === String(selfHubPort)) {
3230
+ this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
3231
+ return;
3232
+ }
2880
3233
  }
2881
3234
  }
2882
3235
  catch { }
@@ -3134,10 +3487,14 @@ class ViewerServer {
3134
3487
  catch { }
3135
3488
  this.log.info(`update-install: success! Updated to ${newVersion}`);
3136
3489
  this.jsonResponse(res, { ok: true, version: newVersion });
3137
- // Trigger Gateway restart after response is sent
3138
3490
  setTimeout(() => {
3139
- this.log.info(`update-install: triggering gateway restart...`);
3140
- process.kill(process.pid, "SIGUSR1");
3491
+ this.log.info(`update-install: triggering gateway restart via SIGUSR1...`);
3492
+ try {
3493
+ process.kill(process.pid, "SIGUSR1");
3494
+ }
3495
+ catch (sig) {
3496
+ this.log.warn(`SIGUSR1 failed: ${sig}`);
3497
+ }
3141
3498
  }, 500);
3142
3499
  });
3143
3500
  });
@@ -3313,7 +3670,7 @@ class ViewerServer {
3313
3670
  try {
3314
3671
  const ocHome = this.getOpenClawHome();
3315
3672
  const memoryDir = node_path_1.default.join(ocHome, "memory");
3316
- const sessionsDir = node_path_1.default.join(ocHome, "agents", "main", "sessions");
3673
+ const agentsDir = node_path_1.default.join(ocHome, "agents");
3317
3674
  const sqliteFiles = [];
3318
3675
  if (node_fs_1.default.existsSync(memoryDir)) {
3319
3676
  for (const f of node_fs_1.default.readdirSync(memoryDir)) {
@@ -3331,38 +3688,45 @@ class ViewerServer {
3331
3688
  }
3332
3689
  let sessionCount = 0;
3333
3690
  let messageCount = 0;
3334
- if (node_fs_1.default.existsSync(sessionsDir)) {
3335
- const jsonlFiles = node_fs_1.default.readdirSync(sessionsDir).filter(f => f.includes(".jsonl"));
3336
- sessionCount = jsonlFiles.length;
3337
- for (const f of jsonlFiles) {
3338
- try {
3339
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(sessionsDir, f), "utf-8");
3340
- const lines = content.split("\n").filter(l => l.trim());
3341
- for (const line of lines) {
3342
- try {
3343
- const obj = JSON.parse(line);
3344
- if (obj.type === "message") {
3345
- const role = obj.message?.role ?? obj.role;
3346
- if (role === "user" || role === "assistant") {
3347
- const mc = obj.message?.content ?? obj.content;
3348
- let txt = "";
3349
- if (typeof mc === "string")
3350
- txt = mc;
3351
- else if (Array.isArray(mc))
3352
- txt = mc.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
3353
- else
3354
- txt = JSON.stringify(mc);
3355
- if (role === "user")
3356
- txt = (0, capture_1.stripInboundMetadata)(txt);
3357
- if (txt && txt.length >= 10)
3358
- messageCount++;
3691
+ if (node_fs_1.default.existsSync(agentsDir)) {
3692
+ for (const entry of node_fs_1.default.readdirSync(agentsDir, { withFileTypes: true })) {
3693
+ if (!entry.isDirectory())
3694
+ continue;
3695
+ const sessDir = node_path_1.default.join(agentsDir, entry.name, "sessions");
3696
+ if (!node_fs_1.default.existsSync(sessDir))
3697
+ continue;
3698
+ const jsonlFiles = node_fs_1.default.readdirSync(sessDir).filter(f => f.includes(".jsonl"));
3699
+ sessionCount += jsonlFiles.length;
3700
+ for (const f of jsonlFiles) {
3701
+ try {
3702
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(sessDir, f), "utf-8");
3703
+ const lines = content.split("\n").filter(l => l.trim());
3704
+ for (const line of lines) {
3705
+ try {
3706
+ const obj = JSON.parse(line);
3707
+ if (obj.type === "message") {
3708
+ const role = obj.message?.role ?? obj.role;
3709
+ if (role === "user" || role === "assistant") {
3710
+ const mc = obj.message?.content ?? obj.content;
3711
+ let txt = "";
3712
+ if (typeof mc === "string")
3713
+ txt = mc;
3714
+ else if (Array.isArray(mc))
3715
+ txt = mc.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
3716
+ else
3717
+ txt = JSON.stringify(mc);
3718
+ if (role === "user")
3719
+ txt = (0, capture_1.stripInboundMetadata)(txt);
3720
+ if (txt && txt.length >= 10)
3721
+ messageCount++;
3722
+ }
3359
3723
  }
3360
3724
  }
3725
+ catch { /* skip bad lines */ }
3361
3726
  }
3362
- catch { /* skip bad lines */ }
3363
3727
  }
3728
+ catch { /* skip unreadable */ }
3364
3729
  }
3365
- catch { /* skip unreadable */ }
3366
3730
  }
3367
3731
  }
3368
3732
  const cfgPath = this.getOpenClawConfigPath();