clay-server 2.11.0-beta.9 → 2.11.0

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/daemon.js CHANGED
@@ -38,6 +38,20 @@ try {
38
38
  process.exit(1);
39
39
  }
40
40
 
41
+ // --- OS users mode: check required system dependencies ---
42
+ if (config.osUsers) {
43
+ var { execSync: checkExec } = require("child_process");
44
+ var missing = [];
45
+ try { checkExec("which setfacl", { stdio: "ignore" }); } catch (e) { missing.push("acl (setfacl)"); }
46
+ try { checkExec("which git", { stdio: "ignore" }); } catch (e) { missing.push("git"); }
47
+ try { checkExec("which useradd", { stdio: "ignore" }); } catch (e) { missing.push("useradd"); }
48
+ if (missing.length > 0) {
49
+ console.error("[daemon] OS users mode requires missing system packages: " + missing.join(", "));
50
+ console.error("[daemon] Install with: sudo apt install " + missing.map(function (m) { return m.split(" ")[0]; }).join(" "));
51
+ process.exit(78); // EX_CONFIG
52
+ }
53
+ }
54
+
41
55
  // --- TLS ---
42
56
  var tlsOptions = null;
43
57
  if (config.tls) {
@@ -82,6 +96,18 @@ var lanIp = (function () {
82
96
  return null;
83
97
  })();
84
98
 
99
+ // --- Helper: get removed projects filtered by existing paths and userId ---
100
+ function getFilteredRemovedProjects(userId) {
101
+ if (!config.removedProjects || config.removedProjects.length === 0) return [];
102
+ return config.removedProjects.filter(function (rp) {
103
+ // In single-user mode (no userId), show entries with no userId
104
+ // In multi-user mode, only show entries belonging to this user
105
+ if (userId && rp.userId && rp.userId !== userId) return false;
106
+ if (!userId && rp.userId) return false;
107
+ return fs.existsSync(rp.path);
108
+ });
109
+ }
110
+
85
111
  // --- Create multi-project server ---
86
112
  var listenHost = config.host || "0.0.0.0";
87
113
 
@@ -94,7 +120,8 @@ var relay = createServer({
94
120
  dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
95
121
  osUsers: config.osUsers || false,
96
122
  lanHost: lanIp ? lanIp + ":" + config.port : null,
97
- onAddProject: function (absPath) {
123
+ getRemovedProjects: function (userId) { return getFilteredRemovedProjects(userId); },
124
+ onAddProject: function (absPath, wsUser) {
98
125
  // Check if already registered
99
126
  for (var j = 0; j < config.projects.length; j++) {
100
127
  if (config.projects[j].path === absPath) {
@@ -104,7 +131,17 @@ var relay = createServer({
104
131
  var slugs = config.projects.map(function (p) { return p.slug; });
105
132
  var slug = generateSlug(absPath, slugs);
106
133
  relay.addProject(absPath, slug);
107
- config.projects.push({ path: absPath, slug: slug, addedAt: Date.now() });
134
+ var projectEntry = { path: absPath, slug: slug, addedAt: Date.now() };
135
+ // Non-admin users own their projects and they default to private
136
+ if (wsUser && wsUser.id && wsUser.role !== "admin") {
137
+ projectEntry.ownerId = wsUser.id;
138
+ projectEntry.visibility = "private";
139
+ }
140
+ config.projects.push(projectEntry);
141
+ // Remove from removedProjects if present
142
+ if (config.removedProjects) {
143
+ config.removedProjects = config.removedProjects.filter(function (rp) { return rp.path !== absPath; });
144
+ }
108
145
  saveConfig(config);
109
146
  try { syncClayrc(config.projects); } catch (e) {}
110
147
  console.log("[daemon] Added project (web):", slug, "→", absPath);
@@ -127,6 +164,7 @@ var relay = createServer({
127
164
  return { ok: true, slug: slug };
128
165
  },
129
166
  onCreateProject: function (projectName, wsUser) {
167
+ console.log("[daemon] onCreateProject wsUser:", JSON.stringify(wsUser ? { id: wsUser.id, role: wsUser.role, username: wsUser.username, linuxUser: wsUser.linuxUser } : null));
130
168
  var os = require("os");
131
169
  var { execSync } = require("child_process");
132
170
  var baseDir;
@@ -152,8 +190,9 @@ var relay = createServer({
152
190
  uidGid = { uid: parseInt(passwdLine[0], 10), gid: parseInt(passwdLine[1], 10) };
153
191
  } catch (e) {}
154
192
  if (uidGid) {
155
- execSync("git init", { cwd: targetDir, uid: uidGid.uid, gid: uidGid.gid });
193
+ fs.chmodSync(targetDir, 0o700);
156
194
  execSync("chown -R " + linuxUser + ":" + linuxUser + " " + JSON.stringify(targetDir));
195
+ execSync("git init", { cwd: targetDir, uid: uidGid.uid, gid: uidGid.gid, env: { PATH: "/usr/local/bin:/usr/bin:/bin" } });
157
196
  } else {
158
197
  execSync("git init", { cwd: targetDir });
159
198
  }
@@ -169,17 +208,25 @@ var relay = createServer({
169
208
  }
170
209
  // Register project
171
210
  var projectEntry = { path: targetDir, slug: slug, addedAt: Date.now() };
172
- if (config.osUsers && wsUser) {
173
- projectEntry.ownerId = wsUser.id;
211
+ if (wsUser && wsUser.id) {
212
+ if (config.osUsers || wsUser.role !== "admin") {
213
+ projectEntry.ownerId = wsUser.id;
214
+ }
215
+ if (wsUser.role !== "admin") {
216
+ projectEntry.visibility = "private";
217
+ }
174
218
  }
175
219
  relay.addProject(targetDir, slug);
176
220
  config.projects.push(projectEntry);
177
221
  saveConfig(config);
178
222
  try { syncClayrc(config.projects); } catch (e) {}
179
- console.log("[daemon] Created project:", slug, "→", targetDir);
223
+ console.log("[daemon] Created project:", slug, "→", targetDir, "entry:", JSON.stringify({ ownerId: projectEntry.ownerId, visibility: projectEntry.visibility }));
180
224
  // OS users mode: grant ACL
181
225
  if (config.osUsers && wsUser && wsUser.linuxUser) {
226
+ console.log("[daemon] Granting ACL:", targetDir, "→", wsUser.linuxUser);
182
227
  grantProjectAccess(targetDir, wsUser.linuxUser);
228
+ } else if (config.osUsers) {
229
+ console.log("[daemon] Skipping ACL grant: osUsers=true but linuxUser=", wsUser && wsUser.linuxUser);
183
230
  }
184
231
  relay.broadcastAll({
185
232
  type: "projects_updated",
@@ -227,14 +274,22 @@ var relay = createServer({
227
274
  callback({ ok: false, error: errMsg });
228
275
  return;
229
276
  }
230
- // chown if osUsers
277
+ // chown and restrict permissions if osUsers
231
278
  if (config.osUsers && wsUser && wsUser.linuxUser) {
232
- try { execSync("chown -R " + wsUser.linuxUser + ":" + wsUser.linuxUser + " " + JSON.stringify(targetDir)); } catch (e) {}
279
+ try {
280
+ fs.chmodSync(targetDir, 0o700);
281
+ execSync("chown -R " + wsUser.linuxUser + ":" + wsUser.linuxUser + " " + JSON.stringify(targetDir));
282
+ } catch (e) {}
233
283
  }
234
284
  // Register project
235
285
  var projectEntry = { path: targetDir, slug: slug, addedAt: Date.now() };
236
- if (config.osUsers && wsUser) {
237
- projectEntry.ownerId = wsUser.id;
286
+ if (wsUser && wsUser.id) {
287
+ if (config.osUsers || wsUser.role !== "admin") {
288
+ projectEntry.ownerId = wsUser.id;
289
+ }
290
+ if (wsUser.role !== "admin") {
291
+ projectEntry.visibility = "private";
292
+ }
238
293
  }
239
294
  relay.addProject(targetDir, slug);
240
295
  config.projects.push(projectEntry);
@@ -257,22 +312,30 @@ var relay = createServer({
257
312
  callback({ ok: false, error: "Failed to start git clone: " + err.message });
258
313
  });
259
314
  },
260
- onRemoveProject: function (slug) {
261
- var found = false;
315
+ onRemoveProject: function (slug, userId) {
316
+ var found = null;
262
317
  for (var j = 0; j < config.projects.length; j++) {
263
- if (config.projects[j].slug === slug) { found = true; break; }
318
+ if (config.projects[j].slug === slug) { found = config.projects[j]; break; }
264
319
  }
265
320
  if (!found) return { ok: false, error: "Project not found" };
266
- // Find path before removing so we can clean up .clayrc
267
- var removedPath = null;
268
- for (var rj = 0; rj < config.projects.length; rj++) {
269
- if (config.projects[rj].slug === slug) { removedPath = config.projects[rj].path; break; }
321
+ // Save to removedProjects for re-add functionality
322
+ if (!config.removedProjects) config.removedProjects = [];
323
+ config.removedProjects.push({
324
+ path: found.path,
325
+ title: found.title || null,
326
+ icon: found.icon || null,
327
+ userId: userId || null,
328
+ removedAt: Date.now(),
329
+ });
330
+ // Cap at 20 entries (oldest first)
331
+ if (config.removedProjects.length > 20) {
332
+ config.removedProjects = config.removedProjects.slice(config.removedProjects.length - 20);
270
333
  }
271
334
  relay.removeProject(slug);
272
335
  config.projects = config.projects.filter(function (p) { return p.slug !== slug; });
273
336
  saveConfig(config);
274
337
  // Remove from .clayrc so it doesn't appear in restore prompt
275
- if (removedPath) { try { removeFromClayrc(removedPath); } catch (e) {} }
338
+ if (found.path) { try { removeFromClayrc(found.path); } catch (e) {} }
276
339
  try { syncClayrc(config.projects); } catch (e) {}
277
340
  console.log("[daemon] Removed project (web):", slug);
278
341
  relay.broadcastAll({
@@ -356,6 +419,7 @@ var relay = createServer({
356
419
  return { ok: true };
357
420
  },
358
421
  onProjectOwnerChanged: function (slug, ownerId) {
422
+ console.log("[daemon] onProjectOwnerChanged:", slug, "→", ownerId);
359
423
  var oldOwnerId = null;
360
424
  var projectIdx = -1;
361
425
  for (var oi = 0; oi < config.projects.length; oi++) {
@@ -386,6 +450,7 @@ var relay = createServer({
386
450
  // Grant new owner
387
451
  if (ownerId) {
388
452
  var newOwner = usersModule.findUserById(ownerId);
453
+ console.log("[daemon] Owner grant ACL:", ownerId, "linuxUser:", newOwner && newOwner.linuxUser, "path:", projectPath);
389
454
  if (newOwner && newOwner.linuxUser) {
390
455
  grantProjectAccess(projectPath, newOwner.linuxUser);
391
456
  }
@@ -669,6 +734,7 @@ var relay = createServer({
669
734
  slug: slug,
670
735
  visibility: config.projects[i].visibility || "public",
671
736
  allowedUsers: config.projects[i].allowedUsers || [],
737
+ ownerId: config.projects[i].ownerId || null,
672
738
  };
673
739
  }
674
740
  }
@@ -1052,25 +1118,31 @@ if (config.keepAwake && process.platform === "darwin") {
1052
1118
 
1053
1119
  // --- Spawn new daemon and graceful restart ---
1054
1120
  function spawnAndRestart() {
1055
- var { spawn: spawnRestart } = require("child_process");
1056
- var { logPath: restartLogPath, configPath: restartConfigPath } = require("./config");
1057
- var daemonScript = path.join(__dirname, "daemon.js");
1058
- var logFd = fs.openSync(restartLogPath(), "a");
1059
- var child = spawnRestart(process.execPath, [daemonScript], {
1060
- detached: true,
1061
- windowsHide: true,
1062
- stdio: ["ignore", logFd, logFd],
1063
- env: Object.assign({}, process.env, {
1064
- CLAY_CONFIG: restartConfigPath(),
1065
- }),
1066
- });
1067
- child.unref();
1068
- fs.closeSync(logFd);
1069
- config.pid = child.pid;
1070
- saveConfig(config);
1071
- console.log("[daemon] Spawned new daemon (PID " + child.pid + "), shutting down...");
1072
- updateHandoff = true;
1073
- setTimeout(function () { gracefulShutdown(); }, 100);
1121
+ try {
1122
+ var { spawn: spawnRestart } = require("child_process");
1123
+ var { logPath: restartLogPath, configPath: restartConfigPath } = require("./config");
1124
+ var daemonScript = path.join(__dirname, "daemon.js");
1125
+ var logFd = fs.openSync(restartLogPath(), "a");
1126
+ var child = spawnRestart(process.execPath, [daemonScript], {
1127
+ detached: true,
1128
+ windowsHide: true,
1129
+ stdio: ["ignore", logFd, logFd],
1130
+ env: Object.assign({}, process.env, {
1131
+ CLAY_CONFIG: restartConfigPath(),
1132
+ }),
1133
+ });
1134
+ child.unref();
1135
+ fs.closeSync(logFd);
1136
+ config.pid = child.pid;
1137
+ saveConfig(config);
1138
+ console.log("[daemon] Spawned new daemon (PID " + child.pid + "), shutting down...");
1139
+ updateHandoff = true;
1140
+ setTimeout(function () { gracefulShutdown(); }, 100);
1141
+ } catch (e) {
1142
+ console.error("[daemon] Restart failed:", e.message);
1143
+ relay.broadcastAll({ type: "toast", level: "error", message: "Restart failed: " + e.message });
1144
+ relay.broadcastAll({ type: "restart_server_result", ok: false, error: e.message });
1145
+ }
1074
1146
  }
1075
1147
 
1076
1148
  // --- Graceful shutdown ---
package/lib/os-users.js CHANGED
@@ -174,6 +174,52 @@ function linuxUserExists(username) {
174
174
  }
175
175
  }
176
176
 
177
+ /**
178
+ * Install Claude CLI for a Linux user account.
179
+ * Downloads and runs the install script, then ensures PATH is configured.
180
+ * Non-fatal: logs errors but does not throw.
181
+ */
182
+ function installClaudeCli(linuxName) {
183
+ try {
184
+ // Download and run the Claude CLI install script as the target user
185
+ execSync(
186
+ "su - " + linuxName + " -c \"curl -fsSL https://claude.ai/install.sh | bash\"",
187
+ { encoding: "utf8", timeout: 60000, stdio: "pipe" }
188
+ );
189
+ console.log("[os-users] Claude CLI installed for " + linuxName);
190
+ } catch (e) {
191
+ var msg = (e.stderr || e.message || "").trim();
192
+ console.error("[os-users] Failed to install Claude CLI for " + linuxName + ": " + msg);
193
+ return;
194
+ }
195
+
196
+ // Append PATH export to the user's shell config if not already present
197
+ try {
198
+ var home = "/home/" + linuxName;
199
+ var rcFile;
200
+ if (fs.existsSync(home + "/.zshrc")) {
201
+ rcFile = "~/.zshrc";
202
+ } else {
203
+ rcFile = "~/.bashrc";
204
+ }
205
+
206
+ // Check if PATH export is already present before appending
207
+ var checkCmd = "su - " + linuxName + " -c \"grep -qF '/.local/bin' " + rcFile + "\"";
208
+ try {
209
+ execSync(checkCmd, { encoding: "utf8", timeout: 5000, stdio: "pipe" });
210
+ console.log("[os-users] PATH already configured in " + rcFile + " for " + linuxName);
211
+ } catch (grepErr) {
212
+ // grep returned non-zero, meaning the line is not present; append it
213
+ var appendCmd = "su - " + linuxName + " -c 'echo \"export PATH=\\\"\\$HOME/.local/bin:\\$PATH\\\"\" >> " + rcFile + "'";
214
+ execSync(appendCmd, { encoding: "utf8", timeout: 5000, stdio: "pipe" });
215
+ console.log("[os-users] PATH export appended to " + rcFile + " for " + linuxName);
216
+ }
217
+ } catch (e) {
218
+ var rcMsg = (e.stderr || e.message || "").trim();
219
+ console.error("[os-users] Failed to configure PATH for " + linuxName + ": " + rcMsg);
220
+ }
221
+ }
222
+
177
223
  /**
178
224
  * Provision a Linux user account for a Clay user.
179
225
  * Creates the account via useradd with a home directory.
@@ -197,6 +243,7 @@ function provisionLinuxUser(clayUsername) {
197
243
  stdio: "pipe",
198
244
  });
199
245
  console.log("[os-users] Provisioned Linux user: " + linuxName + " (Clay user: " + clayUsername + ")");
246
+ installClaudeCli(linuxName);
200
247
  return { ok: true, linuxUser: linuxName };
201
248
  } catch (e) {
202
249
  var msg = (e.stderr || e.message || "").trim();
@@ -219,6 +266,12 @@ function provisionAllUsers(usersModule) {
219
266
  if (user.linuxUser) {
220
267
  // Already mapped, verify the Linux user still exists
221
268
  if (linuxUserExists(user.linuxUser)) {
269
+ // Ensure Claude CLI is installed for existing users
270
+ var cliPath = "/home/" + user.linuxUser + "/.local/bin/claude";
271
+ if (!fs.existsSync(cliPath)) {
272
+ console.log("[os-users] Claude CLI missing for " + user.linuxUser + ", installing...");
273
+ installClaudeCli(user.linuxUser);
274
+ }
222
275
  result.skipped.push({ id: user.id, username: user.username, linuxUser: user.linuxUser });
223
276
  continue;
224
277
  }
@@ -282,7 +335,10 @@ function deactivateLinuxUser(linuxUsername) {
282
335
  function ensureProjectsDir() {
283
336
  var dir = "/var/clay/projects";
284
337
  if (!fs.existsSync(dir)) {
285
- fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
338
+ fs.mkdirSync(dir, { recursive: true, mode: 0o711 });
339
+ } else {
340
+ // Tighten permissions if directory already exists (prevent listing by other users)
341
+ try { fs.chmodSync(dir, 0o711); } catch (e) {}
286
342
  }
287
343
  }
288
344
 
@@ -296,6 +352,7 @@ module.exports = {
296
352
  provisionLinuxUser: provisionLinuxUser,
297
353
  provisionAllUsers: provisionAllUsers,
298
354
  grantAllUsersAccess: grantAllUsersAccess,
355
+ installClaudeCli: installClaudeCli,
299
356
  deactivateLinuxUser: deactivateLinuxUser,
300
357
  ensureProjectsDir: ensureProjectsDir,
301
358
  };
package/lib/pages.js CHANGED
@@ -3,13 +3,21 @@ function pinPageHtml() {
3
3
  '<meta charset="UTF-8">' +
4
4
  '<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' +
5
5
  '<meta name="apple-mobile-web-app-capable" content="yes">' +
6
+ '<link rel="icon" type="image/png" href="/favicon-banded.png">' +
7
+ '<link rel="apple-touch-icon" href="/apple-touch-icon.png">' +
6
8
  '<title>Clay</title>' +
7
9
  '<style>' + authPageStyles + '</style></head><body><div class="c">' +
8
- '<h1>Welcome back</h1>' +
10
+ '<h1 id="greeting"></h1>' +
9
11
  '<div class="sub">Enter your PIN to continue</div>' +
10
12
  pinBoxesHtml +
11
13
  '<div class="err" id="err"></div>' +
12
14
  '<script>' +
15
+ '(function(){' +
16
+ 'var h=document.getElementById("greeting");' +
17
+ 'var visited=localStorage.getItem("clay_visited");' +
18
+ 'if(visited){h.textContent="Welcome back"}' +
19
+ 'else{h.textContent="Welcome to Clay";localStorage.setItem("clay_visited","1")}' +
20
+ '})();' +
13
21
  pinBoxScript +
14
22
  'var err=document.getElementById("err");' +
15
23
  'function submitPin(){' +
@@ -966,39 +974,33 @@ function multiUserLoginPageHtml() {
966
974
  '.catch(function(){errs[1].textContent="Connection error";btns[1].disabled=false})}' +
967
975
  'btns[1].onclick=doLogin;' +
968
976
 
969
- // Force PIN change overlay
977
+ // Force PIN change: reuse the same login screen, switch step1 to "Set new PIN" mode
970
978
  'function showChangePinOverlay(){' +
971
- 'var ov=document.createElement("div");ov.className="c";' +
972
- 'ov.style.cssText="position:fixed;inset:0;background:var(--bg,#0e0e10);z-index:9999;display:flex;align-items:center;justify-content:center";' +
973
- 'ov.innerHTML=\'<div style="width:100%;max-width:380px;padding:24px"><h1>Set your new PIN</h1>\'+' +
974
- '\'<div class="sub">Your temporary PIN has expired. Please set a new 6-digit PIN to continue.</div>\'+' +
975
- '\'<div id="new-pin-boxes" class="pin-boxes">\'+' +
976
- '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
977
- '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
978
- '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
979
- '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
980
- '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
981
- '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
982
- '\'</div><input type="hidden" id="new-pin">\'+' +
983
- '\'<button class="btn" id="save-new-pin" disabled style="margin-top:20px">Save PIN</button>\'+' +
984
- '\'<div class="err" id="new-pin-err"></div></div>\';' +
985
- 'document.body.appendChild(ov);' +
986
- 'var newPinEl=ov.querySelector("#new-pin");' +
987
- 'var saveBtn=ov.querySelector("#save-new-pin");' +
988
- 'var errEl=ov.querySelector("#new-pin-err");' +
989
- 'initPinBoxes("new-pin-boxes","new-pin",function(){if(!saveBtn.disabled)doSavePin()});' +
990
- 'var nb=ov.querySelectorAll(".pin-digit");' +
991
- 'for(var i=0;i<nb.length;i++)nb[i].addEventListener("input",function(){saveBtn.disabled=newPinEl.value.length!==6});' +
992
- 'function doSavePin(){' +
993
- 'saveBtn.disabled=true;errEl.textContent="";' +
979
+ // Hide step dots (no longer a 2-step flow)
980
+ 'var bar=document.querySelector(".steps-bar");if(bar)bar.style.display="none";' +
981
+ // Hide step 0 (username), show step 1 (PIN) with new labels
982
+ 'steps[0].classList.remove("active");' +
983
+ 'steps[1].classList.add("active");' +
984
+ 'var h1=steps[1].querySelector("h1");if(h1)h1.textContent="Set your new PIN";' +
985
+ 'var sub=steps[1].querySelector(".sub");if(sub)sub.textContent="Your temporary PIN has expired. Please set a new 6-digit PIN to continue.";' +
986
+ 'btns[1].textContent="Save PIN";' +
987
+ // Re-init PIN boxes for fresh input
988
+ 'resetPin();' +
989
+ 'initPinBoxes("pin-boxes","pin",function(){if(!btns[1].disabled)doSaveNewPin()});' +
990
+ 'var boxes=document.querySelectorAll(".pin-digit");' +
991
+ 'for(var i=0;i<boxes.length;i++)boxes[i].addEventListener("input",function(){btns[1].disabled=pinEl.value.length!==6});' +
992
+ // Override button handler to save new PIN instead of login
993
+ 'btns[1].onclick=doSaveNewPin;' +
994
+ 'function doSaveNewPin(){' +
995
+ 'btns[1].disabled=true;errs[1].textContent="";' +
994
996
  'fetch("/api/user/pin",{method:"PUT",headers:{"Content-Type":"application/json"},' +
995
- 'body:JSON.stringify({newPin:newPinEl.value})})' +
997
+ 'body:JSON.stringify({newPin:pinEl.value})})' +
996
998
  '.then(function(r){return r.json()})' +
997
999
  '.then(function(d){' +
998
1000
  'if(d.ok){location.reload();return}' +
999
- 'errEl.textContent=d.error||"Failed to save PIN";saveBtn.disabled=false})' +
1000
- '.catch(function(){errEl.textContent="Connection error";saveBtn.disabled=false})}' +
1001
- 'saveBtn.onclick=doSavePin}' +
1001
+ 'errs[1].textContent=d.error||"Failed to save PIN";btns[1].disabled=false})' +
1002
+ '.catch(function(){errs[1].textContent="Connection error";btns[1].disabled=false})}' +
1003
+ '}' +
1002
1004
 
1003
1005
  '</script></div></body></html>';
1004
1006
  }
package/lib/project.js CHANGED
@@ -108,6 +108,7 @@ function createProjectContext(opts) {
108
108
  var moveAllSchedulesToProject = opts.moveAllSchedulesToProject || function () { return { ok: false, error: "Not supported" }; };
109
109
  var getScheduleCount = opts.getScheduleCount || function () { return 0; };
110
110
  var onProcessingChanged = opts.onProcessingChanged || function () {};
111
+ var onSessionDone = opts.onSessionDone || function () {};
111
112
  var onPresenceChange = opts.onPresenceChange || function () {};
112
113
  var updateChannel = opts.updateChannel || "stable";
113
114
  var osUsers = opts.osUsers || false;
@@ -176,6 +177,13 @@ function createProjectContext(opts) {
176
177
  if (ws.readyState === 1) ws.send(JSON.stringify(obj));
177
178
  }
178
179
 
180
+ function sendToAdmins(obj) {
181
+ var data = JSON.stringify(obj);
182
+ for (var ws of clients) {
183
+ if (ws.readyState === 1 && ws._clayUser && ws._clayUser.role === "admin") ws.send(data);
184
+ }
185
+ }
186
+
179
187
  function broadcastClientCount() {
180
188
  var msg = { type: "client_count", count: clients.size };
181
189
  if (usersModule.isMultiUser()) {
@@ -338,6 +346,7 @@ function createProjectContext(opts) {
338
346
  fn(ws, filterFn);
339
347
  }
340
348
  },
349
+ onSessionDone: onSessionDone,
341
350
  });
342
351
  var _projMode = typeof opts.onGetProjectDefaultMode === "function" ? opts.onGetProjectDefaultMode(slug) : null;
343
352
  var _srvMode = typeof opts.onGetServerDefaultMode === "function" ? opts.onGetServerDefaultMode() : null;
@@ -998,11 +1007,11 @@ function createProjectContext(opts) {
998
1007
  var tm = createTerminalManager({ cwd: cwd, send: send, sendTo: sendTo });
999
1008
  var nm = createNotesManager({ cwd: cwd, send: send, sendTo: sendTo });
1000
1009
 
1001
- // Check for updates in background
1010
+ // Check for updates in background (admin only)
1002
1011
  fetchVersion(updateChannel).then(function (v) {
1003
1012
  if (v && isNewer(v, currentVersion)) {
1004
1013
  latestVersion = v;
1005
- send({ type: "update_available", version: v });
1014
+ sendToAdmins({ type: "update_available", version: v });
1006
1015
  }
1007
1016
  });
1008
1017
 
@@ -1022,7 +1031,7 @@ function createProjectContext(opts) {
1022
1031
  var _userId = ws._clayUser ? ws._clayUser.id : null;
1023
1032
  var _filteredProjects = getProjectList(_userId);
1024
1033
  sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, dangerouslySkipPermissions: dangerouslySkipPermissions, osUsers: osUsers, lanHost: lanHost, projectCount: _filteredProjects.length, projects: _filteredProjects, projectOwnerId: projectOwnerId });
1025
- if (latestVersion) {
1034
+ if (latestVersion && ws._clayUser && ws._clayUser.role === "admin") {
1026
1035
  sendTo(ws, { type: "update_available", version: latestVersion });
1027
1036
  }
1028
1037
  if (sm.slashCommands) {
@@ -1203,7 +1212,7 @@ function createProjectContext(opts) {
1203
1212
 
1204
1213
  function handleMessage(ws, msg) {
1205
1214
  // --- DM messages (delegated to server-level handler) ---
1206
- if (msg.type === "dm_open" || msg.type === "dm_send" || msg.type === "dm_list" || msg.type === "dm_typing") {
1215
+ if (msg.type === "dm_open" || msg.type === "dm_send" || msg.type === "dm_list" || msg.type === "dm_typing" || msg.type === "dm_add_favorite" || msg.type === "dm_remove_favorite") {
1207
1216
  if (typeof opts.onDmMessage === "function") {
1208
1217
  opts.onDmMessage(ws, msg);
1209
1218
  }
@@ -1405,9 +1414,17 @@ function createProjectContext(opts) {
1405
1414
  if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return;
1406
1415
  var newChannel = msg.channel === "beta" ? "beta" : "stable";
1407
1416
  updateChannel = newChannel;
1417
+ latestVersion = null;
1408
1418
  if (typeof opts.onSetUpdateChannel === "function") {
1409
1419
  opts.onSetUpdateChannel(newChannel);
1410
1420
  }
1421
+ // Re-fetch with new channel and broadcast to admin clients
1422
+ fetchVersion(updateChannel).then(function (v) {
1423
+ if (v && isNewer(v, currentVersion)) {
1424
+ latestVersion = v;
1425
+ sendToAdmins({ type: "update_available", version: v });
1426
+ }
1427
+ }).catch(function () {});
1411
1428
  return;
1412
1429
  }
1413
1430
 
@@ -1815,7 +1832,7 @@ function createProjectContext(opts) {
1815
1832
  new Promise(function (resolve) { setTimeout(resolve, 3000); }),
1816
1833
  ]).then(function () {
1817
1834
  try {
1818
- var newSession = sm.createSession();
1835
+ var newSession = sm.createSession(null, ws);
1819
1836
  // Send the plan as the first user message (with planContent for UI rendering)
1820
1837
  var userMsg = { type: "user_message", text: planPrompt, planContent: clientPlanContent || null };
1821
1838
  newSession.history.push(userMsg);
@@ -1963,7 +1980,7 @@ function createProjectContext(opts) {
1963
1980
  return;
1964
1981
  }
1965
1982
  if (typeof opts.onAddProject === "function") {
1966
- var result = opts.onAddProject(addAbs);
1983
+ var result = opts.onAddProject(addAbs, ws._clayUser);
1967
1984
  sendTo(ws, { type: "add_project_result", ok: result.ok, slug: result.slug, error: result.error, existing: result.existing });
1968
1985
  } else {
1969
1986
  sendTo(ws, { type: "add_project_result", ok: false, error: "Not supported" });
@@ -2029,8 +2046,10 @@ function createProjectContext(opts) {
2029
2046
  moveAllSchedulesToProject(removeSlug, msg.moveTasksTo);
2030
2047
  }
2031
2048
  if (typeof opts.onRemoveProject === "function") {
2032
- var removeResult = opts.onRemoveProject(removeSlug);
2033
- sendTo(ws, { type: "remove_project_result", ok: removeResult.ok, slug: removeSlug, error: removeResult.error });
2049
+ // Send result before removing so the WS is still open
2050
+ sendTo(ws, { type: "remove_project_result", ok: true, slug: removeSlug });
2051
+ var removeUserId = ws._clayUser ? ws._clayUser.id : null;
2052
+ opts.onRemoveProject(removeSlug, removeUserId);
2034
2053
  } else {
2035
2054
  sendTo(ws, { type: "remove_project_result", ok: false, error: "Not supported" });
2036
2055
  }