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

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 (63) hide show
  1. package/README.md +1 -1
  2. package/dist/capture/index.d.ts.map +1 -1
  3. package/dist/capture/index.js +6 -0
  4. package/dist/capture/index.js.map +1 -1
  5. package/dist/client/connector.d.ts.map +1 -1
  6. package/dist/client/connector.js +61 -7
  7. package/dist/client/connector.js.map +1 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +0 -2
  10. package/dist/config.js.map +1 -1
  11. package/dist/hub/server.d.ts +7 -0
  12. package/dist/hub/server.d.ts.map +1 -1
  13. package/dist/hub/server.js +171 -8
  14. package/dist/hub/server.js.map +1 -1
  15. package/dist/ingest/providers/index.d.ts.map +1 -1
  16. package/dist/ingest/providers/index.js +37 -6
  17. package/dist/ingest/providers/index.js.map +1 -1
  18. package/dist/recall/engine.d.ts.map +1 -1
  19. package/dist/recall/engine.js +78 -1
  20. package/dist/recall/engine.js.map +1 -1
  21. package/dist/shared/llm-call.d.ts +1 -0
  22. package/dist/shared/llm-call.d.ts.map +1 -1
  23. package/dist/shared/llm-call.js +82 -8
  24. package/dist/shared/llm-call.js.map +1 -1
  25. package/dist/skill/evolver.d.ts +2 -0
  26. package/dist/skill/evolver.d.ts.map +1 -1
  27. package/dist/skill/evolver.js +3 -0
  28. package/dist/skill/evolver.js.map +1 -1
  29. package/dist/storage/sqlite.d.ts +5 -1
  30. package/dist/storage/sqlite.d.ts.map +1 -1
  31. package/dist/storage/sqlite.js +13 -4
  32. package/dist/storage/sqlite.js.map +1 -1
  33. package/dist/telemetry.d.ts +12 -5
  34. package/dist/telemetry.d.ts.map +1 -1
  35. package/dist/telemetry.js +135 -38
  36. package/dist/telemetry.js.map +1 -1
  37. package/dist/types.d.ts +1 -2
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/types.js.map +1 -1
  40. package/dist/viewer/html.d.ts.map +1 -1
  41. package/dist/viewer/html.js +735 -285
  42. package/dist/viewer/html.js.map +1 -1
  43. package/dist/viewer/server.d.ts +16 -0
  44. package/dist/viewer/server.d.ts.map +1 -1
  45. package/dist/viewer/server.js +349 -21
  46. package/dist/viewer/server.js.map +1 -1
  47. package/index.ts +26 -2
  48. package/openclaw.plugin.json +1 -1
  49. package/package.json +1 -2
  50. package/scripts/postinstall.cjs +1 -1
  51. package/src/capture/index.ts +8 -0
  52. package/src/client/connector.ts +62 -7
  53. package/src/config.ts +0 -2
  54. package/src/hub/server.ts +168 -8
  55. package/src/ingest/providers/index.ts +41 -7
  56. package/src/recall/engine.ts +73 -1
  57. package/src/shared/llm-call.ts +97 -9
  58. package/src/skill/evolver.ts +5 -0
  59. package/src/storage/sqlite.ts +19 -6
  60. package/src/telemetry.ts +152 -39
  61. package/src/types.ts +1 -2
  62. package/src/viewer/html.ts +735 -285
  63. package/src/viewer/server.ts +322 -21
