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

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) {
@@ -1,4 +1,4 @@
1
- // One-shot boot routing after durable Home preferences become available.
1
+ // One-shot routing for a server-selected project or the no-project Home fallback.
2
2
  import { store } from './store.js';
3
3
  import { rememberHomePrimarySurface } from './home-surface.js';
4
4
 
@@ -10,9 +10,13 @@ function explicitProjectPath(pathname) {
10
10
  }
11
11
 
12
12
  export function resolveHomeBootDestination(options) {
13
- if (!options || options.surfaceLoaded !== true) return "wait";
13
+ if (!options) return "wait";
14
14
  if (options.paneMode || explicitProjectPath(options.pathname)) return "project";
15
- if (options.surface === "project" && options.currentSlug) return "project";
15
+ // The server-selected project embedded in the root shell is authoritative.
16
+ // Home is entered only through an explicit in-app action; reloading `/`
17
+ // returns to the project workspace even if Home was the last open surface.
18
+ if (options.currentSlug) return "project";
19
+ if (options.surfaceLoaded !== true) return "wait";
16
20
  if (options.dockLoaded !== true) return "wait";
17
21
  return "home";
18
22
  }
@@ -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
+ }
package/lib/server.js CHANGED
@@ -124,6 +124,14 @@ function stripPrefix(urlPath, slug) {
124
124
  return rest || "/";
125
125
  }
126
126
 
127
+ function injectRootProjectSlug(html, slug) {
128
+ var bodyPattern = /<body\b([^>]*)>/i;
129
+ if (!bodyPattern.test(html)) throw new Error("App shell body is missing");
130
+ return html.replace(bodyPattern, function (_match, attributes) {
131
+ return '<body' + attributes + ' data-home-project-slug="' + slug + '">';
132
+ });
133
+ }
134
+
127
135
  /**
128
136
  * Create a multi-project server.
129
137
  * opts: { tlsOptions, caPath, pinHash, port, debug, dangerouslySkipPermissions }
@@ -741,7 +749,8 @@ function createServer(opts) {
741
749
  // --- Skills routes (delegated to server-skills) ---
742
750
  if (skills.handleRequest(req, res, fullUrl)) return;
743
751
 
744
- // Root path — render home while keeping the last accessible project connected.
752
+ // Root path — render the default project workspace while keeping Home
753
+ // available as an explicit client-side surface.
745
754
  if (fullUrl === "/" && req.method === "GET") {
746
755
  if (!isRequestAuthed(req)) {
747
756
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
@@ -751,11 +760,16 @@ function createServer(opts) {
751
760
  if (projects.size > 0) {
752
761
  var targetSlug = null;
753
762
  var reqUser = users.isMultiUser() ? getMultiUserFromReq(req) : null;
763
+ function isOrdinaryProject(slug) {
764
+ var candidate = slug ? projects.get(slug) : null;
765
+ var status = candidate && candidate.getStatus ? candidate.getStatus() : null;
766
+ return !!candidate && (!status || status.isMate !== true);
767
+ }
754
768
  var homePreference = typeof users.getHomeSurfacePreference === "function"
755
769
  ? users.getHomeSurfacePreference(reqUser ? reqUser.id : "default")
756
770
  : null;
757
771
  var preferredContextSlug = homePreference && homePreference.projectSlug;
758
- if (preferredContextSlug && projects.has(preferredContextSlug)) {
772
+ if (isOrdinaryProject(preferredContextSlug)) {
759
773
  if (reqUser && onGetProjectAccess) {
760
774
  var preferredAccess = onGetProjectAccess(preferredContextSlug);
761
775
  if (preferredAccess && !preferredAccess.error && users.canAccessProject(reqUser.id, preferredAccess)) {
@@ -767,7 +781,7 @@ function createServer(opts) {
767
781
  }
768
782
  // Check for last-visited project cookie
769
783
  var lastProject = parseCookies(req)["clay_last_project"];
770
- if (!targetSlug && lastProject && projects.has(lastProject)) {
784
+ if (!targetSlug && isOrdinaryProject(lastProject)) {
771
785
  if (reqUser && onGetProjectAccess) {
772
786
  var lpAccess = onGetProjectAccess(lastProject);
773
787
  if (lpAccess && !lpAccess.error && users.canAccessProject(reqUser.id, lpAccess)) {
@@ -781,6 +795,7 @@ function createServer(opts) {
781
795
  if (!targetSlug) {
782
796
  projects.forEach(function (ctx, s) {
783
797
  if (targetSlug) return;
798
+ if (!isOrdinaryProject(s)) return;
784
799
  if (reqUser && onGetProjectAccess) {
785
800
  var access = onGetProjectAccess(s);
786
801
  if (access && !access.error && users.canAccessProject(reqUser.id, access)) {
@@ -794,7 +809,7 @@ function createServer(opts) {
794
809
  if (targetSlug) {
795
810
  try {
796
811
  var homeHtml = fs.readFileSync(path.join(publicDir, "index.html"), "utf8");
797
- homeHtml = homeHtml.replace("<body>", '<body data-home-project-slug="' + targetSlug + '">');
812
+ homeHtml = injectRootProjectSlug(homeHtml, targetSlug);
798
813
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
799
814
  res.end(homeHtml);
800
815
  } catch (e) {
@@ -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.23",
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",