clay-server 4.0.0-beta.21 → 4.0.0-beta.22

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.
package/lib/public/app.js CHANGED
@@ -277,6 +277,10 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
277
277
  projectName: projectName,
278
278
  cwd: "",
279
279
  currentSlug: currentSlug,
280
+ activeProjectSlug: null,
281
+ sessionActivatedProjectSlug: null,
282
+ socketPath: null,
283
+ pendingHomeProjectSlug: null,
280
284
  currentProjectOwnerId: null,
281
285
  isOsUsers: false,
282
286
  skipPermsEnabled: false,
@@ -221,15 +221,24 @@ export function connect() {
221
221
  // two sockets can never both be pinging.
222
222
  stopHeartbeat();
223
223
  if (ws && ws.readyState === 1) setStatus("disconnected");
224
- if (ws) { ws.onclose = null; ws.close(); }
224
+ if (ws) {
225
+ ws.onopen = null;
226
+ ws.onmessage = null;
227
+ ws.onerror = null;
228
+ ws.onclose = null;
229
+ ws.close();
230
+ }
225
231
  if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; }
226
232
 
227
233
  var protocol = location.protocol === "https:" ? "wss:" : "ws:";
228
- var newWs = new WebSocket(protocol + "//" + location.host + store.get('wsPath'));
234
+ var socketPath = store.get('wsPath');
235
+ store.set({ socketPath: socketPath, activeProjectSlug: null, sessionActivatedProjectSlug: null });
236
+ var newWs = new WebSocket(protocol + "//" + location.host + socketPath);
229
237
  setWs(newWs);
230
238
 
231
239
  // If not connected within 3s, force retry