@@ -84,6 +84,12 @@ export class ViewerServer {
84
84
  { running: false, done: false, stopped: false, processed: 0, total: 0, tasksCreated: 0, skillsCreated: 0, errors: 0, skippedSessions: 0, totalSessions: 0 };
85
85
  private ppSSEClients: http.ServerResponse[] = [];
86
86
 
87
+ private notifSSEClients: http.ServerResponse[] = [];
88
+ private notifPollTimer?: ReturnType<typeof setInterval>;
89
+ private lastKnownNotifCount = 0;
90
+ private hubHeartbeatTimer?: ReturnType<typeof setInterval>;
91
+ private static readonly HUB_HEARTBEAT_INTERVAL_MS = 45_000;
92
+
87
93
  constructor(opts: ViewerServerOptions) {
88
94
  this.store = opts.store;
89
95
  this.embedder = opts.embedder;
@@ -103,15 +109,16 @@ export class ViewerServer {
103
109
  this.server.on("error", (err: NodeJS.ErrnoException) => {
104
110
  if (err.code === "EADDRINUSE") {
105
111
  this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
106
- this.server!.listen(this.port + 1, "127.0.0.1");
112
+ this.server!.listen(this.port + 1, "0.0.0.0");
107
113
  } else {
108
114
  reject(err);
109
115
  }
110
116
  });
111
- this.server.listen(this.port, "127.0.0.1", () => {
117
+ this.server.listen(this.port, "0.0.0.0", () => {
112
118
  const addr = this.server!.address();
113
119
  const actualPort = typeof addr === "object" && addr ? addr.port : this.port;
114
120
  this.autoCleanupPolluted();
121
+ this.startHubHeartbeat();
115
122
  resolve(`http://127.0.0.1:${actualPort}`);
116
123
  });
117
124
  });
@@ -134,6 +141,10 @@ export class ViewerServer {
134
141
  }
135
142
 
136
143
  stop(): void {
144
+ this.stopHubHeartbeat();
145
+ this.stopNotifPoll();
146
+ for (const c of this.notifSSEClients) { try { c.end(); } catch {} }
147
+ this.notifSSEClients = [];
137
148
  this.server?.close();
138
149
  this.server = null;
139
150
  }
@@ -279,9 +290,12 @@ export class ViewerServer {
279
290
  else if (p === "/api/sharing/notifications" && req.method === "GET") this.serveSharingNotifications(res, url);
280
291
  else if (p === "/api/sharing/notifications/read" && req.method === "POST") this.handleSharingNotificationsRead(req, res);
281
292
  else if (p === "/api/sharing/notifications/clear" && req.method === "POST") this.handleSharingNotificationsClear(req, res);
293
+ else if (p === "/api/notifications/stream" && req.method === "GET") this.handleNotifSSE(req, res);
282
294
  else if (p === "/api/admin/shared-tasks" && req.method === "GET") this.serveAdminSharedTasks(res);
295
+ else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+\/detail$/) && req.method === "GET") this.serveHubTaskDetail(res, p);
283
296
  else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteTask(res, p);
284
297
  else if (p === "/api/admin/shared-skills" && req.method === "GET") this.serveAdminSharedSkills(res);
298
+ else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+\/detail$/) && req.method === "GET") this.serveHubSkillDetail(res, p);
285
299
  else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteSkill(res, p);
286
300
  else if (p === "/api/admin/shared-memories" && req.method === "GET") this.serveAdminSharedMemories(res);
