clay-server 4.0.0 → 4.1.0-beta.1

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 (52) hide show
  1. package/lib/daemon-project-access.js +118 -0
  2. package/lib/daemon-projects.js +19 -5
  3. package/lib/daemon.js +19 -18
  4. package/lib/project-logs-access-scope.js +94 -0
  5. package/lib/project-logs-query.js +3 -0
  6. package/lib/project-logs-service.js +86 -84
  7. package/lib/project-logs-store.js +5 -3
  8. package/lib/project-pair-lifecycle.js +71 -61
  9. package/lib/project-pair-message.js +50 -0
  10. package/lib/project-pair-replacement-state.js +72 -0
  11. package/lib/project-pair-task-control.js +264 -0
  12. package/lib/project-pair-usage.js +73 -0
  13. package/lib/project-session-pair.js +115 -116
  14. package/lib/project-sessions.js +40 -6
  15. package/lib/project-worker-proposal.js +54 -57
  16. package/lib/project.js +27 -4
  17. package/lib/public/app.js +1 -0
  18. package/lib/public/css/command-palette.css +26 -0
  19. package/lib/public/css/icon-strip.css +40 -0
  20. package/lib/public/index.html +2 -2
  21. package/lib/public/modules/app-message-router.js +1 -1
  22. package/lib/public/modules/app-messages.js +9 -0
  23. package/lib/public/modules/project-access.js +141 -0
  24. package/lib/public/modules/project-grouping.js +22 -0
  25. package/lib/public/modules/search-clay-chat.js +47 -6
  26. package/lib/public/modules/search-clay-controls.js +86 -0
  27. package/lib/public/modules/sidebar-projects.js +14 -171
  28. package/lib/public/modules/worker-proposal.js +4 -0
  29. package/lib/public/style.css +1 -1
  30. package/lib/sdk-bridge.js +21 -1
  31. package/lib/server-admin.js +3 -3
  32. package/lib/server-global-ws.js +10 -0
  33. package/lib/server-home-chat-events.js +3 -0
  34. package/lib/server-home-chat.js +35 -0
  35. package/lib/server-mates.js +4 -3
  36. package/lib/server-palette.js +17 -14
  37. package/lib/server.js +184 -102
  38. package/lib/session-pair-factory.js +3 -0
  39. package/lib/session-pair-history.js +40 -0
  40. package/lib/session-pair-mcp-server.js +12 -1
  41. package/lib/session-pair-operation.js +11 -0
  42. package/lib/session-pair-prompts.js +24 -2
  43. package/lib/session-pair-turn-control.js +8 -3
  44. package/lib/worker-proposal-control.js +69 -0
  45. package/lib/worker-proposal-decision.js +7 -0
  46. package/lib/worker-runtime-catalog.js +52 -0
  47. package/lib/workspace-query-access.js +3 -2
  48. package/lib/workspace-query-service.js +1 -1
  49. package/lib/worktree.js +8 -4
  50. package/lib/ws-schema.js +7 -0
  51. package/lib/yoke/adapters/codex.js +6 -2
  52. package/package.json +1 -1
