@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
@@ -34,6 +34,7 @@ export interface ViewerServerOptions {
34
34
  log: Logger;
35
35
  dataDir: string;
36
36
  ctx?: PluginContext;
37
+ defaultHubPort?: number;
37
38
  }
38
39
 
39
40
  interface AuthState {
@@ -51,6 +52,8 @@ export class ViewerServer {
51
52
  private readonly authFile: string;
52
53
  private readonly auth: AuthState;
53
54
  private readonly ctx?: PluginContext;
55
+ private readonly cookieName: string;
56
+ private readonly defaultHubPort: number;
54
57
 
55
58
  private static readonly SESSION_TTL = 24 * 60 * 60 * 1000;
56
59
  private static readonly PLUGIN_VERSION: string = (() => {
@@ -99,17 +102,31 @@ export class ViewerServer {
99
102
  this.ctx = opts.ctx;
100
103
  this.authFile = path.join(opts.dataDir, "viewer-auth.json");
101
104
  this.auth = { passwordHash: null, sessions: new Map() };
105
+ this.cookieName = `memos_token_${opts.port}`;
106
+ this.defaultHubPort = opts.defaultHubPort ?? 18800;
102
107
  this.resetToken = crypto.randomBytes(16).toString("hex");
103
108
  this.loadAuth();
104
109
  }
105
110
 
111
+ private getHubPort(): number {
112
+ const configured = this.ctx?.config?.sharing?.hub?.port;
113
+ if (configured && configured !== 18800) return configured;
114
+ return this.defaultHubPort;
115
+ }
116
+
106
117
  start(): Promise<string> {
118
+ const MAX_PORT_RETRIES = 5;
107
119
  return new Promise((resolve, reject) => {
120
+ let retries = 0;
108
121
  this.server = http.createServer((req, res) => this.handleRequest(req, res));
109
122
  this.server.on("error", (err: NodeJS.ErrnoException) => {
110
- if (err.code === "EADDRINUSE") {
111
- this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
112
- this.server!.listen(this.port + 1, "0.0.0.0");
123
+ if (err.code === "EADDRINUSE" && retries < MAX_PORT_RETRIES) {
124
+ retries++;
125
+ const nextPort = this.port + retries;
126
+ this.log.warn(`Viewer port ${this.port + retries - 1} in use, trying ${nextPort}`);
127
+ this.server!.listen(nextPort, "0.0.0.0");
128
+ } else if (err.code === "EADDRINUSE") {
129
+ reject(new Error(`Viewer failed to find open port after ${MAX_PORT_RETRIES} retries (tried ${this.port}–${this.port + MAX_PORT_RETRIES})`));
113
130
  } else {
114
131
  reject(err);
115
132
  }
@@ -187,7 +204,8 @@ export class ViewerServer {
187
204
 
188
205
  private isValidSession(req: http.IncomingMessage): boolean {
189
206
  const cookie = req.headers.cookie ?? "";
190
- const match = cookie.match(/memos_token=([a-f0-9]+)/);
207
+ const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
208
+ const match = cookie.match(re);
191
209
  if (!match) return false;
192
210
  const expiry = this.auth.sessions.get(match[1]);
193
211
  if (!expiry) return false;
@@ -270,6 +288,7 @@ export class ViewerServer {
270
288
  else if (p === "/api/sharing/remove-user" && req.method === "POST") this.handleSharingRemoveUser(req, res);
271
289
  else if (p === "/api/sharing/change-role" && req.method === "POST") this.handleSharingChangeRole(req, res);
272
290
  else if (p === "/api/sharing/retry-join" && req.method === "POST") this.handleRetryJoin(req, res);
291
+ else if (p === "/api/sharing/leave" && req.method === "POST") this.handleLeaveTeam(req, res);
273
292
  else if (p === "/api/sharing/search/memories" && req.method === "POST") this.handleSharingMemorySearch(req, res);
274
293
  else if (p === "/api/sharing/memories/list" && req.method === "GET") this.serveSharingMemoryList(res, url);
275
294
  else if (p === "/api/sharing/tasks/list" && req.method === "GET") this.serveSharingTaskList(res, url);
@@ -290,6 +309,7 @@ export class ViewerServer {
290
309
  else if (p === "/api/sharing/notifications" && req.method === "GET") this.serveSharingNotifications(res, url);
291
310
  else if (p === "/api/sharing/notifications/read" && req.method === "POST") this.handleSharingNotificationsRead(req, res);
292
311
  else if (p === "/api/sharing/notifications/clear" && req.method === "POST") this.handleSharingNotificationsClear(req, res);
312
+ else if (p === "/api/sharing/sync-hub-removal" && req.method === "POST") this.handleSyncHubRemoval(req, res);
293
313
  else if (p === "/api/notifications/stream" && req.method === "GET") this.handleNotifSSE(req, res);
294
314
  else if (p === "/api/admin/shared-tasks" && req.method === "GET") this.serveAdminSharedTasks(res);
295
315
  else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+\/detail$/) && req.method === "GET") this.serveHubTaskDetail(res, p);
@@ -350,7 +370,7 @@ export class ViewerServer {
350
370
  const token = this.createSession();
351
371
  res.writeHead(200, {
352
372
  "Content-Type": "application/json",
353
- "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
373
+ "Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
354
374
  });
355
375
  res.end(JSON.stringify({ ok: true, message: "Password set successfully" }));
356
376
  } catch (err) {
@@ -372,7 +392,7 @@ export class ViewerServer {
372
392
  const token = this.createSession();
373
393
  res.writeHead(200, {
374
394
  "Content-Type": "application/json",
375
- "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
395
+ "Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
376
396
  });
377
397
  res.end(JSON.stringify({ ok: true }));
378
398
  } catch (err) {
@@ -384,11 +404,12 @@ export class ViewerServer {
384
404
 
385
405
  private handleLogout(req: http.IncomingMessage, res: http.ServerResponse): void {
386
406
  const cookie = req.headers.cookie ?? "";
387
- const match = cookie.match(/memos_token=([a-f0-9]+)/);
407
+ const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
408
+ const match = cookie.match(re);
388
409
  if (match) this.auth.sessions.delete(match[1]);
389
410
  res.writeHead(200, {
390
411
  "Content-Type": "application/json",
391
- "Set-Cookie": "memos_token=; Path=/; HttpOnly; Max-Age=0",
412
+ "Set-Cookie": `${this.cookieName}=; Path=/; HttpOnly; Max-Age=0`,
392
413
  });
393
414
  res.end(JSON.stringify({ ok: true }));
394
415
  }
@@ -415,7 +436,7 @@ export class ViewerServer {
415
436
  const sessionToken = this.createSession();
416
437
  res.writeHead(200, {
417
438
  "Content-Type": "application/json",
418
- "Set-Cookie": `memos_token=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
439
+ "Set-Cookie": `${this.cookieName}=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
419
440
  });
420
441
  res.end(JSON.stringify({ ok: true, message: "Password reset successfully" }));
421
442
  } catch (err) {
@@ -451,9 +472,8 @@ export class ViewerServer {
451
472
  if (session) { conditions.push("session_key = ?"); params.push(session); }
452
473
  if (role) { conditions.push("role = ?"); params.push(role); }
453
474
  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 + "%");
475
+ conditions.push("(owner = ? OR owner = 'public')");
476
+ params.push(owner);
457
477
  } else if (owner) {
458
478
  conditions.push("owner = ?"); params.push(owner);
459
479
  }
@@ -462,7 +482,7 @@ export class ViewerServer {
462
482
 
463
483
  const where = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
464
484
  const totalRow = db.prepare("SELECT COUNT(*) as count FROM chunks" + where).get(...params) as any;
465
- const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
485
+ 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);
466
486
  const findMergeSources = db.prepare("SELECT id, summary, role FROM chunks WHERE dedup_target = ? AND (dedup_status = 'merged' OR dedup_status = 'duplicate')");
467
487
 
468
488
  const chunkIds = rawMemories.map((m: any) => m.id);
@@ -473,6 +493,12 @@ export class ViewerServer {
473
493
  const placeholders = chunkIds.map(() => "?").join(",");
474
494
  const sharedRows = db.prepare(`SELECT source_chunk_id, visibility, group_id FROM hub_memories WHERE source_chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ source_chunk_id: string; visibility: string; group_id: string | null }>;
475
495
  for (const r of sharedRows) sharingMap.set(r.source_chunk_id, r);
496
+ const teamMetaRows = db.prepare(`SELECT chunk_id, visibility, group_id FROM team_shared_chunks WHERE chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ chunk_id: string; visibility: string; group_id: string | null }>;
497
+ for (const r of teamMetaRows) {
498
+ if (!sharingMap.has(r.chunk_id)) {
499
+ sharingMap.set(r.chunk_id, { visibility: r.visibility, group_id: r.group_id });
500
+ }
501
+ }
476
502
  const localRows = db.prepare(`SELECT chunk_id, original_owner, shared_at FROM local_shared_memories WHERE chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ chunk_id: string; original_owner: string; shared_at: number }>;
477
503
  for (const r of localRows) localShareMap.set(r.chunk_id, r);
478
504
  } catch {
@@ -640,9 +666,8 @@ export class ViewerServer {
640
666
  let sessionQuery: string;
641
667
  let sessionParams: any[];
642
668
  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 + "%"];
669
+ 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";
670
+ sessionParams = [ownerFilter];
646
671
  } else if (ownerFilter) {
647
672
  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
673
  sessionParams = [ownerFilter];
@@ -667,6 +692,12 @@ export class ViewerServer {
667
692
  owners = ownerRows.map((o: any) => o.owner);
668
693
  } catch { /* column may not exist yet */ }
669
694
 
695
+ let currentAgentOwner = "agent:main";
696
+ try {
697
+ 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;
698
+ if (latest?.owner) currentAgentOwner = latest.owner;
699
+ } catch { /* best-effort */ }
700
+
670
701
  this.jsonResponse(res, {
671
702
  totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embCount,
672
703
  totalSkills: skillCount,
@@ -675,6 +706,7 @@ export class ViewerServer {
675
706
  timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
676
707
  sessions: sessionList,
677
708
  owners,
709
+ currentAgentOwner,
678
710
  });
679
711
  } catch (e) {
680
712
  this.log.warn(`stats error: ${e}`);
@@ -1042,11 +1074,21 @@ export class ViewerServer {
1042
1074
  });
1043
1075
  }
1044
1076
 
1045
- private handleSkillDelete(res: http.ServerResponse, urlPath: string): void {
1077
+ private async handleSkillDelete(res: http.ServerResponse, urlPath: string): Promise<void> {
1046
1078
  const skillId = urlPath.replace("/api/skill/", "");
1047
1079
  const skill = this.store.getSkill(skillId);
1048
1080
  if (!skill) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Skill not found" })); return; }
1049
- // Remove skill directory from disk
1081
+ try {
1082
+ const hub = this.resolveHubConnection();
1083
+ if (hub) {
1084
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/skills/unpublish", {
1085
+ method: "POST",
1086
+ body: JSON.stringify({ sourceSkillId: skillId }),
1087
+ }).catch(() => {});
1088
+ }
1089
+ const db = (this.store as any).db;
1090
+ db.prepare("DELETE FROM hub_skills WHERE source_skill_id = ?").run(skillId);
1091
+ } catch (_) {}
1050
1092
  try {
1051
1093
  if (skill.dirPath && fs.existsSync(skill.dirPath)) {
1052
1094
  fs.rmSync(skill.dirPath, { recursive: true, force: true });
@@ -1209,7 +1251,6 @@ export class ViewerServer {
1209
1251
  let hubSynced = false;
1210
1252
 
1211
1253
  if (scope === "team") {
1212
- if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1213
1254
  if (!isTeamShared) {
1214
1255
  const hubClient = await this.resolveHubClientAware();
1215
1256
  const refreshedChunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
@@ -1217,17 +1258,24 @@ export class ViewerServer {
1217
1258
  method: "POST",
1218
1259
  body: JSON.stringify({ memory: { sourceChunkId: refreshedChunk.id, role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary, kind: refreshedChunk.kind, groupId: null, visibility: "public" } }),
1219
1260
  });
1220
- if (hubClient.userId) {
1261
+ if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1262
+ const memoryId = String((response as any)?.memoryId ?? "");
1263
+ const isHubRole = this.ctx?.config?.sharing?.role === "hub";
1264
+ if (hubClient.userId && isHubRole) {
1221
1265
  const existing = this.store.getHubMemoryBySource(hubClient.userId, chunkId);
1222
1266
  this.store.upsertHubMemory({
1223
- id: (response as any)?.memoryId ?? existing?.id ?? crypto.randomUUID(),
1267
+ id: memoryId || existing?.id || crypto.randomUUID(),
1224
1268
  sourceChunkId: chunkId, sourceUserId: hubClient.userId,
1225
1269
  role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary ?? "",
1226
1270
  kind: refreshedChunk.kind, groupId: null, visibility: "public",
1227
1271
  createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1228
1272
  });
1273
+ } else if (hubClient.userId) {
1274
+ this.store.upsertTeamSharedChunk(chunkId, { hubMemoryId: memoryId, visibility: "public", groupId: null });
1229
1275
  }
1230
1276
  hubSynced = true;
1277
+ } else {
1278
+ if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1231
1279
  }
1232
1280
  } else if (scope === "local") {
1233
1281
  if (isTeamShared) {
@@ -1237,6 +1285,7 @@ export class ViewerServer {
1237
1285
  method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1238
1286
  });
1239
1287
  if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1288
+ this.store.deleteTeamSharedChunk(chunkId);
1240
1289
  hubSynced = true;
1241
1290
  } catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
1242
1291
  }
@@ -1249,6 +1298,7 @@ export class ViewerServer {
1249
1298
  method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1250
1299
  });
1251
1300
  if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1301
+ this.store.deleteTeamSharedChunk(chunkId);
1252
1302
  hubSynced = true;
1253
1303
  } catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
1254
1304
  }
@@ -1289,15 +1339,6 @@ export class ViewerServer {
1289
1339
 
1290
1340
  let hubSynced = false;
1291
1341
 
1292
- if (scope === "local" || scope === "team") {
1293
- if (!isLocalShared) {
1294
- const originalOwner = task.owner;
1295
- const db = (this.store as any).db;
1296
- 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());
1297
- db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1298
- }
1299
- }
1300
-
1301
1342
  if (scope === "team") {
1302
1343
  if (!isTeamShared) {
1303
1344
  const chunks = this.store.getChunksByTask(taskId);
@@ -1321,6 +1362,21 @@ export class ViewerServer {
1321
1362
  }
1322
1363
  hubSynced = true;
1323
1364
  }
1365
+ if (!isLocalShared) {
1366
+ const originalOwner = task.owner;
1367
+ const db = (this.store as any).db;
1368
+ 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());
1369
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1370
+ }
1371
+ }
1372
+
1373
+ if (scope === "local") {
1374
+ if (!isLocalShared) {
1375
+ const originalOwner = task.owner;
1376
+ const db = (this.store as any).db;
1377
+ 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());
1378
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1379
+ }
1324
1380
  }
1325
1381
 
1326
1382
  if (scope === "local" && isTeamShared) {
@@ -1390,10 +1446,6 @@ export class ViewerServer {
1390
1446
 
1391
1447
  let hubSynced = false;
1392
1448
 
1393
- if (scope === "local" || scope === "team") {
1394
- if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
1395
- }
1396
-
1397
1449
  if (scope === "team") {
1398
1450
  if (!isTeamShared) {
1399
1451
  const bundle = buildSkillBundleForHub(this.store, skillId);
@@ -1415,6 +1467,11 @@ export class ViewerServer {
1415
1467
  }
1416
1468
  hubSynced = true;
1417
1469
  }
1470
+ if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
1471
+ }
1472
+
1473
+ if (scope === "local") {
1474
+ if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
1418
1475
  }
1419
1476
 
1420
1477
  if (scope === "local" && isTeamShared) {
@@ -1451,7 +1508,17 @@ export class ViewerServer {
1451
1508
 
1452
1509
  private getHubMemoryForChunk(chunkId: string): any {
1453
1510
  const db = (this.store as any).db;
1454
- return db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
1511
+ const hub = db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
1512
+ if (hub) return hub;
1513
+ const ts = this.store.getTeamSharedChunk(chunkId);
1514
+ if (ts) {
1515
+ return {
1516
+ source_chunk_id: chunkId,
1517
+ visibility: ts.visibility,
1518
+ group_id: ts.groupId,
1519
+ };
1520
+ }
1521
+ return undefined;
1455
1522
  }
1456
1523
 
1457
1524
  private getHubTaskForLocal(taskId: string): any {
@@ -1498,6 +1565,7 @@ export class ViewerServer {
1498
1565
  // ─── Config API ───
1499
1566
 
1500
1567
  private getOpenClawConfigPath(): string {
1568
+ if (process.env.OPENCLAW_CONFIG_PATH) return process.env.OPENCLAW_CONFIG_PATH;
1501
1569
  const home = process.env.HOME || process.env.USERPROFILE || "";
1502
1570
  const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
1503
1571
  return path.join(ocHome, "openclaw.json");
@@ -1587,7 +1655,20 @@ export class ViewerServer {
1587
1655
  base.connection.teamName = info?.teamName ?? sharing.hub?.teamName ?? null;
1588
1656
  base.connection.apiVersion = info?.apiVersion ?? null;
1589
1657
  } catch { /* ignore */ }
1590
- this.jsonResponse(res, base);
1658
+
1659
+ const hubStats: any = { totalMembers: 0, onlineMembers: 0, pendingMembers: 0 };
1660
+ try {
1661
+ const activeUsers = this.store.listHubUsers("active");
1662
+ const pendingUsers = this.store.listHubUsers("pending");
1663
+ const now = Date.now();
1664
+ const OFFLINE_THRESHOLD = 120_000;
1665
+ hubStats.totalMembers = activeUsers.length;
1666
+ hubStats.onlineMembers = activeUsers.filter(u =>
1667
+ u.lastActiveAt && (now - u.lastActiveAt < OFFLINE_THRESHOLD),
1668
+ ).length;
1669
+ hubStats.pendingMembers = pendingUsers.length;
1670
+ } catch { /* best-effort */ }
1671
+ this.jsonResponse(res, { ...base, hubStats });
1591
1672
  return;
1592
1673
  }
1593
1674
 
@@ -1606,6 +1687,9 @@ export class ViewerServer {
1606
1687
  if (status.user?.status === "rejected") {
1607
1688
  output.connection.rejected = true;
1608
1689
  }
1690
+ if (status.user?.status === "removed") {
1691
+ output.connection.removed = true;
1692
+ }
1609
1693
  if (status.connected && status.hubUrl) {
1610
1694
  try {
1611
1695
  const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null) as any;
@@ -1723,7 +1807,14 @@ export class ViewerServer {
1723
1807
  });
1724
1808
  this.jsonResponse(res, { ok: true, result });
1725
1809
  } catch (err) {
1726
- this.jsonResponse(res, { ok: false, error: String(err) });
1810
+ const errStr = String(err);
1811
+ if (errStr.includes("username_taken")) {
1812
+ this.jsonResponse(res, { ok: false, error: "username_taken" });
1813
+ } else if (errStr.includes("invalid_params")) {
1814
+ this.jsonResponse(res, { ok: false, error: "invalid_params" });
1815
+ } else {
1816
+ this.jsonResponse(res, { ok: false, error: errStr });
1817
+ }
1727
1818
  }
1728
1819
  });
1729
1820
  }
@@ -1746,10 +1837,13 @@ export class ViewerServer {
1746
1837
  const nickname = sharing.client?.nickname;
1747
1838
  const username = nickname || os.userInfo().username || "user";
1748
1839
  const hostname = os.hostname() || "unknown";
1840
+ const persisted = this.store.getClientHubConnection();
1841
+ const existingIdentityKey = persisted?.identityKey || "";
1749
1842
  const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
1750
1843
  method: "POST",
1751
- body: JSON.stringify({ teamToken, username, deviceName: hostname }),
1844
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true, identityKey: existingIdentityKey }),
1752
1845
  }) as any;
1846
+ const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
1753
1847
  this.store.setClientHubConnection({
1754
1848
  hubUrl,
1755
1849
  userId: String(result.userId || ""),
@@ -1757,6 +1851,8 @@ export class ViewerServer {
1757
1851
  userToken: result.userToken || "",
1758
1852
  role: "member",
1759
1853
  connectedAt: Date.now(),
1854
+ identityKey: returnedIdentityKey,
1855
+ lastKnownStatus: result.status || "",
1760
1856
  });
1761
1857
  this.jsonResponse(res, { ok: true, status: result.status || "pending" });
1762
1858
  } catch (err) {
@@ -2032,14 +2128,14 @@ export class ViewerServer {
2032
2128
  },
2033
2129
  }),
2034
2130
  });
2035
- const hubUserId = hubClient.userId;
2036
- if (hubUserId) {
2131
+ const mid = String((response as any)?.memoryId ?? "");
2132
+ if (hubClient.userId && this.ctx?.config?.sharing?.role === "hub") {
2037
2133
  const now = Date.now();
2038
- const existing = this.store.getHubMemoryBySource(hubUserId, chunk.id);
2134
+ const existing = this.store.getHubMemoryBySource(hubClient.userId, chunk.id);
2039
2135
  this.store.upsertHubMemory({
2040
- id: (response as any)?.memoryId ?? existing?.id ?? crypto.randomUUID(),
2136
+ id: mid || existing?.id || crypto.randomUUID(),
2041
2137
  sourceChunkId: chunk.id,
2042
- sourceUserId: hubUserId,
2138
+ sourceUserId: hubClient.userId,
2043
2139
  role: chunk.role,
2044
2140
  content: chunk.content,
2045
2141
  summary: chunk.summary ?? "",
@@ -2049,6 +2145,8 @@ export class ViewerServer {
2049
2145
  createdAt: existing?.createdAt ?? now,
2050
2146
  updatedAt: now,
2051
2147
  });
2148
+ } else if (hubClient.userId) {
2149
+ this.store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: mid, visibility, groupId });
2052
2150
  }
2053
2151
  this.jsonResponse(res, { ok: true, chunkId, visibility, response });
2054
2152
  } catch (err) {
@@ -2070,6 +2168,7 @@ export class ViewerServer {
2070
2168
  });
2071
2169
  const hubUserId = hubClient.userId;
2072
2170
  if (hubUserId) this.store.deleteHubMemoryBySource(hubUserId, chunkId);
2171
+ this.store.deleteTeamSharedChunk(chunkId);
2073
2172
  this.jsonResponse(res, { ok: true, chunkId });
2074
2173
  } catch (err) {
2075
2174
  this.jsonResponse(res, { ok: false, error: String(err) });
@@ -2166,7 +2265,7 @@ export class ViewerServer {
2166
2265
  // Hub 模式:连接自己,用 bootstrap admin token
2167
2266
  const sharing = this.ctx.config.sharing;
2168
2267
  if (sharing?.role === "hub") {
2169
- const hubPort = sharing.hub?.port ?? 18800;
2268
+ const hubPort = this.getHubPort();
2170
2269
  const hubUrl = `http://127.0.0.1:${hubPort}`;
2171
2270
  try {
2172
2271
  const authPath = path.join(this.dataDir, "hub-auth.json");
@@ -2383,6 +2482,31 @@ export class ViewerServer {
2383
2482
  });
2384
2483
  }
2385
2484
 
2485
+ /** Badge-only: clear Client team-share UI metadata when Hub admin removes that memory. Does NOT touch chunks, embeddings, or hub_memories (recall paths). */
2486
+ private handleSyncHubRemoval(req: http.IncomingMessage, res: http.ServerResponse): void {
2487
+ this.readBody(req, (body) => {
2488
+ try {
2489
+ const parsed = JSON.parse(body || "{}");
2490
+ const sourceChunkId = String(parsed.sourceChunkId || "");
2491
+ const memoryIdFromNotif = parsed.memoryId != null && parsed.memoryId !== "" ? String(parsed.memoryId) : "";
2492
+ if (!sourceChunkId) return this.jsonResponse(res, { ok: false, error: "missing_source_chunk_id" }, 400);
2493
+ // Admin removal notifications stay in the feed; if the user re-shared, team_shared_chunks has a new hub_memory_id.
2494
+ // Only clear the badge when this notification refers to the same Hub row we still track (or no id — legacy).
2495
+ if (memoryIdFromNotif) {
2496
+ const current = this.store.getTeamSharedChunk(sourceChunkId);
2497
+ const curId = current?.hubMemoryId ? String(current.hubMemoryId) : "";
2498
+ if (curId && curId !== memoryIdFromNotif) {
2499
+ return this.jsonResponse(res, { ok: true, sourceChunkId, skipped: true, reason: "stale_notification_re_shared" });
2500
+ }
2501
+ }
2502
+ this.store.deleteTeamSharedChunk(sourceChunkId);
2503
+ this.jsonResponse(res, { ok: true, sourceChunkId });
2504
+ } catch (e) {
2505
+ this.jsonResponse(res, { ok: false, error: String(e) }, 500);
2506
+ }
2507
+ });
2508
+ }
2509
+
2386
2510
  private handleNotifSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
2387
2511
  res.writeHead(200, {
2388
2512
  "Content-Type": "text/event-stream",
@@ -2393,6 +2517,7 @@ export class ViewerServer {
2393
2517
  res.write("data: {\"type\":\"connected\"}\n\n");
2394
2518
  this.notifSSEClients.push(res);
2395
2519
  if (!this.notifPollTimer) this.startNotifPoll();
2520
+ else this.notifPollImmediate();
2396
2521
  req.on("close", () => {
2397
2522
  this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
2398
2523
  if (this.notifSSEClients.length === 0) this.stopNotifPoll();
@@ -2428,6 +2553,20 @@ export class ViewerServer {
2428
2553
  if (this.notifPollTimer) { clearInterval(this.notifPollTimer); this.notifPollTimer = undefined; }
2429
2554
  }
2430
2555
 
2556
+ private notifPollImmediate(): void {
2557
+ const hub = this.resolveHubConnection();
2558
+ if (!hub) return;
2559
+ hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")
2560
+ .then((data: any) => {
2561
+ const count = data?.unreadCount ?? 0;
2562
+ if (count !== this.lastKnownNotifCount) {
2563
+ this.lastKnownNotifCount = count;
2564
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2565
+ }
2566
+ })
2567
+ .catch(() => {});
2568
+ }
2569
+
2431
2570
  private startHubHeartbeat(): void {
2432
2571
  this.stopHubHeartbeat();
2433
2572
  const sendHeartbeat = async () => {
@@ -2526,7 +2665,10 @@ export class ViewerServer {
2526
2665
  if (!entry.config) entry.config = {};
2527
2666
  const config = entry.config as Record<string, unknown>;
2528
2667
 
2529
- const oldSharingRole = (config.sharing as Record<string, unknown>)?.role as string | undefined;
2668
+ const oldSharing = config.sharing as Record<string, unknown> | undefined;
2669
+ const oldSharingRole = oldSharing?.role as string | undefined;
2670
+ const oldSharingEnabled = Boolean(oldSharing?.enabled);
2671
+ const oldClientHubAddress = String((oldSharing?.client as Record<string, unknown>)?.hubAddress || "");
2530
2672
 
2531
2673
  if (newCfg.embedding) config.embedding = newCfg.embedding;
2532
2674
  if (newCfg.summarizer) config.summarizer = newCfg.summarizer;
@@ -2542,12 +2684,14 @@ export class ViewerServer {
2542
2684
  if (merged.role === "client" && merged.client) {
2543
2685
  const clientCfg = merged.client as Record<string, unknown>;
2544
2686
  const addr = String(clientCfg.hubAddress || "");
2545
- if (addr) {
2687
+ if (addr && oldSharingRole === "hub" && oldSharingEnabled) {
2688
+ const selfHubPort = (oldSharing?.hub as Record<string, unknown>)?.port ?? 18800;
2546
2689
  const localIPs = this.getLocalIPs();
2547
2690
  localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2548
2691
  try {
2549
2692
  const u = new URL(addr.startsWith("http") ? addr : `http://${addr}`);
2550
- if (localIPs.includes(u.hostname)) {
2693
+ const targetPort = u.port || (u.protocol === "https:" ? "443" : "80");
2694
+ if (localIPs.includes(u.hostname) && targetPort === String(selfHubPort)) {
2551
2695
  res.writeHead(400, { "Content-Type": "application/json" });
2552
2696
  res.end(JSON.stringify({ error: "cannot_join_self" }));
2553
2697
  return;
@@ -2556,16 +2700,43 @@ export class ViewerServer {
2556
2700
  }
2557
2701
  }
2558
2702
 
2559
- // When switching away from client mode, notify Hub that we're leaving
2560
2703
  const newRole = merged.role as string | undefined;
2561
- if (oldSharingRole === "client" && newRole !== "client") {
2562
- this.notifyHubLeave();
2704
+ const newEnabled = Boolean(merged.enabled);
2705
+
2706
+ // Detect disabling sharing or switching away from hub mode
2707
+ const wasHub = oldSharingEnabled && oldSharingRole === "hub";
2708
+ const isHub = newEnabled && newRole === "hub";
2709
+ if (wasHub && !isHub) {
2710
+ await this.notifyHubShutdown();
2711
+ this.stopHubHeartbeat();
2712
+ this.log.info("Hub shutting down: notified connected clients");
2713
+ }
2714
+
2715
+ // Detect disabling sharing or switching away from client mode
2716
+ const wasClient = oldSharingEnabled && oldSharingRole === "client";
2717
+ const isClient = newEnabled && newRole === "client";
2718
+ if (wasClient && !isClient) {
2719
+ await this.withdrawOrLeaveHub();
2720
+ this.store.clearClientHubConnection();
2721
+ this.log.info("Client hub connection cleared (sharing disabled or role changed)");
2722
+ }
2723
+
2724
+ if (wasClient && isClient) {
2725
+ const newClientAddr = String((merged.client as Record<string, unknown>)?.hubAddress || "");
2726
+ if (newClientAddr && oldClientHubAddress && normalizeHubUrl(newClientAddr) !== normalizeHubUrl(oldClientHubAddress)) {
2727
+ this.notifyHubLeave();
2728
+ const oldConn = this.store.getClientHubConnection();
2729
+ if (oldConn) {
2730
+ this.store.setClientHubConnection({ ...oldConn, hubUrl: normalizeHubUrl(newClientAddr), userToken: "", lastKnownStatus: "hub_changed" });
2731
+ }
2732
+ this.log.info("Client hub connection token cleared (switched to different Hub), identity preserved");
2733
+ }
2563
2734
  }
2564
2735
 
2565
2736
  if (merged.role === "hub") {
2566
2737
  merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2567
2738
  } else if (merged.role === "client") {
2568
- merged.hub = { port: 18800, teamName: "", teamToken: "" };
2739
+ merged.hub = { teamName: "", teamToken: "" };
2569
2740
  }
2570
2741
  config.sharing = merged;
2571
2742
  }
@@ -2574,7 +2745,26 @@ export class ViewerServer {
2574
2745
  fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
2575
2746
  this.log.info("Plugin config updated via Viewer");
2576
2747
  this.stopHubHeartbeat();
2577
- this.jsonResponse(res, { ok: true });
2748
+
2749
+ // When switching to client mode or re-enabling sharing as client, send join request
2750
+ const finalSharing = config.sharing as Record<string, unknown> | undefined;
2751
+ const nowClient = Boolean(finalSharing?.enabled) && finalSharing?.role === "client";
2752
+ const previouslyClient = oldSharingEnabled && oldSharingRole === "client";
2753
+ let joinStatus: string | undefined;
2754
+ if (nowClient && !previouslyClient) {
2755
+ try {
2756
+ joinStatus = await this.autoJoinOnSave(finalSharing);
2757
+ } catch (e) {
2758
+ this.log.warn(`Auto-join on save failed: ${e}`);
2759
+ }
2760
+ }
2761
+
2762
+ this.jsonResponse(res, { ok: true, joinStatus, restart: true });
2763
+
2764
+ setTimeout(() => {
2765
+ this.log.info("config-save: triggering gateway restart via SIGUSR1...");
2766
+ try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
2767
+ }, 500);
2578
2768
  } catch (e) {
2579
2769
  this.log.warn(`handleSaveConfig error: ${e}`);
2580
2770
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -2583,6 +2773,75 @@ export class ViewerServer {
2583
2773
  });
2584
2774
  }
2585
2775
 
2776
+ private async autoJoinOnSave(sharing: Record<string, unknown>): Promise<string | undefined> {
2777
+ const clientCfg = sharing.client as Record<string, unknown> | undefined;
2778
+ const hubAddress = String(clientCfg?.hubAddress || "");
2779
+ const teamToken = String(clientCfg?.teamToken || "");
2780
+ if (!hubAddress || !teamToken) return undefined;
2781
+ const hubUrl = normalizeHubUrl(hubAddress);
2782
+ const os = await import("os");
2783
+ const nickname = String(clientCfg?.nickname || "");
2784
+ const username = nickname || os.userInfo().username || "user";
2785
+ const hostname = os.hostname() || "unknown";
2786
+ const persisted = this.store.getClientHubConnection();
2787
+ const existingIdentityKey = persisted?.identityKey || "";
2788
+ const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
2789
+ method: "POST",
2790
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, identityKey: existingIdentityKey }),
2791
+ }) as any;
2792
+ const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
2793
+ this.store.setClientHubConnection({
2794
+ hubUrl,
2795
+ userId: String(result.userId || ""),
2796
+ username,
2797
+ userToken: result.userToken || "",
2798
+ role: "member",
2799
+ connectedAt: Date.now(),
2800
+ identityKey: returnedIdentityKey,
2801
+ lastKnownStatus: result.status || "",
2802
+ });
2803
+ this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
2804
+ if (result.userToken) {
2805
+ this.startHubHeartbeat();
2806
+ }
2807
+ return result.status;
2808
+ }
2809
+
2810
+ private handleLeaveTeam(_req: http.IncomingMessage, res: http.ServerResponse): void {
2811
+ this.readBody(_req, async () => {
2812
+ try {
2813
+ await this.withdrawOrLeaveHub();
2814
+ this.store.clearClientHubConnection();
2815
+
2816
+ const configPath = this.getOpenClawConfigPath();
2817
+ if (configPath && fs.existsSync(configPath)) {
2818
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
2819
+ const pluginKey = Object.keys(raw.plugins?.entries ?? {}).find(k => k.includes("memos-local"));
2820
+ if (pluginKey) {
2821
+ const cfg = raw.plugins.entries[pluginKey].config ?? {};
2822
+ if (cfg.sharing) {
2823
+ cfg.sharing.enabled = false;
2824
+ cfg.sharing.client = { hubAddress: "", userToken: "", teamToken: "" };
2825
+ }
2826
+ raw.plugins.entries[pluginKey].config = cfg;
2827
+ fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n");
2828
+ this.log.info("handleLeaveTeam: config updated, sharing disabled");
2829
+ }
2830
+ }
2831
+
2832
+ this.jsonResponse(res, { ok: true, restart: true });
2833
+
2834
+ setTimeout(() => {
2835
+ this.log.info("handleLeaveTeam: triggering gateway restart via SIGUSR1...");
2836
+ try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
2837
+ }, 500);
2838
+ } catch (e) {
2839
+ this.log.warn(`handleLeaveTeam error: ${e}`);
2840
+ this.jsonResponse(res, { ok: false, error: String(e) });
2841
+ }
2842
+ });
2843
+ }
2844
+
2586
2845
  private async notifyHubLeave(): Promise<void> {
2587
2846
  try {
2588
2847
  const hub = this.resolveHubConnection();
@@ -2601,6 +2860,79 @@ export class ViewerServer {
2601
2860
  }
2602
2861
  }
2603
2862
 
2863
+ private async withdrawOrLeaveHub(): Promise<void> {
2864
+ try {
2865
+ const persisted = this.store.getClientHubConnection();
2866
+ const sharing = this.ctx?.config?.sharing;
2867
+
2868
+ if (persisted?.userToken && persisted?.hubUrl) {
2869
+ await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
2870
+ this.log.info("Notified Hub of voluntary leave (had token)");
2871
+ return;
2872
+ }
2873
+
2874
+ const hub = this.resolveHubConnection();
2875
+ if (hub?.userToken) {
2876
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
2877
+ this.log.info("Notified Hub of voluntary leave (resolved connection)");
2878
+ return;
2879
+ }
2880
+
2881
+ const hubUrl = persisted?.hubUrl || (sharing?.client?.hubAddress ? normalizeHubUrl(sharing.client.hubAddress) : null);
2882
+ const userId = persisted?.userId;
2883
+ const teamToken = sharing?.client?.teamToken;
2884
+ if (hubUrl && userId && teamToken) {
2885
+ const withdrawUrl = `${normalizeHubUrl(hubUrl)}/api/v1/hub/withdraw-pending`;
2886
+ await fetch(withdrawUrl, {
2887
+ method: "POST",
2888
+ headers: { "content-type": "application/json" },
2889
+ body: JSON.stringify({ teamToken, userId }),
2890
+ });
2891
+ this.log.info("Withdrew pending application from Hub");
2892
+ return;
2893
+ }
2894
+
2895
+ this.log.info("No hub connection to clean up (no token, no pending)");
2896
+ } catch (e) {
2897
+ this.log.warn(`Failed to withdraw/leave Hub: ${e}`);
2898
+ }
2899
+ }
2900
+
2901
+ private async notifyHubShutdown(): Promise<void> {
2902
+ try {
2903
+ const sharing = this.ctx?.config.sharing;
2904
+ if (!sharing || sharing.role !== "hub") return;
2905
+ const hubPort = this.getHubPort();
2906
+ const authPath = path.join(this.dataDir, "hub-auth.json");
2907
+ let adminToken: string | undefined;
2908
+ try {
2909
+ const authData = JSON.parse(fs.readFileSync(authPath, "utf8"));
2910
+ adminToken = authData?.bootstrapAdminToken;
2911
+ } catch { return; }
2912
+ if (!adminToken) return;
2913
+
2914
+ const users = this.store.listHubUsers("active");
2915
+ const { v4: uuidv4 } = require("uuid");
2916
+ for (const u of users) {
2917
+ try {
2918
+ this.store.insertHubNotification({
2919
+ id: uuidv4(),
2920
+ userId: u.id,
2921
+ type: "hub_shutdown",
2922
+ resource: "hub",
2923
+ title: "Hub is shutting down",
2924
+ message: "The Hub server is shutting down. You may be disconnected.",
2925
+ });
2926
+ } catch (e) {
2927
+ this.log.warn(`Failed to insert shutdown notification for user ${u.id}: ${e}`);
2928
+ }
2929
+ }
2930
+ this.log.info(`Hub shutdown: notified ${users.length} approved user(s)`);
2931
+ } catch (e) {
2932
+ this.log.warn(`notifyHubShutdown error: ${e}`);
2933
+ }
2934
+ }
2935
+
2604
2936
  private handleUpdateUsername(req: http.IncomingMessage, res: http.ServerResponse): void {
2605
2937
  this.readBody(req, async (body) => {
2606
2938
  if (!this.ctx) return this.jsonResponse(res, { error: "sharing_unavailable" });
@@ -2628,10 +2960,10 @@ export class ViewerServer {
2628
2960
  this.log.warn(`Failed to update hub-auth.json: ${e}`);
2629
2961
  }
2630
2962
  } else {
2631
- const persisted = this.store.getClientHubConnection();
2632
- if (persisted) {
2963
+ const persistedConn = this.store.getClientHubConnection();
2964
+ if (persistedConn) {
2633
2965
  this.store.setClientHubConnection({
2634
- ...persisted,
2966
+ ...persistedConn,
2635
2967
  username: result.username,
2636
2968
  userToken: result.userToken,
2637
2969
  });
@@ -2655,12 +2987,17 @@ export class ViewerServer {
2655
2987
  const { hubUrl } = JSON.parse(body);
2656
2988
  if (!hubUrl) { this.jsonResponse(res, { ok: false, error: "hubUrl is required" }); return; }
2657
2989
  try {
2658
- const localIPs = this.getLocalIPs();
2659
- localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2660
- const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
2661
- if (localIPs.includes(parsed.hostname)) {
2662
- this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
2663
- return;
2990
+ const sharing = this.ctx?.config?.sharing;
2991
+ if (sharing?.enabled && sharing.role === "hub") {
2992
+ const selfHubPort = this.getHubPort();
2993
+ const localIPs = this.getLocalIPs();
2994
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2995
+ const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
2996
+ const targetPort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
2997
+ if (localIPs.includes(parsed.hostname) && targetPort === String(selfHubPort)) {
2998
+ this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
2999
+ return;
3000
+ }
2664
3001
  }
2665
3002
  } catch {}
2666
3003
  const url = hubUrl.replace(/\/+$/, "") + "/api/v1/hub/info";
@@ -2898,10 +3235,9 @@ export class ViewerServer {
2898
3235
  this.log.info(`update-install: success! Updated to ${newVersion}`);
2899
3236
  this.jsonResponse(res, { ok: true, version: newVersion });
2900
3237
 
2901
- // Trigger Gateway restart after response is sent
2902
3238
  setTimeout(() => {
2903
- this.log.info(`update-install: triggering gateway restart...`);
2904
- process.kill(process.pid, "SIGUSR1");
3239
+ this.log.info(`update-install: triggering gateway restart via SIGUSR1...`);
3240
+ try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
2905
3241
  }, 500);
2906
3242
  });
2907
3243
  });
@@ -3083,7 +3419,7 @@ export class ViewerServer {
3083
3419
  try {
3084
3420
  const ocHome = this.getOpenClawHome();
3085
3421
  const memoryDir = path.join(ocHome, "memory");
3086
- const sessionsDir = path.join(ocHome, "agents", "main", "sessions");
3422
+ const agentsDir = path.join(ocHome, "agents");
3087
3423
 
3088
3424
  const sqliteFiles: Array<{ file: string; chunks: number }> = [];
3089
3425
  if (fs.existsSync(memoryDir)) {
@@ -3102,31 +3438,36 @@ export class ViewerServer {
3102
3438
 
3103
3439
  let sessionCount = 0;
3104
3440
  let messageCount = 0;
3105
- if (fs.existsSync(sessionsDir)) {
3106
- const jsonlFiles = fs.readdirSync(sessionsDir).filter(f => f.includes(".jsonl"));
3107
- sessionCount = jsonlFiles.length;
3108
- for (const f of jsonlFiles) {
3109
- try {
3110
- const content = fs.readFileSync(path.join(sessionsDir, f), "utf-8");
3111
- const lines = content.split("\n").filter(l => l.trim());
3112
- for (const line of lines) {
3113
- try {
3114
- const obj = JSON.parse(line);
3115
- if (obj.type === "message") {
3116
- const role = obj.message?.role ?? obj.role;
3117
- if (role === "user" || role === "assistant") {
3118
- const mc = obj.message?.content ?? obj.content;
3119
- let txt = "";
3120
- if (typeof mc === "string") txt = mc;
3121
- else if (Array.isArray(mc)) txt = mc.filter((p: any) => p.type === "text" && p.text).map((p: any) => p.text).join("\n");
3122
- else txt = JSON.stringify(mc);
3123
- if (role === "user") txt = stripInboundMetadata(txt);
3124
- if (txt && txt.length >= 10) messageCount++;
3441
+ if (fs.existsSync(agentsDir)) {
3442
+ for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
3443
+ if (!entry.isDirectory()) continue;
3444
+ const sessDir = path.join(agentsDir, entry.name, "sessions");
3445
+ if (!fs.existsSync(sessDir)) continue;
3446
+ const jsonlFiles = fs.readdirSync(sessDir).filter(f => f.includes(".jsonl"));
3447
+ sessionCount += jsonlFiles.length;
3448
+ for (const f of jsonlFiles) {
3449
+ try {
3450
+ const content = fs.readFileSync(path.join(sessDir, f), "utf-8");
3451
+ const lines = content.split("\n").filter(l => l.trim());
3452
+ for (const line of lines) {
3453
+ try {
3454
+ const obj = JSON.parse(line);
3455
+ if (obj.type === "message") {
3456
+ const role = obj.message?.role ?? obj.role;
3457
+ if (role === "user" || role === "assistant") {
3458
+ const mc = obj.message?.content ?? obj.content;
3459
+ let txt = "";
3460
+ if (typeof mc === "string") txt = mc;
3461
+ else if (Array.isArray(mc)) txt = mc.filter((p: any) => p.type === "text" && p.text).map((p: any) => p.text).join("\n");
3462
+ else txt = JSON.stringify(mc);
3463
+ if (role === "user") txt = stripInboundMetadata(txt);
3464
+ if (txt && txt.length >= 10) messageCount++;
3465
+ }
3125
3466
  }
3126
- }
3127
- } catch { /* skip bad lines */ }
3128
- }
3129
- } catch { /* skip unreadable */ }
3467
+ } catch { /* skip bad lines */ }
3468
+ }
3469
+ } catch { /* skip unreadable */ }
3470
+ }
3130
3471
  }
3131
3472
  }
3132
3473