287
301
  else if (p.match(/^\/api\/admin\/shared-memories\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteMemory(res, p);
@@ -436,7 +450,13 @@ export class ViewerServer {
436
450
  const params: any[] = [];
437
451
  if (session) { conditions.push("session_key = ?"); params.push(session); }
438
452
  if (role) { conditions.push("role = ?"); params.push(role); }
439
- if (owner) { conditions.push("owner = ?"); params.push(owner); }
453
+ if (owner && owner.startsWith("agent:")) {
454
+ const agentPrefix = owner + ":";
455
+ conditions.push("(owner = ? OR (owner = 'public' AND session_key LIKE ?))");
456
+ params.push(owner, agentPrefix + "%");
457
+ } else if (owner) {
458
+ conditions.push("owner = ?"); params.push(owner);
459
+ }
440
460
  if (dateFrom) { conditions.push("created_at >= ?"); params.push(new Date(dateFrom).getTime()); }
441
461
  if (dateTo) { conditions.push("created_at <= ?"); params.push(new Date(dateTo).getTime()); }
442
462
 
@@ -510,9 +530,10 @@ export class ViewerServer {
510
530
  private serveTasks(res: http.ServerResponse, url: URL): void {
511
531
  this.store.recordViewerEvent("tasks_list");
512
532
  const status = url.searchParams.get("status") ?? undefined;
533
+ const owner = url.searchParams.get("owner") ?? undefined;
513
534
  const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit")) || 50));
514
535
  const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
515
- const { tasks, total } = this.store.listTasks({ status, limit, offset });
536
+ const { tasks, total } = this.store.listTasks({ status, limit, offset, owner });
516
537
 
517
538
  const db = (this.store as any).db;
518
539
  const items = tasks.map((t) => {
@@ -616,12 +637,20 @@ export class ViewerServer {
616
637
  }
617
638
  let embCount = 0;
618
639
  try { embCount = (db.prepare("SELECT COUNT(*) as count FROM embeddings").get() as any).count; } catch { /* table may not exist */ }
619
- const sessionQuery = ownerFilter
620
- ? "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"
621
- : "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks GROUP BY session_key ORDER BY latest DESC";
622
- const sessionList = (ownerFilter
623
- ? db.prepare(sessionQuery).all(ownerFilter)
624
- : db.prepare(sessionQuery).all()) as any[];
640
+ let sessionQuery: string;
641
+ let sessionParams: any[];
642
+ if (ownerFilter && ownerFilter.startsWith("agent:")) {
643
+ const agentPrefix = ownerFilter + ":";
644
+ 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";
645
+ sessionParams = [ownerFilter, agentPrefix + "%"];
646
+ } else if (ownerFilter) {
647
+ 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";
648
+ sessionParams = [ownerFilter];
649
+ } else {
650
+ sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks GROUP BY session_key ORDER BY latest DESC";
651
+ sessionParams = [];
652
+ }
653
+ const sessionList = db.prepare(sessionQuery).all(...sessionParams) as any[];
625
654
 
626
655
  let skillCount = 0;
627
656
  try { skillCount = (db.prepare("SELECT COUNT(*) as count FROM skills").get() as any).count; } catch { /* table may not exist yet */ }
@@ -634,10 +663,16 @@ export class ViewerServer {
634
663
 
635
664
  let owners: string[] = [];
636
665
  try {
637
- const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL ORDER BY owner").all() as any[];
666
+ const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY owner").all() as any[];
638
667
  owners = ownerRows.map((o: any) => o.owner);
639
668
  } catch { /* column may not exist yet */ }
640
669
 
670
+ let currentAgentOwner = "agent:main";
671
+ try {
672
+ 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() as any;
673
+ if (latest?.owner) currentAgentOwner = latest.owner;
674
+ } catch { /* best-effort */ }
675
+
641
676
  this.jsonResponse(res, {
642
677
  totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embCount,
643
678
  totalSkills: skillCount,
@@ -646,6 +681,7 @@ export class ViewerServer {
646
681
  timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
647
682
  sessions: sessionList,
648
683
  owners,
684
+ currentAgentOwner,
649
685
  });
650
686
  } catch (e) {
651
687
  this.log.warn(`stats error: ${e}`);
@@ -1470,7 +1506,8 @@ export class ViewerServer {
1470
1506
 
1471
1507
  private getOpenClawConfigPath(): string {
1472
1508
  const home = process.env.HOME || process.env.USERPROFILE || "";
1473
- return path.join(home, ".openclaw", "openclaw.json");
1509
+ const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
1510
+ return path.join(ocHome, "openclaw.json");
1474
1511
  }
1475
1512
 
1476
1513
  private getPluginEntryConfig(raw: any): Record<string, unknown> {
@@ -1712,12 +1749,21 @@ export class ViewerServer {
1712
1749
  }
1713
1750
  try {
1714
1751
  const hubUrl = normalizeHubUrl(hubAddress);
1752
+ const localIPs = this.getLocalIPs();
1753
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
1754
+ try {
1755
+ const u = new URL(hubUrl);
1756
+ if (localIPs.includes(u.hostname)) {
1757
+ return this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
1758
+ }
1759
+ } catch {}
1715
1760
  const os = await import("os");
1716
- const username = os.userInfo().username || "user";
1761
+ const nickname = sharing.client?.nickname;
1762
+ const username = nickname || os.userInfo().username || "user";
1717
1763
  const hostname = os.hostname() || "unknown";
1718
1764
  const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
1719
1765
  method: "POST",
1720
- body: JSON.stringify({ teamToken, username, deviceName: hostname }),
1766
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true }),
1721
1767
  }) as any;
1722
1768
  this.store.setClientHubConnection({
1723
1769
  hubUrl,
@@ -1738,7 +1784,13 @@ export class ViewerServer {
1738
1784
  if (!this.ctx) return this.jsonResponse(res, { memories: [], error: "sharing_unavailable" });
1739
1785
  try {
1740
1786
  const limit = Number(url.searchParams.get("limit") || 40);
1741
- const data = await hubListMemories(this.store, this.ctx, { limit });
1787
+ const hub = this.resolveHubConnection();
1788
+ let data: any;
1789
+ if (hub) {
1790
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/memories?limit=${limit}`);
1791
+ } else {
1792
+ data = await hubListMemories(this.store, this.ctx, { limit });
1793
+ }
1742
1794
  this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
1743
1795
  } catch (err) {
1744
1796
  this.jsonResponse(res, { memories: [], error: String(err) });
@@ -1749,7 +1801,13 @@ export class ViewerServer {
1749
1801
  if (!this.ctx) return this.jsonResponse(res, { tasks: [], error: "sharing_unavailable" });
1750
1802
  try {
1751
1803
  const limit = Number(url.searchParams.get("limit") || 40);
1752
- const data = await hubListTasks(this.store, this.ctx, { limit });
1804
+ const hub = this.resolveHubConnection();
1805
+ let data: any;
1806
+ if (hub) {
1807
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/tasks?limit=${limit}`);
1808
+ } else {
1809
+ data = await hubListTasks(this.store, this.ctx, { limit });
1810
+ }
1753
1811
  this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
1754
1812
  } catch (err) {
1755
1813
  this.jsonResponse(res, { tasks: [], error: String(err) });
@@ -1760,7 +1818,13 @@ export class ViewerServer {
1760
1818
  if (!this.ctx) return this.jsonResponse(res, { skills: [], error: "sharing_unavailable" });
1761
1819
  try {
1762
1820
  const limit = Number(url.searchParams.get("limit") || 40);
1763
- const data = await hubListSkills(this.store, this.ctx, { limit });
1821
+ const hub = this.resolveHubConnection();
1822
+ let data: any;
1823
+ if (hub) {
1824
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/skills/list?limit=${limit}`);
1825
+ } else {
1826
+ data = await hubListSkills(this.store, this.ctx, { limit });
1827
+ }
1764
1828
  this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
1765
1829
  } catch (err) {
1766
1830
  this.jsonResponse(res, { skills: [], error: String(err) });
@@ -1776,13 +1840,21 @@ export class ViewerServer {
1776
1840
  const query = String(parsed.query || "");
1777
1841
  const role = typeof parsed.role === "string" ? parsed.role : undefined;
1778
1842
  const maxResults = typeof parsed.maxResults === "number" ? parsed.maxResults : 10;
1779
- const scope = parsed.scope === "group" || parsed.scope === "all" ? parsed.scope : "local";
1843
+ const scope = parsed.scope === "group" || parsed.scope === "all" || parsed.scope === "hub" ? (parsed.scope === "hub" ? "all" : parsed.scope) : "local";
1780
1844
  const local = this.searchLocalViewerMemories(query, { role, maxResults });
1781
1845
  if (scope === "local") {
1782
1846
  return this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub });
1783
1847
  }
1784
1848
  try {
1785
- const hub = await hubSearchMemories(this.store, this.ctx, { query, maxResults, scope });
1849
+ const conn = this.resolveHubConnection();
1850
+ let hub: any;
1851
+ if (conn) {
1852
+ hub = await hubRequestJson(conn.hubUrl, conn.userToken, "/api/v1/hub/search", {
1853
+ method: "POST", body: JSON.stringify({ query, maxResults, scope }),
1854
+ });
1855
+ } else {
1856
+ hub = await hubSearchMemories(this.store, this.ctx!, { query, maxResults, scope });
1857
+ }
1786
1858
  this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub });
1787
1859
  } catch (err) {
1788
1860
  this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub, error: String(err) });
@@ -2192,6 +2264,34 @@ export class ViewerServer {
2192
2264
  }
2193
2265
  }
2194
2266
 
2267
+ private async serveHubTaskDetail(res: http.ServerResponse, p: string): Promise<void> {
2268
+ const hub = this.resolveHubConnection();
2269
+ if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
2270
+ const m = p.match(/^\/api\/admin\/shared-tasks\/([^/]+)\/detail$/);
2271
+ if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
2272
+ const taskId = decodeURIComponent(m[1]);
2273
+ try {
2274
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-tasks/${encodeURIComponent(taskId)}/detail`, { method: "GET" }) as any;
2275
+ this.jsonResponse(res, data);
2276
+ } catch (err) {
2277
+ this.jsonResponse(res, { error: String(err) }, 500);
2278
+ }
2279
+ }
2280
+
2281
+ private async serveHubSkillDetail(res: http.ServerResponse, p: string): Promise<void> {
2282
+ const hub = this.resolveHubConnection();
2283
+ if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
2284
+ const m = p.match(/^\/api\/admin\/shared-skills\/([^/]+)\/detail$/);
2285
+ if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
2286
+ const skillId = decodeURIComponent(m[1]);
2287
+ try {
2288
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-skills/${encodeURIComponent(skillId)}/detail`, { method: "GET" }) as any;
2289
+ this.jsonResponse(res, data);
2290
+ } catch (err) {
2291
+ this.jsonResponse(res, { error: String(err) }, 500);
2292
+ }
2293
+ }
2294
+
2195
2295
  private async serveAdminSharedSkills(res: http.ServerResponse): Promise<void> {
2196
2296
  const hub = this.resolveHubConnection();
2197
2297
  if (!hub) return this.jsonResponse(res, { skills: [], error: "not_configured" });
@@ -2272,6 +2372,12 @@ export class ViewerServer {
2272
2372
  const body = JSON.parse(raw || "{}");
2273
2373
  await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/read", { method: "POST", body: JSON.stringify(body) });
2274
2374
  this.jsonResponse(res, { ok: true });
2375
+ try {
2376
+ const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
2377
+ const count = data?.unreadCount ?? 0;
2378
+ this.lastKnownNotifCount = count;
2379
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2380
+ } catch { /* best effort */ }
2275
2381
  } catch (err) {
2276
2382
  this.jsonResponse(res, { ok: false, error: String(err) });
2277
2383
  }
@@ -2285,12 +2391,81 @@ export class ViewerServer {
2285
2391
  try {
2286
2392
  await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/clear", { method: "POST", body: "{}" });
2287
2393
  this.jsonResponse(res, { ok: true });
2394
+ this.broadcastNotifSSE({ type: "cleared", unreadCount: 0 });
2288
2395
  } catch (err) {
2289
2396
  this.jsonResponse(res, { ok: false, error: String(err) });
2290
2397
  }
2291
2398
  });
2292
2399
  }
2293
2400
 
2401
+ private handleNotifSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
2402
+ res.writeHead(200, {
2403
+ "Content-Type": "text/event-stream",
2404
+ "Cache-Control": "no-cache",
2405
+ Connection: "keep-alive",
2406
+ "Access-Control-Allow-Origin": "*",
2407
+ });
2408
+ res.write("data: {\"type\":\"connected\"}\n\n");
2409
+ this.notifSSEClients.push(res);
2410
+ if (!this.notifPollTimer) this.startNotifPoll();
2411
+ req.on("close", () => {
2412
+ this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
2413
+ if (this.notifSSEClients.length === 0) this.stopNotifPoll();
2414
+ });
2415
+ }
2416
+
2417
+ private broadcastNotifSSE(data: Record<string, unknown>): void {
2418
+ const msg = `data: ${JSON.stringify(data)}\n\n`;
2419
+ this.notifSSEClients = this.notifSSEClients.filter((c) => {
2420
+ try { c.write(msg); return true; } catch { return false; }
2421
+ });
2422
+ }
2423
+
2424
+ private startNotifPoll(): void {
2425
+ this.stopNotifPoll();
2426
+ const tick = async () => {
2427
+ const hub = this.resolveHubConnection();
2428
+ if (!hub) return;
2429
+ try {
2430
+ const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
2431
+ const count = data?.unreadCount ?? 0;
2432
+ if (count !== this.lastKnownNotifCount) {
2433
+ this.lastKnownNotifCount = count;
2434
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2435
+ }
2436
+ } catch { /* ignore */ }
2437
+ };
2438
+ tick();
2439
+ this.notifPollTimer = setInterval(tick, 3000);
2440
+ }
2441
+
2442
+ private stopNotifPoll(): void {
2443
+ if (this.notifPollTimer) { clearInterval(this.notifPollTimer); this.notifPollTimer = undefined; }
2444
+ }
2445
+
2446
+ private startHubHeartbeat(): void {
2447
+ this.stopHubHeartbeat();
2448
+ const sendHeartbeat = async () => {
2449
+ try {
2450
+ const hub = this.resolveHubConnection();
2451
+ if (!hub) {
2452
+ const persisted = this.store.getClientHubConnection();
2453
+ if (persisted?.hubUrl && persisted?.userToken) {
2454
+ await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2455
+ }
2456
+ return;
2457
+ }
2458
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2459
+ } catch { /* best-effort */ }
2460
+ };
2461
+ sendHeartbeat();
2462
+ this.hubHeartbeatTimer = setInterval(sendHeartbeat, ViewerServer.HUB_HEARTBEAT_INTERVAL_MS);
2463
+ }
2464
+
2465
+ private stopHubHeartbeat(): void {
2466
+ if (this.hubHeartbeatTimer) { clearInterval(this.hubHeartbeatTimer); this.hubHeartbeatTimer = undefined; }
2467
+ }
2468
+
2294
2469
  private getLocalIPs(): string[] {
2295
2470
  const nets = os.networkInterfaces();
2296
2471
  const ips: string[] = [];
@@ -2343,7 +2518,7 @@ export class ViewerServer {
2343
2518
  }
2344
2519
 
2345
2520
  private handleSaveConfig(req: http.IncomingMessage, res: http.ServerResponse): void {
2346
- this.readBody(req, (body) => {
2521
+ this.readBody(req, async (body) => {
2347
2522
  try {
2348
2523
  const newCfg = JSON.parse(body);
2349
2524
  const cfgPath = this.getOpenClawConfigPath();
@@ -2366,6 +2541,11 @@ export class ViewerServer {
2366
2541
  if (!entry.config) entry.config = {};
2367
2542
  const config = entry.config as Record<string, unknown>;
2368
2543
 
2544
+ const oldSharing = config.sharing as Record<string, unknown> | undefined;
2545
+ const oldSharingRole = oldSharing?.role as string | undefined;
2546
+ const oldSharingEnabled = Boolean(oldSharing?.enabled);
2547
+ const oldClientHubAddress = String((oldSharing?.client as Record<string, unknown>)?.hubAddress || "");
2548
+
2369
2549
  if (newCfg.embedding) config.embedding = newCfg.embedding;
2370
2550
  if (newCfg.summarizer) config.summarizer = newCfg.summarizer;
2371
2551
  if (newCfg.skillEvolution) config.skillEvolution = newCfg.skillEvolution;
@@ -2393,6 +2573,38 @@ export class ViewerServer {
2393
2573
  } catch {}
2394
2574
  }
2395
2575
  }
2576
+
2577
+ const newRole = merged.role as string | undefined;
2578
+ const newEnabled = Boolean(merged.enabled);
2579
+
2580
+ // Detect disabling sharing or switching away from hub mode
2581
+ const wasHub = oldSharingEnabled && oldSharingRole === "hub";
2582
+ const isHub = newEnabled && newRole === "hub";
2583
+ if (wasHub && !isHub) {
2584
+ await this.notifyHubShutdown();
2585
+ this.stopHubHeartbeat();
2586
+ this.log.info("Hub shutting down: notified connected clients");
2587
+ }
2588
+
2589
+ // Detect disabling sharing or switching away from client mode
2590
+ const wasClient = oldSharingEnabled && oldSharingRole === "client";
2591
+ const isClient = newEnabled && newRole === "client";
2592
+ if (wasClient && !isClient) {
2593
+ this.notifyHubLeave();
2594
+ this.store.clearClientHubConnection();
2595
+ this.log.info("Cleared client hub connection (sharing disabled or role changed)");
2596
+ }
2597
+
2598
+ // Detect switching to a different Hub while still in client mode
2599
+ if (wasClient && isClient) {
2600
+ const newClientAddr = String((merged.client as Record<string, unknown>)?.hubAddress || "");
2601
+ if (newClientAddr && oldClientHubAddress && normalizeHubUrl(newClientAddr) !== normalizeHubUrl(oldClientHubAddress)) {
2602
+ this.notifyHubLeave();
2603
+ this.store.clearClientHubConnection();
2604
+ this.log.info("Cleared client hub connection (switched to different Hub)");
2605
+ }
2606
+ }
2607
+
2396
2608
  if (merged.role === "hub") {
2397
2609
  merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2398
2610
  } else if (merged.role === "client") {
@@ -2404,6 +2616,14 @@ export class ViewerServer {
2404
2616
  fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
2405
2617
  fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
2406
2618
  this.log.info("Plugin config updated via Viewer");
2619
+ this.stopHubHeartbeat();
2620
+
2621
+ // When switching to client mode, immediately send join request
2622
+ const finalSharing = config.sharing as Record<string, unknown> | undefined;
2623
+ if (finalSharing?.role === "client" && oldSharingRole !== "client") {
2624
+ this.autoJoinOnSave(finalSharing).catch(e => this.log.warn(`Auto-join on save failed: ${e}`));
2625
+ }
2626
+
2407
2627
  this.jsonResponse(res, { ok: true });
2408
2628
  } catch (e) {
2409
2629
  this.log.warn(`handleSaveConfig error: ${e}`);
@@ -2413,6 +2633,87 @@ export class ViewerServer {
2413
2633
  });
2414
2634
  }
2415
2635
 
2636
+ private async autoJoinOnSave(sharing: Record<string, unknown>): Promise<void> {
2637
+ const clientCfg = sharing.client as Record<string, unknown> | undefined;
2638
+ const hubAddress = String(clientCfg?.hubAddress || "");
2639
+ const teamToken = String(clientCfg?.teamToken || "");
2640
+ if (!hubAddress || !teamToken) return;
2641
+ const hubUrl = normalizeHubUrl(hubAddress);
2642
+ const os = await import("os");
2643
+ const nickname = String(clientCfg?.nickname || "");
2644
+ const username = nickname || os.userInfo().username || "user";
2645
+ const hostname = os.hostname() || "unknown";
2646
+ const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
2647
+ method: "POST",
2648
+ body: JSON.stringify({ teamToken, username, deviceName: hostname }),
2649
+ }) as any;
2650
+ this.store.setClientHubConnection({
2651
+ hubUrl,
2652
+ userId: String(result.userId || ""),
2653
+ username,
2654
+ userToken: result.userToken || "",
2655
+ role: "member",
2656
+ connectedAt: Date.now(),
2657
+ });
2658
+ this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
2659
+ if (result.userToken) {
2660
+ this.startHubHeartbeat();
2661
+ }
2662
+ }
2663
+
2664
+ private async notifyHubLeave(): Promise<void> {
2665
+ try {
2666
+ const hub = this.resolveHubConnection();
2667
+ if (hub) {
2668
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
2669
+ this.log.info("Notified Hub of voluntary leave");
2670
+ return;
2671
+ }
2672
+ const persisted = this.store.getClientHubConnection();
2673
+ if (persisted?.hubUrl && persisted?.userToken) {
2674
+ await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
2675
+ this.log.info("Notified Hub of voluntary leave (persisted connection)");
2676
+ }
2677
+ } catch (e) {
2678
+ this.log.warn(`Failed to notify Hub of leave: ${e}`);
2679
+ }
2680
+ }
2681
+
2682
+ private async notifyHubShutdown(): Promise<void> {
2683
+ try {
2684
+ const sharing = this.ctx?.config.sharing;
2685
+ if (!sharing || sharing.role !== "hub") return;
2686
+ const hubPort = sharing.hub?.port ?? 18800;
2687
+ const authPath = path.join(this.dataDir, "hub-auth.json");
2688
+ let adminToken: string | undefined;
2689
+ try {
2690
+ const authData = JSON.parse(fs.readFileSync(authPath, "utf8"));
2691
+ adminToken = authData?.bootstrapAdminToken;
2692
+ } catch { return; }
2693
+ if (!adminToken) return;
2694
+
2695
+ const users = this.store.listHubUsers("active");
2696
+ const { v4: uuidv4 } = require("uuid");
2697
+ for (const u of users) {
2698
+ try {
2699
+ this.store.insertHubNotification({
2700
+ id: uuidv4(),
2701
+ userId: u.id,
2702
+ type: "hub_shutdown",
2703
+ resource: "hub",
2704
+ title: "Hub is shutting down",
2705
+ message: "The Hub server is shutting down. You may be disconnected.",
2706
+ });
2707
+ } catch (e) {
2708
+ this.log.warn(`Failed to insert shutdown notification for user ${u.id}: ${e}`);
2709
+ }
2710
+ }
2711
+ this.log.info(`Hub shutdown: notified ${users.length} approved user(s)`);
2712
+ } catch (e) {
2713
+ this.log.warn(`notifyHubShutdown error: ${e}`);
2714
+ }
2715
+ }
2716
+
2416
2717
  private handleUpdateUsername(req: http.IncomingMessage, res: http.ServerResponse): void {
2417
2718
  this.readBody(req, async (body) => {
2418
2719
  if (!this.ctx) return this.jsonResponse(res, { error: "sharing_unavailable" });
@@ -2866,7 +3167,7 @@ export class ViewerServer {
2866
3167
 
2867
3168
  private getOpenClawHome(): string {
2868
3169
  const home = process.env.HOME || process.env.USERPROFILE || "";
2869
- return path.join(home, ".openclaw");
3170
+ return process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
2870
3171
  }
2871
3172
 
2872
3173
  private handleCleanupPolluted(res: http.ServerResponse): void {