package/lib/sdk-bridge.js CHANGED
@@ -627,8 +627,18 @@ function createSDKBridge(opts) {
627
627
  var PAIR_MCP_PREFIX = "mcp__clay-sessions__";
628
628
  var pairMcpTools = {
629
629
  propose_worker: true,
630
+ inspect_worker_proposal: true,
631
+ cancel_worker_proposal: true,
632
+ worker_runtime_catalog: true,
630
633
  send_to_partner: true,
631
634
  read_partner: true,
635
+ message_partner: true,
636
+ queue_partner_followup: true,
637
+ inspect_partner_followups: true,
638
+ cancel_partner_followup: true,
639
+ replace_partner_task: true,
640
+ resume_partner_task: true,
641
+ report_partner_outcome: true,
632
642
  partner_status: true,
633
643
  replace_partner: true,
634
644
  interrupt_partner: true,
@@ -638,6 +648,16 @@ function createSDKBridge(opts) {
638
648
  };
639
649
  var pairDynamicTools = {
640
650
  propose_worker: true,
651
+ inspect_worker_proposal: true,
652
+ cancel_worker_proposal: true,
653
+ worker_runtime_catalog: true,
654
+ message_partner: true,
655
+ queue_partner_followup: true,
656
+ inspect_partner_followups: true,
657
+ cancel_partner_followup: true,
658
+ replace_partner_task: true,
659
+ resume_partner_task: true,
660
+ report_partner_outcome: true,
641
661
  partner_status: true,
642
662
  replace_partner: true,
643
663
  interrupt_partner: true,
@@ -866,7 +886,7 @@ function createSDKBridge(opts) {
866
886
  }
867
887
 
868
888
  var _nm = getNotificationsModule();
869
- if (_nm) {
889
+ if (_nm && session.homeClayEntryMode !== "search") {
870
890
  _nm.notify("permission_request", {
871
891
  title: permissionPushTitle(toolName, input),
872
892
  body: permissionPushBody(toolName, input),
@@ -632,9 +632,9 @@ function attachAdmin(ctx) {
632
632
  return true;
633
633
  }
634
634
  var projSlug = fullUrl.split("/")[4];
635
- if (projSlug.indexOf("--") !== -1) {
636
- res.writeHead(400, { "Content-Type": "application/json" });
637
- res.end('{"error":"Worktree projects inherit parent settings"}');
635
+ if (!projects.get(projSlug)) {
636
+ res.writeHead(404, { "Content-Type": "application/json" });
637
+ res.end('{"error":"Project not found"}');
638
638
  return true;
639
639
  }
640
640
  var body = "";
@@ -40,6 +40,8 @@ function attachGlobalWs(opts) {
40
40
  var onCloneProject = opts.onCloneProject;
41
41
  var onHomePreferenceMessage = opts.onHomePreferenceMessage;
42
42
  var onAppMessage = opts.onAppMessage;
43
+ var onConnection = opts.onConnection;
44
+ var clients = new Set();
43
45
 
44
46
  function handleMessage(ws, msg) {
45
47
  if (!msg || typeof msg !== "object") return;
@@ -184,16 +186,24 @@ function attachGlobalWs(opts) {
184
186
 
185
187
  function handleConnection(ws, wsUser) {
186
188
  ws._clayUser = wsUser || null;
189
+ clients.add(ws);
190
+ ws.on("close", function () { clients.delete(ws); });
187
191
  ws.on("message", function (data) {
188
192
  var msg;
189
193
  try { msg = JSON.parse(data); } catch (e) { return; }
190
194
  handleMessage(ws, msg);
191
195
  });
196
+ if (typeof onConnection === "function") onConnection(ws);
197
+ }
198
+
199
+ function forEachClient(visitor) {
200
+ clients.forEach(function (ws) { visitor(ws); });
192
201
  }
193
202
 
194
203
  return {
195
204
  handleConnection: handleConnection,
196
205
  handleMessage: handleMessage,
206
+ forEachClient: forEachClient,
197
207
  };
198
208
  }
199
209
 
@@ -267,6 +267,9 @@ function transformEvent(event, mateId, session, requestId, stableSessionId) {
267
267
  }
268
268
  if (event.type === "tool_executing") return Object.assign({ type: "home_clay_activity", activityId: session._homeClayToolActivityId || "tool:" + (session._homeClayActivityStep || 1), phase: "searching", status: "active", label: searchToolLabel(event.name, event.input), step: session._homeClayActivityStep || 1 }, metadata);
269
269
  if (event.type === "tool_result") return Object.assign({ type: "home_clay_activity", activityId: session._homeClayToolActivityId || "tool:" + (session._homeClayActivityStep || 1), phase: event.is_error ? "reconsidering" : "reviewing", status: event.is_error ? "error" : "done", label: event.is_error ? "That lead did not work; trying another route" : "Reviewed the search results", step: session._homeClayActivityStep || 1 }, metadata);
270
+ if (event.type === "permission_request") return Object.assign({ type: "home_mate_permission_request", permissionRequestId: event.requestId, toolName: event.toolName, toolInput: event.toolInput, decisionReason: event.decisionReason || "" }, metadata);
271
+ if (event.type === "permission_resolved") return Object.assign({ type: "home_mate_permission_resolved", permissionRequestId: event.requestId, decision: event.decision || "deny" }, metadata);
272
+ if (event.type === "permission_cancel") return Object.assign({ type: "home_mate_permission_cancelled", permissionRequestId: event.requestId }, metadata);
270
273
  }
271
274
  if (session && session.homeDebatePlanning === true && HOME_DEBATE_EVENT_TYPES[event.type]) {
272
275
  var live = { type: "home_debate_event", eventType: event.type };
@@ -224,6 +224,12 @@ function attachHomeChat(deps) {
224
224
  messages: historyToHomeChat(session.history || [], session.debateSetupMode === true, session.mateCreationMode === true),
225
225
  isProcessing: session.isProcessing === true,
226
226
  };
227
+ if (session.homeClayEntryMode === "search") {
228
+ payload.pendingPermissions = Object.keys(session.pendingPermissions || {}).map(function (permissionRequestId) {
229
+ var pending = session.pendingPermissions[permissionRequestId];
230
+ return { permissionRequestId: permissionRequestId, toolName: pending.toolName, toolInput: pending.toolInput, decisionReason: pending.decisionReason || "" };
231
+ });
232
+ }
227
233
  if (session.debateSetupMode === true) payload.debatePlanning = true;
228
234
  if (session.homeDebatePhase) payload.debatePhase = session.homeDebatePhase;
229
235
  if (session.mateCreationMode === true) payload.mateCreation = true;
@@ -388,6 +394,35 @@ function attachHomeChat(deps) {
388
394
 
389
395
  function handleMessage(ws, msg) {
390
396
  if (!msg || typeof msg.type !== "string") return false;
397
+ if (msg.type === "home_mate_stop" || msg.type === "home_mate_permission_response") {
398
+ var controlUserId = ws._clayUser ? ws._clayUser.id : null;
399
+ if (users.isMultiUser() && !controlUserId) {
400
+ sendError(ws, msg.mateId || null, "Not authenticated.", msg.requestId || null, msg.sessionId || null);
401
+ return true;
402
+ }
403
+ var controlFound = findMateProject(controlUserId, msg.mateId, true);
404
+ var controlTap = messageTap(ws, msg);
405
+ var controlManager = controlFound ? controlFound.ctx.getSessionManager() : null;
406
+ var controlSession = controlManager && controlTap && controlTap.mateId === controlFound.mate.id && controlTap.requestId === msg.requestId
407
+ ? controlManager.sessions.get(controlTap.sessionId) : null;
408
+ var controlReference = controlSession ? sessionReference(controlSession) : null;
409
+ if (!controlFound || !controlSession || controlReference !== msg.sessionId || !ownsSession(controlSession, controlUserId) || controlSession.homeClayEntryMode !== "search") {
410
+ sendError(ws, msg.mateId || null, "Conversation control is no longer available.", msg.requestId || null, msg.sessionId || null, "session_not_found");
411
+ return true;
412
+ }
413
+ if (msg.type === "home_mate_permission_response") {
414
+ var controlDecision = msg.decision === "allow" ? "allow" : (msg.decision === "deny" ? "deny" : null);
415
+ if (!controlDecision || typeof msg.permissionRequestId !== "string" || !controlSession.pendingPermissions[msg.permissionRequestId]) {
416
+ sendError(ws, controlFound.mate.id, "This permission request is no longer pending.", msg.requestId || null, msg.sessionId || null, "permission_not_found", { toolId: msg.permissionRequestId });
417
+ return true;
418
+ }
419
+ controlFound.ctx.handleHomePermissionResponse(ws, { requestId: msg.permissionRequestId, decision: controlDecision }, controlSession);
420
+ } else {
421
+ sendMessage(ws, { type: "home_mate_stopping", mateId: controlFound.mate.id, sessionId: controlReference, requestId: msg.requestId || null });
422
+ controlFound.ctx.stopHomeSession(controlSession);
423
+ }
424
+ return true;
425
+ }
391
426
  if (msg.type !== "home_debates_list" && msg.type !== "home_clay_ask" && msg.type !== "home_clay_session_resolve" && msg.type !== "home_clay_log_resolve" && msg.type !== "home_mate_present" && msg.type !== "home_mate_open" && msg.type !== "home_mate_sessions_list" && msg.type !== "home_mate_session_open" && msg.type !== "home_mate_send" && msg.type !== "home_mate_new_session" && msg.type !== "home_mate_debate_plan" && msg.type !== "home_debate_proposal_response" && msg.type !== "home_debate_question_response" && msg.type !== "home_debate_control" && msg.type !== "home_mate_creation_plan" && msg.type !== "home_mate_creation_question_response" && msg.type !== "home_mate_creation_proposal_response" && msg.type !== "home_mate_close" && msg.type !== "home_mate_memory_list" && msg.type !== "home_mate_knowledge_list" && msg.type !== "home_mate_models_get" && msg.type !== "home_mate_model_set") {
392
427
  return false;
393
428
  }
@@ -20,11 +20,12 @@ function attachMates(ctx) {
20
20
  var projList = [];
21
21
  projects.forEach(function (pCtx, pSlug) {
22
22
  var st = pCtx.getStatus();
23
- if (st.isMate || st.isWorktree) return;
23
+ if (st.isMate) return;
24
24
  // Filter by user access in multi-user mode
25
- if (userId && users.isMultiUser() && onGetProjectAccess) {
25
+ if (users.isMultiUser() && onGetProjectAccess) {
26
+ if (!userId) return;
26
27
  var access = onGetProjectAccess(pSlug);
27
- if (access && !access.error && !users.canAccessProject(userId, access)) return;
28
+ if (!access || access.error || !users.canAccessProject(userId, access)) return;
28
29
  }
29
30
  projList.push(st);
30
31
  });
@@ -5,6 +5,17 @@ function attachPalette(ctx) {
5
5
  var projects = ctx.projects;
6
6
  var getMultiUserFromReq = ctx.getMultiUserFromReq;
7
7
  var onGetProjectAccess = ctx.onGetProjectAccess;
8
+ var canUserAccessSlug = ctx.canUserAccessSlug;
9
+
10
+ function accessFor(user, slug, status) {
11
+ if (!user) return { visibility: "public" };
12
+ if (typeof canUserAccessSlug !== "function" || !canUserAccessSlug(user.id, slug)) return null;
13
+ if (status && status.isMate) {
14
+ return { visibility: "private", ownerId: status.projectOwnerId || null, allowedUsers: [] };
15
+ }
16
+ var access = onGetProjectAccess ? onGetProjectAccess(slug) : null;
17
+ return access && !access.error ? access : null;
18
+ }
8
19
 
9
20
  function handleRequest(req, res, fullUrl) {
10
21
  if (req.method !== "GET" || fullUrl !== "/api/palette/search") return false;
@@ -26,17 +37,13 @@ function attachPalette(ctx) {
26
37
  // Recent mode: return all sessions sorted by lastActivity
27
38
  projects.forEach(function (pCtx, pSlug) {
28
39
  var status = pCtx.getStatus();
29
- if (status.isWorktree) return;
30
- if (paletteUser && onGetProjectAccess) {
31
- var pAccess = onGetProjectAccess(pSlug);
32
- if (pAccess && !pAccess.error && !users.canAccessProject(paletteUser.id, pAccess)) return;
33
- }
40
+ var pAccess = accessFor(paletteUser, pSlug, status);
41
+ if (paletteUser && !pAccess) return;
34
42
  pCtx.sm.sessions.forEach(function (session) {
35
43
  if (session.hidden) return;
36
44
  if (paletteUser) {
37
45
  if (users.isMultiUser()) {
38
- var sAccess = onGetProjectAccess ? onGetProjectAccess(pSlug) : null;
39
- if (!users.canAccessSession(paletteUser.id, session, sAccess)) return;
46
+ if (!users.canAccessSession(paletteUser.id, session, pAccess)) return;
40
47
  }
41
48
  } else {
42
49
  if (session.ownerId) return;
@@ -65,18 +72,14 @@ function attachPalette(ctx) {
65
72
  var projectSessions = [];
66
73
  projects.forEach(function (pCtx, pSlug) {
67
74
  var status = pCtx.getStatus();
68
- if (status.isWorktree) return;
69
- if (paletteUser && onGetProjectAccess) {
70
- var pAccess = onGetProjectAccess(pSlug);
71
- if (pAccess && !pAccess.error && !users.canAccessProject(paletteUser.id, pAccess)) return;
72
- }
75
+ var pAccess = accessFor(paletteUser, pSlug, status);
76
+ if (paletteUser && !pAccess) return;
73
77
  var accessibleSessions = [];
74
78
  pCtx.sm.sessions.forEach(function (session) {
75
79
  if (session.hidden) return;
76
80
  if (paletteUser) {
77
81
  if (users.isMultiUser()) {
78
- var sAccess = onGetProjectAccess ? onGetProjectAccess(pSlug) : null;
79
- if (!users.canAccessSession(paletteUser.id, session, sAccess)) return;
82
+ if (!users.canAccessSession(paletteUser.id, session, pAccess)) return;
80
83
  }
81
84
  } else {
82
85
  if (session.ownerId) return;
package/lib/server.js CHANGED
@@ -202,6 +202,23 @@ function createServer(opts) {
202
202
  var isRequestAuthed = auth.isRequestAuthed;
203
203
  var parseCookies = auth.parseCookies;
204
204
 
205
+ function canUserAccessSlug(userId, slug) {
206
+ if (!userId || !slug) return false;
207
+ var project = projects && projects.get(slug);
208
+ var status = project && project.getStatus ? project.getStatus() : null;
209
+ if (status && status.isMate === true) {
210
+ return users.canAccessProject(userId, {
211
+ visibility: "private",
212
+ ownerId: status.projectOwnerId || null,
213
+ allowedUsers: [],
214
+ }) === true;
215
+ }
216
+ if (!onGetProjectAccess) return false;
217
+ var access = onGetProjectAccess(slug);
218
+ if (!access || access.error) return false;
219
+ return users.canAccessProject(userId, access) === true;
220
+ }
221
+
205
222
  var realVersion = require("../package.json").version;
206
223
  var currentVersion = debug ? "0.0.9" : realVersion;
207
224
 
@@ -339,6 +356,13 @@ function createServer(opts) {
339
356
  if (!access || access.error) return false;
340
357
  return users.canAccessProject(userId, access) === true;
341
358
  },
359
+ hasFullProjectAccess: function (userId, status) {
360
+ if (!status || !status.slug || !onGetProjectAccess) return false;
361
+ var access = onGetProjectAccess(status.slug);
362
+ if (!access || access.error) return false;
363
+ var governing = access.isWorktree && access.parentAccess ? access.parentAccess : access;
364
+ return users.canAccessProject(userId, governing) === true;
365
+ },
342
366
  resolveMate: function (userId, mateId) {
343
367
  return mates.getMate(mates.buildMateCtx(users.isMultiUser() ? userId : null), mateId);
344
368
  },
@@ -403,6 +427,7 @@ function createServer(opts) {
403
427
  projects: projects,
404
428
  getMultiUserFromReq: getMultiUserFromReq,
405
429
  onGetProjectAccess: onGetProjectAccess,
430
+ canUserAccessSlug: canUserAccessSlug,
406
431
  });
407
432
 
408
433
  // --- Push module (global) ---
@@ -662,7 +687,11 @@ function createServer(opts) {
662
687
  if (isRequestAuthed(req)) {
663
688
  var mem = process.memoryUsage();
664
689
  var activeSessions = 0;
665
- projects.forEach(function (ctx) {
690
+ var healthProjects = 0;
691
+ var healthUser = users.isMultiUser() ? getMultiUserFromReq(req) : null;
692
+ projects.forEach(function (ctx, slug) {
693
+ if (users.isMultiUser() && (!healthUser || !canUserAccessSlug(healthUser.id, slug))) return;
694
+ healthProjects++;
666
695
  if (ctx && ctx.clients) {
667
696
  activeSessions += ctx.clients.size || 0;
668
697
  }
@@ -671,7 +700,7 @@ function createServer(opts) {
671
700
  health.version = pkg.version;
672
701
  health.node = process.version;
673
702
  health.sessions = activeSessions;
674
- health.projects = projects.size;
703
+ health.projects = healthProjects;
675
704
  health.memory = {
676
705
  rss: mem.rss,
677
706
  heapUsed: mem.heapUsed,
@@ -850,7 +879,9 @@ function createServer(opts) {
850
879
  return;
851
880
  }
852
881
  var projectList = [];
882
+ var infoUser = users.isMultiUser() ? getMultiUserFromReq(req) : null;
853
883
  projects.forEach(function (ctx, slug) {
884
+ if (users.isMultiUser() && (!infoUser || !canUserAccessSlug(infoUser.id, slug))) return;
854
885
  projectList.push({ slug: slug, project: ctx.project });
855
886
  });
856
887
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -912,15 +943,12 @@ function createServer(opts) {
912
943
  res.setHeader("Set-Cookie", "clay_last_project=" + slug + "; Path=/; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : ""));
913
944
 
914
945
  // Multi-user: check project access for HTTP requests
915
- if (users.isMultiUser() && onGetProjectAccess) {
946
+ if (users.isMultiUser()) {
916
947
  var httpUser = getMultiUserFromReq(req);
917
- if (httpUser) {
918
- var httpAccess = onGetProjectAccess(slug);
919
- if (httpAccess && !httpAccess.error && !users.canAccessProject(httpUser.id, httpAccess)) {
920
- res.writeHead(302, { "Location": "/" });
921
- res.end();
922
- return;
923
- }
948
+ if (!httpUser || !canUserAccessSlug(httpUser.id, slug)) {
949
+ res.writeHead(302, { "Location": "/" });
950
+ res.end();
951
+ return;
924
952
  }
925
953
  }
926
954
 
@@ -1029,6 +1057,9 @@ function createServer(opts) {
1029
1057
  onAppMessage: function (ws, msg) {
1030
1058
  return handleDmMessage(ws, msg);
1031
1059
  },
1060
+ onConnection: function (ws) {
1061
+ sendProjectsUpdatedToClient(ws);
1062
+ },
1032
1063
  });
1033
1064
 
1034
1065
  server.on("upgrade", function (req, socket, head) {
@@ -1106,19 +1137,19 @@ function createServer(opts) {
1106
1137
  if (users.isMultiUser()) {
1107
1138
  wsUser = getMultiUserFromReq(req);
1108
1139
  // Check project access for multi-user mode
1109
- if (wsUser && onGetProjectAccess) {
1110
- // For worktree projects, inherit access from parent
1111
- var accessSlug = (wsSlug.indexOf("--") !== -1) ? wsSlug.split("--")[0] : wsSlug;
1112
- var projectAccess = onGetProjectAccess(accessSlug);
1140
+ if (wsUser) {
1141
+ var projectAccess = onGetProjectAccess ? onGetProjectAccess(wsSlug) : null;
1113
1142
  if (debug) console.log("[server] WS access check:", wsSlug, "user:", wsUser.id, "role:", wsUser.role, "visibility:", projectAccess && projectAccess.visibility, "ownerId:", projectAccess && projectAccess.ownerId, "allowed:", projectAccess && projectAccess.allowedUsers);
1114
- if (projectAccess && !projectAccess.error) {
1115
- if (!users.canAccessProject(wsUser.id, projectAccess)) {
1116
- if (debug) console.log("[server] WS rejected: access denied for", wsUser.id, "on", wsSlug);
1117
- socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1118
- socket.destroy();
1119
- return;
1120
- }
1143
+ if (!canUserAccessSlug(wsUser.id, wsSlug)) {
1144
+ if (debug) console.log("[server] WS rejected: access denied for", wsUser.id, "on", wsSlug);
1145
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1146
+ socket.destroy();
1147
+ return;
1121
1148
  }
1149
+ } else {
1150
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1151
+ socket.destroy();
1152
+ return;
1122
1153
  }
1123
1154
  }
1124
1155
 
@@ -1200,13 +1231,9 @@ function createServer(opts) {
1200
1231
  var filtered = allProjectsList;
1201
1232
  if (users.isMultiUser() && onGetProjectAccess) {
1202
1233
  var wsUser = ws._clayUser;
1203
- if (wsUser) {
1204
- filtered = allProjectsList.filter(function (p) {
1205
- var access = onGetProjectAccess(p.slug);
1206
- if (!access || access.error) return true;
1207
- return users.canAccessProject(wsUser.id, access);
1208
- });
1209
- }
1234
+ filtered = wsUser ? allProjectsList.filter(function (p) {
1235
+ return canUserAccessSlug(wsUser.id, p.slug);
1236
+ }) : [];
1210
1237
  }
1211
1238
  // Attach per-project unread counts for this client
1212
1239
  var unreadMap = getCrossProjectUnread(ws);
@@ -1306,20 +1333,12 @@ function createServer(opts) {
1306
1333
  authToken: pinHash || null,
1307
1334
  getProjectCount: function () { return projects.size; },
1308
1335
  getProjectList: function (userId) {
1309
- var list = [];
1310
- projects.forEach(function (ctx, s) {
1311
- var status = ctx.getStatus();
1312
- if (userId && users.isMultiUser() && onGetProjectAccess) {
1313
- var access = onGetProjectAccess(s);
1314
- if (access && !access.error && !users.canAccessProject(userId, access)) return;
1315
- }
1316
- list.push(status);
1317
- });
1318
- return list;
1336
+ return getVisibleProjects(userId);
1319
1337
  },
1320
- getHubSchedules: function () {
1338
+ getHubSchedules: function (userId) {
1321
1339
  var allSchedules = [];
1322
1340
  projects.forEach(function (ctx, s) {
1341
+ if (users.isMultiUser() && (!userId || !canUserAccessSlug(userId, s))) return;
1323
1342
  var status = ctx.getStatus();
1324
1343
  var recs = ctx.getSchedules();
1325
1344
  for (var i = 0; i < recs.length; i++) {
@@ -1379,6 +1398,7 @@ function createServer(opts) {
1379
1398
  if (!ctx) return 0;
1380
1399
  return ctx.getSchedules().length;
1381
1400
  },
1401
+ canAccessProjectSlug: canUserAccessSlug,
1382
1402
  onPresenceChange: broadcastPresenceChange,
1383
1403
  onProcessingChanged: broadcastProcessingChange,
1384
1404
  onSessionDone: function () { onSessionDone(slug); },
@@ -1592,27 +1612,81 @@ function createServer(opts) {
1592
1612
  function getServerUsers() {
1593
1613
  var seen = {};
1594
1614
  var list = [];
1595
- projects.forEach(function (ctx) {
1596
- ctx.forEachClient(function (ws) {
1597
- if (!ws._clayUser) return;
1598
- var u = ws._clayUser;
1599
- if (seen[u.id]) return;
1600
- seen[u.id] = true;
1601
- var p = u.profile || {};
1602
- list.push({
1603
- id: u.id,
1604
- displayName: p.name || u.displayName || u.username,
1605
- username: u.username,
1606
- avatarStyle: p.avatarStyle || "imprint",
1607
- avatarSeed: p.avatarSeed || u.username,
1608
- avatarColor: p.avatarColor || "#5857fc",
1609
- avatarCustom: p.avatarCustom || "",
1610
- });
1615
+ forEachAppClient(function (ws) {
1616
+ if (!ws._clayUser) return;
1617
+ var u = ws._clayUser;
1618
+ if (seen[u.id]) return;
1619
+ seen[u.id] = true;
1620
+ var p = u.profile || {};
1621
+ list.push({
1622
+ id: u.id,
1623
+ displayName: p.name || u.displayName || u.username,
1624
+ username: u.username,
1625
+ avatarStyle: p.avatarStyle || "imprint",
1626
+ avatarSeed: p.avatarSeed || u.username,
1627
+ avatarColor: p.avatarColor || "#5857fc",
1628
+ avatarCustom: p.avatarCustom || "",
1611
1629
  });
1612
1630
  });
1613
1631
  return list;
1614
1632
  }
1615
1633
 
1634
+ function forEachAppClient(visitor) {
1635
+ if (globalWs && typeof globalWs.forEachClient === "function") {
1636
+ globalWs.forEachClient(visitor);
1637
+ }
1638
+ projects.forEach(function (ctx) {
1639
+ ctx.forEachClient(visitor);
1640
+ });
1641
+ }
1642
+
1643
+ function getVisibleProjects(userId) {
1644
+ var visible = [];
1645
+ projects.forEach(function (ctx, slug) {
1646
+ if (users.isMultiUser() && (!userId || !canUserAccessSlug(userId, slug))) return;
1647
+ visible.push(ctx.getStatus());
1648
+ });
1649
+ return visible;
1650
+ }
1651
+
1652
+ function sendProjectsUpdatedToClient(ws, serverUsers, allUsers) {
1653
+ if (!ws || ws.readyState !== 1) return;
1654
+ var userId = ws._clayUser ? ws._clayUser.id : null;
1655
+ var visibleProjects = getVisibleProjects(userId);
1656
+ var message = {
1657
+ type: "projects_updated",
1658
+ projects: visibleProjects,
1659
+ projectCount: visibleProjects.length,
1660
+ removedProjects: getRemovedProjects(userId),
1661
+ };
1662
+ if (users.isMultiUser()) {
1663
+ message.serverUsers = serverUsers || getServerUsers();
1664
+ message.allUsers = allUsers || users.getAllUsers().map(function (user) {
1665
+ var profile = user.profile || {};
1666
+ return {
1667
+ id: user.id,
1668
+ displayName: profile.name || user.displayName || user.username,
1669
+ username: user.username,
1670
+ role: user.role,
1671
+ avatarStyle: profile.avatarStyle || "imprint",
1672
+ avatarSeed: profile.avatarSeed || user.username,
1673
+ avatarColor: profile.avatarColor || "#5857fc",
1674
+ avatarCustom: profile.avatarCustom || "",
1675
+ };
1676
+ });
1677
+ message.dmFavorites = userId ? users.getDmFavorites(userId) : [];
1678
+ message.dmConversations = [];
1679
+ if (userId) {
1680
+ var hidden = users.getDmHidden(userId);
1681
+ var dmList = dm.getDmList(userId);
1682
+ for (var i = 0; i < dmList.length; i++) {
1683
+ if (hidden.indexOf(dmList[i].otherUserId) === -1) message.dmConversations.push(dmList[i].otherUserId);
1684
+ }
1685
+ }
1686
+ }
1687
+ ws.send(JSON.stringify(message));
1688
+ }
1689
+
1616
1690
  // Debounced broadcast of projects_updated when presence changes
1617
1691
  // Sends per-user filtered project lists + server-wide user list
1618
1692
  var presenceTimer = null;
@@ -1621,12 +1695,7 @@ function createServer(opts) {
1621
1695
  presenceTimer = setTimeout(function () {
1622
1696
  presenceTimer = null;
1623
1697
  if (!users.isMultiUser()) {
1624
- broadcastAll({
1625
- type: "projects_updated",
1626
- projects: getProjects(),
1627
- projectCount: projects.size,
1628
- removedProjects: getRemovedProjects(),
1629
- });
1698
+ forEachAppClient(function (ws) { sendProjectsUpdatedToClient(ws); });
1630
1699
  return;
1631
1700
  }
1632
1701
  var serverUsers = getServerUsers();
@@ -1643,61 +1712,73 @@ function createServer(opts) {
1643
1712
  avatarCustom: p.avatarCustom || "",
1644
1713
  };
1645
1714
  });
1646
- // Build per-user filtered lists, send individually
1715
+ // Build per-user filtered lists, send individually to both project and
1716
+ // slug-less Home sockets. A Home fallback must replace, never retain,
1717
+ // the project cache from its previous authenticated project socket.
1647
1718
  var sentUsers = {};
1648
- projects.forEach(function (ctx) {
1649
- ctx.forEachClient(function (ws) {
1650
- var userId = ws._clayUser ? ws._clayUser.id : null;
1651
- var key = userId || "__anon__";
1652
- if (sentUsers[key]) {
1653
- // Already computed for this user, just send the cached msg
1654
- ws.send(sentUsers[key]);
1655
- return;
1656
- }
1657
- var filteredProjects = [];
1658
- projects.forEach(function (pCtx, s) {
1659
- var status = pCtx.getStatus();
1660
- if (userId && onGetProjectAccess) {
1661
- var access = onGetProjectAccess(s);
1662
- if (access && !access.error && !users.canAccessProject(userId, access)) return;
1663
- }
1664
- filteredProjects.push(status);
1665
- });
1666
- // Per-user DM data
1667
- var userDmFavorites = userId ? users.getDmFavorites(userId) : [];
1668
- var userDmHidden = userId ? users.getDmHidden(userId) : [];
1669
- var userDmConversations = [];
1670
- if (userId) {
1671
- var dmList = dm.getDmList(userId);
1672
- for (var di = 0; di < dmList.length; di++) {
1673
- if (userDmHidden.indexOf(dmList[di].otherUserId) === -1) {
1674
- userDmConversations.push(dmList[di].otherUserId);
1675
- }
1719
+ forEachAppClient(function (ws) {
1720
+ var userId = ws._clayUser ? ws._clayUser.id : null;
1721
+ var key = userId || "__anon__";
1722
+ if (sentUsers[key]) {
1723
+ // Already computed for this user, just send the cached msg
1724
+ if (ws.readyState === 1) ws.send(sentUsers[key]);
1725
+ return;
1726
+ }
1727
+ var filteredProjects = getVisibleProjects(userId);
1728
+ // Per-user DM data
1729
+ var userDmFavorites = userId ? users.getDmFavorites(userId) : [];
1730
+ var userDmHidden = userId ? users.getDmHidden(userId) : [];
1731
+ var userDmConversations = [];
1732
+ if (userId) {
1733
+ var dmList = dm.getDmList(userId);
1734
+ for (var di = 0; di < dmList.length; di++) {
1735
+ if (userDmHidden.indexOf(dmList[di].otherUserId) === -1) {
1736
+ userDmConversations.push(dmList[di].otherUserId);
1676
1737
  }
1677
1738
  }
1678
- var msgStr = JSON.stringify({
1679
- type: "projects_updated",
1680
- projects: filteredProjects,
1681
- projectCount: projects.size,
1682
- serverUsers: serverUsers,
1683
- allUsers: allUsers,
1684
- dmFavorites: userDmFavorites,
1685
- dmConversations: userDmConversations,
1686
- removedProjects: getRemovedProjects(userId),
1687
- });
1688
- sentUsers[key] = msgStr;
1689
- ws.send(msgStr);
1739
+ }
1740
+ var msgStr = JSON.stringify({
1741
+ type: "projects_updated",
1742
+ projects: filteredProjects,
1743
+ projectCount: filteredProjects.length,
1744
+ serverUsers: serverUsers,
1745
+ allUsers: allUsers,
1746
+ dmFavorites: userDmFavorites,
1747
+ dmConversations: userDmConversations,
1748
+ removedProjects: getRemovedProjects(userId),
1690
1749
  });
1750
+ sentUsers[key] = msgStr;
1751
+ if (ws.readyState === 1) ws.send(msgStr);
1691
1752
  });
1692
1753
  }, 300);
1693
1754
  }
1694
1755
 
1695
1756
  function broadcastAll(msg) {
1757
+ if (msg && msg.type === "projects_updated") {
1758
+ broadcastPresenceChange();
1759
+ return;
1760
+ }
1696
1761
  projects.forEach(function (ctx) {
1697
1762
  ctx.send(msg);
1698
1763
  });
1699
1764
  }
1700
1765
 
1766
+ function refreshProjectAccess() {
1767
+ if (!users.isMultiUser()) return;
1768
+ projects.forEach(function (ctx, slug) {
1769
+ if (!ctx || typeof ctx.forEachClient !== "function") return;
1770
+ ctx.forEachClient(function (ws) {
1771
+ var userId = ws._clayUser && ws._clayUser.id;
1772
+ if (userId && canUserAccessSlug(userId, slug)) return;
1773
+ try {
1774
+ if (ws.readyState === 1) ws.send(JSON.stringify({ type: "access_revoked", slug: slug }));
1775
+ ws.close(1008, "Project access revoked");
1776
+ } catch (e) {}
1777
+ });
1778
+ });
1779
+ broadcastPresenceChange();
1780
+ }
1781
+
1701
1782
  // Send a message to every live ws belonging to a specific user across all projects.
1702
1783
  // Used by user-targeted notifications (e.g. user-to-user @mentions).
1703
1784
  function sendToUser(userId, msg) {
@@ -1813,6 +1894,7 @@ function createServer(opts) {
1813
1894
  setRecovery: auth.setRecovery,
1814
1895
  clearRecovery: auth.clearRecovery,
1815
1896
  broadcastAll: broadcastAll,
1897
+ refreshProjectAccess: refreshProjectAccess,
1816
1898
  forEachProject: forEachProject,
1817
1899
  destroyProject: removeProject,
1818
1900
  destroyAll: destroyAll,