232
240
  connectTimeoutId = setTimeout(function () {
241
+ if (getWs() !== newWs) return;
233
242
  if (!store.get('connected')) {
234
243
  newWs.onclose = null;
235
244
  newWs.onerror = null;
@@ -239,6 +248,7 @@ export function connect() {
239
248
  }, 3000);
240
249
 
241
250
  newWs.onopen = function () {
251
+ if (getWs() !== newWs) return;
242
252
  if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; }
243
253
  if (hasConnectedOnce && disconnectedAt) {
244
254
  console.log("[clay] WebSocket reconnected after " + (Date.now() - disconnectedAt) + "ms");
@@ -267,6 +277,7 @@ export function connect() {
267
277
  };
268
278
 
269
279
  newWs.onclose = function (e) {
280
+ if (getWs() !== newWs) return;
270
281
  if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; }
271
282
  stopHeartbeat();
272
283
  disconnectedAt = Date.now();
@@ -277,6 +288,7 @@ export function connect() {
277
288
  " reason=" + (e.reason || "(none)") + " wasClean=false");
278
289
  }
279
290
  closeDmUserPicker();
291
+ store.set({ activeProjectSlug: null, sessionActivatedProjectSlug: null });
280
292
  setStatus("disconnected");
281
293
  setActivity(null);
282
294
  scheduleReconnect();
@@ -285,6 +297,7 @@ export function connect() {
285
297
  newWs.onerror = function () {};
286
298
 
287
299
  newWs.onmessage = function (event) {
300
+ if (getWs() !== newWs) return;
288
301
  // Backup: if we're receiving messages, we're connected
289
302
  if (!store.get('connected')) {
290
303
  setStatus("connected");
@@ -3,6 +3,7 @@
3
3
  import { store } from './store.js';
4
4
  import { getWs } from './ws-ref.js';
5
5
  import { getCachedProjects, switchProject } from './app-projects.js';
6
+ import { chooseProjectActivationTarget } from './project-activation.js';
6
7
  import { mateAvatarUrl } from './avatar.js';
7
8
  import { exitDmMode } from './app-dm.js';
8
9
  import { closeHomeDock, initHomeDock, renderDock, requestHomeDockPreference } from './home-dock.js';
@@ -45,18 +46,10 @@ function syncHomeCloseControl() {
45
46
  }
46
47
 
47
48
  function getHomeReturnSlug() {
48
- var projects = getCachedProjects() || [];
49
- var candidates = [store.get('currentSlug'), store.get('homeSurfaceProjectSlug')];
50
- for (var ci = 0; ci < candidates.length; ci++) {
51
- if (!candidates[ci]) continue;
52
- for (var pi = 0; pi < projects.length; pi++) {
53
- if (projects[pi] && !projects[pi].isMate && projects[pi].slug === candidates[ci]) return candidates[ci];
54
- }
55
- }
56
- for (var i = 0; i < projects.length; i++) {
57
- if (projects[i] && !projects[i].isMate && projects[i].slug) return projects[i].slug;
58
- }
59
- return null;
49
+ return chooseProjectActivationTarget(
50
+ getCachedProjects(),
51
+ [store.get('currentSlug'), store.get('homeSurfaceProjectSlug')]
52
+ );
60
53
  }
61
54
 
62
55
  function getVisibleMates() {
@@ -407,7 +400,6 @@ export function hideHomeHub() {
407
400
  export function minimizeHomeHub() {
408
401
  var slug = getHomeReturnSlug();
409
402
  if (!slug || !homeHubVisible) return;
410
- rememberHomePrimarySurface("project");
411
403
  switchProject(slug);
412
404
  var projectInput = document.getElementById("input");
413
405
  if (projectInput && !projectInput.disabled) projectInput.focus({ preventScroll: true });
@@ -63,7 +63,7 @@ import { resolvePaneSession, resolveSwitchedVendor } from './pane-session.js';
63
63
  import { selectDefaultVendorForBlankSession } from './vendor-selection.js';
64
64
  import { getModelInfoUpdate, modelEntryValue, modelEntryMatches, handleModelSelectionResult, requestVendorModels } from './model-picker.js';
65
65
  import { getModelEffortLevels, accumulateUsage, updateUsagePanel, accumulateContext, updateContextPanel, renderCtxPopover, updateStatusPanel } from './app-panels.js';
66
- import { updateProjectList, resetClientState, showUpdateAvailable, handleRemoveProjectCheckResult, handleRemoveProjectResult, handleBrowseDirResult, handleAddProjectResult, handleCloneProgress } from './app-projects.js';
66
+ import { updateProjectList, resetClientState, showUpdateAvailable, handleRemoveProjectCheckResult, handleRemoveProjectResult, handleBrowseDirResult, handleAddProjectResult, handleCloneProgress, finishProjectSessionActivation } from './app-projects.js';
67
67
  import { updateHistorySentinel, prependOlderHistory } from './app-header.js';
68
68
  import { hideHomeHub, showHomeHub } from './app-home-hub.js';
69
69
  import { handleToolsState, handleToolInstalled, handleToolRemoved, handleToolStorageResult, handleToolLlmResult, handleToolsError, handleToolControlRequest, handleToolServerState, handleToolServerEvent, handleCapsuleGameSession } from './home-tools.js';
@@ -311,7 +311,7 @@ export function processMessage(msg) {
311
311
  detachTuiView();
312
312
  store.set({ projectName: msg.project || msg.cwd, vendorInfo: msg.vendors || {} });
313
313
  if (msg.cwd) store.set({ cwd: msg.cwd });
314
- if (msg.slug) store.set({ currentSlug: msg.slug });
314
+ if (msg.slug) store.set({ currentSlug: msg.slug, activeProjectSlug: msg.slug });
315
315
  try { var _is = store.snap(); localStorage.setItem("clay-project-name-" + (_is.currentSlug || "default"), _is.projectName); } catch (e) {}
316
316
  // In mate DM, keep title as mate name and re-apply mate color
317
317
  if (store.get('dmMode') && store.get('dmTargetUser') && store.get('dmTargetUser').isMate) {
@@ -762,7 +762,6 @@ export function processMessage(msg) {
762
762
  break;
763
763
 
764
764
  case "session_switched":
765
- hideHomeHub();
766
765
  closeWhatsNewArticle();
767
766
  // Save draft from outgoing session
768
767
  var _prevSid = store.get('activeSessionId');
@@ -860,6 +859,7 @@ export function processMessage(msg) {
860
859
  // Reload survival: a restored active session that is a split-group
861
860
  // member reopens its group (no-op when a split is already open).
862
861
  maybeRestoreSplitGroup();
862
+ if (finishProjectSessionActivation()) hideHomeHub();
863
863
  break;
864
864
 
865
865
  case "session_full_access_changed":
@@ -36,6 +36,8 @@ import { resetDebateState } from './debate.js';
36
36
  import { removeDebateBottomBar } from './app-debate-ui.js';
37
37
  import { closeProjectSettings } from './project-settings.js';
38
38
  import { chooseProjectAfterRemoval } from './project-removal-target.js';
39
+ import { isProjectActivated, isProjectActivationPending, isProjectContextConnected, projectWsPath } from './project-activation.js';
40
+ import { rememberHomePrimarySurface } from './home-surface.js';
39
41
 
40
42
  // --- Module-owned state ---
41
43
  var cachedProjects = [];
@@ -417,30 +419,32 @@ export function switchProject(slug) {
417
419
  if (!slug) return;
418
420
  document.cookie = "clay_last_project=" + encodeURIComponent(slug) + "; Path=/; SameSite=Strict; Max-Age=31536000";
419
421
  var st = store.snap();
420
- var targetWsPath = "/p/" + slug + "/ws";
421
- var alreadyInProject = slug === st.currentSlug && st.wsPath === targetWsPath;
422
+ var ws = getWs();
423
+ var targetWsPath = projectWsPath(slug);
424
+ var homeVisible = isHomeHubVisible();
425
+ var alreadyInProject = isProjectActivated(st, slug, ws);
422
426
  var wasDm = st.dmMode;
423
427
  var wasMate = st.dmMode && st.dmTargetUser && st.dmTargetUser.isMate;
424
428
  if (st.dmMode) exitDmMode(wasMate);
425
429
  closeWhatsNewArticle();
426
- if (isHomeHubVisible()) {
430
+ if (homeVisible && alreadyInProject) {
431
+ rememberHomePrimarySurface("project", slug);
427
432
  hideHomeHub();
428
- if (alreadyInProject) {
429
- if (document.documentElement.classList.contains("pwa-standalone")) {
430
- history.replaceState(null, "", "/p/" + slug + "/");
431
- } else {
432
- history.pushState(null, "", "/p/" + slug + "/");
433
- }
434
- return;
433
+ if (document.documentElement.classList.contains("pwa-standalone")) {
434
+ history.replaceState(null, "", "/p/" + slug + "/");
435
+ } else {
436
+ history.pushState(null, "", "/p/" + slug + "/");
435
437
  }
438
+ return;
436
439
  }
437
440
  if (alreadyInProject) {
438
- var ws = getWs();
439
441
  if (wasDm && ws && ws.readyState === 1) {
440
442
  ws.send(JSON.stringify({ type: "switch_session", id: store.get('activeSessionId') }));
441
443
  }
442
444
  return;
443
445
  }
446
+ if (homeVisible) store.set({ pendingHomeProjectSlug: slug });
447
+ if (isProjectActivationPending(store.snap(), slug, ws)) return;
444
448
  resetFileBrowser();
445
449
  closeNotesBrowser();
446
450
  hideMemory();
@@ -449,13 +453,42 @@ export function switchProject(slug) {
449
453
  store.set({ currentSlug: slug });
450
454
  store.set({ basePath: "/p/" + slug + "/" });
451
455
  store.set({ wsPath: targetWsPath });
456
+ if (!homeVisible) {
457
+ if (document.documentElement.classList.contains("pwa-standalone")) {
458
+ history.replaceState(null, "", "/p/" + slug + "/");
459
+ } else {
460
+ history.pushState(null, "", "/p/" + slug + "/");
461
+ }
462
+ }
463
+ resetClientState();
464
+ connect();
465
+ }
466
+
467
+ export function completePendingProjectActivation() {
468
+ var slug = store.get('pendingHomeProjectSlug');
469
+ if (!slug || !isProjectActivated(store.snap(), slug, getWs())) return false;
470
+ store.set({ pendingHomeProjectSlug: null });
471
+ rememberHomePrimarySurface("project", slug);
452
472
  if (document.documentElement.classList.contains("pwa-standalone")) {
453
473
  history.replaceState(null, "", "/p/" + slug + "/");
454
474
  } else {
455
475
  history.pushState(null, "", "/p/" + slug + "/");
456
476
  }
457
- resetClientState();
458
- connect();
477
+ return true;
478
+ }
479
+
480
+ export function confirmCurrentProjectSessionActivation() {
481
+ var slug = store.get('activeProjectSlug');
482
+ if (!slug || !isProjectContextConnected(store.snap(), slug, getWs())) return false;
483
+ store.set({ sessionActivatedProjectSlug: slug });
484
+ return true;
485
+ }
486
+
487
+ export function finishProjectSessionActivation() {
488
+ var confirmed = confirmCurrentProjectSessionActivation();
489
+ if (!confirmed) return false;
490
+ if (!store.get('pendingHomeProjectSlug')) return true;
491
+ return completePendingProjectActivation();
459
492
  }
460
493
 
461
494
  export function showUpdateAvailable(msg) {
@@ -0,0 +1,45 @@
1
+ // Pure project activation and Home-return selection helpers.
2
+
3
+ export function projectWsPath(slug) {
4
+ return slug ? "/p/" + slug + "/ws" : null;
5
+ }
6
+
7
+ export function chooseProjectActivationTarget(projects, candidates) {
8
+ var available = Array.isArray(projects) ? projects : [];
9
+ var preferred = Array.isArray(candidates) ? candidates : [];
10
+ for (var ci = 0; ci < preferred.length; ci++) {
11
+ if (!preferred[ci]) continue;
12
+ for (var pi = 0; pi < available.length; pi++) {
13
+ if (available[pi] && !available[pi].isMate && available[pi].slug === preferred[ci]) return preferred[ci];
14
+ }
15
+ }
16
+ for (var i = 0; i < available.length; i++) {
17
+ if (available[i] && !available[i].isMate && available[i].slug) return available[i].slug;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ export function isProjectActivationPending(state, slug, socket) {
23
+ var target = projectWsPath(slug);
24
+ return !!target
25
+ && state.currentSlug === slug
26
+ && state.wsPath === target
27
+ && state.socketPath === target
28
+ && !!socket
29
+ && (socket.readyState === 0 || socket.readyState === 1);
30
+ }
31
+
32
+ export function isProjectActivated(state, slug, socket) {
33
+ return isProjectActivationPending(state, slug, socket)
34
+ && socket.readyState === 1
35
+ && state.connected === true
36
+ && state.activeProjectSlug === slug
37
+ && state.sessionActivatedProjectSlug === slug;
38
+ }
39
+
40
+ export function isProjectContextConnected(state, slug, socket) {
41
+ return isProjectActivationPending(state, slug, socket)
42
+ && socket.readyState === 1
43
+ && state.connected === true
44
+ && state.activeProjectSlug === slug;
45
+ }
@@ -25,9 +25,11 @@ function handleChange(ctx, ws, msg) {
25
25
 
26
26
  function revokeViewers(ctx, session) {
27
27
  if (ctx.usersModule.isMultiUser() && session.sessionVisibility === "private") {
28
+ var project = ctx.getProjectAccess ? ctx.getProjectAccess() : null;
28
29
  ctx.clients.forEach(function(client) {
29
30
  if (client._clayActiveSession !== session.localId) return;
30
- if (client._clayUser && client._clayUser.id === session.ownerId) return;
31
+ var userId = client._clayUser && client._clayUser.id;
32
+ if (userId && ctx.usersModule.canAccessSession(userId, session, project)) return;
31
33
  // Clear the server-side selection before reconnecting so no further
32
34
  // session events or input can cross the revoked access boundary.
33
35
  client._clayActiveSession = null;
@@ -89,11 +89,11 @@ function attachPermissions(deps) {
89
89
  function canAccessSession(userId, session, project) {
90
90
  // Must have project access first
91
91
  if (!canAccessProject(userId, project)) return false;
92
+ // Resolve the stored user record so client-supplied roles cannot elevate access.
93
+ var user = findUserById(userId);
94
+ if (user && user.role === "admin") return true;
92
95
  // Sessions without ownerId are legacy -- only admin can see them
93
- if (!session.ownerId) {
94
- var user = findUserById(userId);
95
- return !!(user && user.role === "admin");
96
- }
96
+ if (!session.ownerId) return false;
97
97
  // Owner can always see their own sessions
98
98
  if (session.ownerId === userId) return true;
99
99
  // Sharing is opt-in; missing or invalid visibility stays private.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.0.0-beta.21",
3
+ "version": "4.0.0-beta.22",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",