clay-server 2.8.2 → 2.9.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/project.js CHANGED
@@ -9,9 +9,35 @@ var { createNotesManager } = require("./notes");
9
9
  var { fetchLatestVersion, isNewer } = require("./updater");
10
10
  var { execFileSync, spawn } = require("child_process");
11
11
  var { createLoopRegistry } = require("./scheduler");
12
+ var usersModule = require("./users");
12
13
 
13
14
  var MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
14
15
 
16
+ // Validate environment variable string (KEY=VALUE per line)
17
+ // Returns null if valid, or an error string if invalid
18
+ function validateEnvString(str) {
19
+ if (!str || !str.trim()) return null;
20
+ var lines = str.split("\n");
21
+ for (var i = 0; i < lines.length; i++) {
22
+ var line = lines[i].trim();
23
+ if (!line || line.charAt(0) === "#") continue;
24
+ // Must be KEY=VALUE format
25
+ var eqIdx = line.indexOf("=");
26
+ if (eqIdx < 1) return "Invalid format at line " + (i + 1) + ": expected KEY=VALUE";
27
+ var key = line.substring(0, eqIdx);
28
+ // Key must be valid env var name (no shell metacharacters)
29
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
30
+ return "Invalid variable name at line " + (i + 1) + ": " + key;
31
+ }
32
+ // Value must not contain shell injection characters
33
+ var value = line.substring(eqIdx + 1);
34
+ if (/[`$\\;|&><(){}\n]/.test(value) && !/^["'].*["']$/.test(value)) {
35
+ return "Potentially unsafe value at line " + (i + 1) + ": shell metacharacters detected";
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+
15
41
  // SDK loaded dynamically (ESM module)
16
42
  var sdkModule = null;
17
43
  function getSDK() {
@@ -81,6 +107,7 @@ function createProjectContext(opts) {
81
107
  var moveAllSchedulesToProject = opts.moveAllSchedulesToProject || function () { return { ok: false, error: "Not supported" }; };
82
108
  var getScheduleCount = opts.getScheduleCount || function () { return 0; };
83
109
  var onProcessingChanged = opts.onProcessingChanged || function () {};
110
+ var onPresenceChange = opts.onPresenceChange || function () {};
84
111
  var latestVersion = null;
85
112
 
86
113
  // --- Per-project clients ---
@@ -98,7 +125,28 @@ function createProjectContext(opts) {
98
125
  }
99
126
 
100
127
  function broadcastClientCount() {
101
- send({ type: "client_count", count: clients.size });
128
+ var msg = { type: "client_count", count: clients.size };
129
+ if (usersModule.isMultiUser()) {
130
+ var seen = {};
131
+ var userList = [];
132
+ for (var c of clients) {
133
+ if (!c._clayUser) continue;
134
+ var u = c._clayUser;
135
+ if (seen[u.id]) continue;
136
+ seen[u.id] = true;
137
+ var p = u.profile || {};
138
+ userList.push({
139
+ id: u.id,
140
+ displayName: p.name || u.displayName || u.username,
141
+ username: u.username,
142
+ avatarStyle: p.avatarStyle || "thumbs",
143
+ avatarSeed: p.avatarSeed || u.username,
144
+ });
145
+ }
146
+ msg.users = userList;
147
+ }
148
+ send(msg);
149
+ onPresenceChange();
102
150
  }
103
151
 
104
152
  function sendToOthers(sender, obj) {
@@ -202,7 +250,25 @@ function createProjectContext(opts) {
202
250
  }
203
251
 
204
252
  // --- Session manager ---
205
- var sm = createSessionManager({ cwd: cwd, send: send });
253
+ var sm = createSessionManager({
254
+ cwd: cwd,
255
+ send: send,
256
+ sendTo: sendTo,
257
+ sendEach: function (fn) {
258
+ for (var ws of clients) {
259
+ var user = ws._clayUser;
260
+ var filterFn = null;
261
+ if (usersModule.isMultiUser() && user) {
262
+ filterFn = (function (u) {
263
+ return function (s) {
264
+ return usersModule.canAccessSession(u.id, s, { visibility: "public" });
265
+ };
266
+ })(user);
267
+ }
268
+ fn(ws, filterFn);
269
+ }
270
+ },
271
+ });
206
272
  var _projMode = typeof opts.onGetProjectDefaultMode === "function" ? opts.onGetProjectDefaultMode(slug) : null;
207
273
  var _srvMode = typeof opts.onGetServerDefaultMode === "function" ? opts.onGetServerDefaultMode() : null;
208
274
  sm.currentPermissionMode = (_projMode && _projMode.mode) || (_srvMode && _srvMode.mode) || "default";
@@ -320,6 +386,31 @@ function createProjectContext(opts) {
320
386
  } catch (e) {
321
387
  // No saved state, use defaults
322
388
  }
389
+ // Recover orphaned loops: if idle but completed loop files exist in .claude/loops/
390
+ if (loopState.phase === "idle") {
391
+ var _loopsBase = path.join(cwd, ".claude", "loops");
392
+ try {
393
+ var _loopDirs = fs.readdirSync(_loopsBase).filter(function (d) {
394
+ return d.indexOf("loop_") === 0;
395
+ });
396
+ for (var _li = 0; _li < _loopDirs.length; _li++) {
397
+ var _ld = path.join(_loopsBase, _loopDirs[_li]);
398
+ try {
399
+ fs.accessSync(path.join(_ld, "PROMPT.md"));
400
+ fs.accessSync(path.join(_ld, "JUDGE.md"));
401
+ fs.accessSync(path.join(_ld, "LOOP.json"));
402
+ // Found a completed loop — recover to approval phase
403
+ loopState.loopId = _loopDirs[_li];
404
+ loopState.phase = "approval";
405
+ var _loopCfg = JSON.parse(fs.readFileSync(path.join(_ld, "LOOP.json"), "utf8"));
406
+ loopState.maxIterations = _loopCfg.maxIterations || 20;
407
+ saveLoopState();
408
+ console.log("[ralph-loop] Recovered orphaned loop: " + _loopDirs[_li]);
409
+ break;
410
+ } catch (e) {}
411
+ }
412
+ } catch (e) {}
413
+ }
323
414
  }
324
415
 
325
416
  function clearLoopState() {
@@ -846,7 +937,8 @@ function createProjectContext(opts) {
846
937
  });
847
938
 
848
939
  // --- WS connection handler ---
849
- function handleConnection(ws) {
940
+ function handleConnection(ws, wsUser) {
941
+ ws._clayUser = wsUser || null;
850
942
  clients.add(ws);
851
943
  broadcastClientCount();
852
944
 
@@ -857,7 +949,9 @@ function createProjectContext(opts) {
857
949
  }
858
950
 
859
951
  // Send cached state
860
- sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, dangerouslySkipPermissions: dangerouslySkipPermissions, lanHost: lanHost, projectCount: getProjectCount(), projects: getProjectList() });
952
+ var _userId = ws._clayUser ? ws._clayUser.id : null;
953
+ var _filteredProjects = getProjectList(_userId);
954
+ sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, dangerouslySkipPermissions: dangerouslySkipPermissions, lanHost: lanHost, projectCount: _filteredProjects.length, projects: _filteredProjects });
861
955
  if (latestVersion) {
862
956
  sendTo(ws, { type: "update_available", version: latestVersion });
863
957
  }
@@ -879,6 +973,17 @@ function createProjectContext(opts) {
879
973
  fs.accessSync(path.join(cwd, ".claude", "JUDGE.md"));
880
974
  hasLoopFiles = true;
881
975
  } catch (e) {}
976
+ // Also check loop directory files
977
+ if (!hasLoopFiles && loopState.loopId) {
978
+ var _avDir = loopDir();
979
+ if (_avDir) {
980
+ try {
981
+ fs.accessSync(path.join(_avDir, "PROMPT.md"));
982
+ fs.accessSync(path.join(_avDir, "JUDGE.md"));
983
+ hasLoopFiles = true;
984
+ } catch (e) {}
985
+ }
986
+ }
882
987
  sendTo(ws, {
883
988
  type: "loop_available",
884
989
  available: hasLoopFiles,
@@ -911,10 +1016,16 @@ function createProjectContext(opts) {
911
1016
  });
912
1017
  }
913
1018
 
914
- // Session list
1019
+ // Session list (filtered for multi-user)
1020
+ var allSessions = [].concat(Array.from(sm.sessions.values())).filter(function (s) { return !s.hidden; });
1021
+ if (usersModule.isMultiUser() && wsUser) {
1022
+ allSessions = allSessions.filter(function (s) {
1023
+ return usersModule.canAccessSession(wsUser.id, s, { visibility: "public" });
1024
+ });
1025
+ }
915
1026
  sendTo(ws, {
916
1027
  type: "session_list",
917
- sessions: [].concat(Array.from(sm.sessions.values())).filter(function (s) { return !s.hidden; }).map(function (s) {
1028
+ sessions: allSessions.map(function (s) {
918
1029
  var loop = s.loop ? Object.assign({}, s.loop) : null;
919
1030
  if (loop && loop.loopId && loopRegistry) {
920
1031
  var rec = loopRegistry.getById(loop.loopId);
@@ -931,13 +1042,21 @@ function createProjectContext(opts) {
931
1042
  isProcessing: s.isProcessing,
932
1043
  lastActivity: s.lastActivity || s.createdAt || 0,
933
1044
  loop: loop,
1045
+ ownerId: s.ownerId || null,
1046
+ sessionVisibility: s.sessionVisibility || "shared",
934
1047
  };
935
1048
  }),
936
1049
  });
937
1050
 
938
- // Restore active session for this client
1051
+ // Restore active session for this client (check access in multi-user mode)
939
1052
  var active = sm.getActiveSession();
1053
+ if (active && usersModule.isMultiUser() && wsUser) {
1054
+ if (!usersModule.canAccessSession(wsUser.id, active, { visibility: "public" })) {
1055
+ active = null;
1056
+ }
1057
+ }
940
1058
  if (active) {
1059
+ ws._clayActiveSession = active.localId;
941
1060
  sendTo(ws, { type: "session_switched", id: active.localId, cliSessionId: active.cliSessionId || null, loop: active.loop || null });
942
1061
 
943
1062
  var total = active.history.length;
@@ -968,6 +1087,8 @@ function createProjectContext(opts) {
968
1087
  }
969
1088
  }
970
1089
 
1090
+ broadcastPresence();
1091
+
971
1092
  ws.on("message", function (raw) {
972
1093
  var msg;
973
1094
  try { msg = JSON.parse(raw.toString()); } catch (e) { return; }
@@ -1002,7 +1123,21 @@ function createProjectContext(opts) {
1002
1123
  }
1003
1124
 
1004
1125
  if (msg.type === "new_session") {
1005
- sm.createSession();
1126
+ var sessionOpts = {};
1127
+ if (ws._clayUser) sessionOpts.ownerId = ws._clayUser.id;
1128
+ if (msg.sessionVisibility) sessionOpts.sessionVisibility = msg.sessionVisibility;
1129
+ var newSess = sm.createSession(sessionOpts, usersModule.isMultiUser() ? ws : undefined);
1130
+ if (usersModule.isMultiUser()) {
1131
+ ws._clayActiveSession = newSess.localId;
1132
+ broadcastPresence();
1133
+ }
1134
+ return;
1135
+ }
1136
+
1137
+ if (msg.type === "set_session_visibility") {
1138
+ if (typeof msg.sessionId === "number" && (msg.visibility === "shared" || msg.visibility === "private")) {
1139
+ sm.setSessionVisibility(msg.sessionId, msg.visibility);
1140
+ }
1006
1141
  return;
1007
1142
  }
1008
1143
 
@@ -1017,9 +1152,11 @@ function createProjectContext(opts) {
1017
1152
  break;
1018
1153
  }
1019
1154
  }
1020
- sm.resumeSession(msg.cliSessionId, { history: history, title: title });
1155
+ var _rws = usersModule.isMultiUser() ? ws : undefined;
1156
+ sm.resumeSession(msg.cliSessionId, { history: history, title: title }, _rws);
1021
1157
  }).catch(function () {
1022
- sm.resumeSession(msg.cliSessionId);
1158
+ var _rws = usersModule.isMultiUser() ? ws : undefined;
1159
+ sm.resumeSession(msg.cliSessionId, undefined, _rws);
1023
1160
  });
1024
1161
  return;
1025
1162
  }
@@ -1056,14 +1193,23 @@ function createProjectContext(opts) {
1056
1193
 
1057
1194
  if (msg.type === "switch_session") {
1058
1195
  if (msg.id && sm.sessions.has(msg.id)) {
1059
- sm.switchSession(msg.id);
1196
+ // Check access in multi-user mode
1197
+ if (usersModule.isMultiUser() && ws._clayUser) {
1198
+ var switchTarget = sm.sessions.get(msg.id);
1199
+ if (!usersModule.canAccessSession(ws._clayUser.id, switchTarget, { visibility: "public" })) return;
1200
+ ws._clayActiveSession = msg.id;
1201
+ sm.switchSession(msg.id, ws);
1202
+ broadcastPresence();
1203
+ } else {
1204
+ sm.switchSession(msg.id);
1205
+ }
1060
1206
  }
1061
1207
  return;
1062
1208
  }
1063
1209
 
1064
1210
  if (msg.type === "delete_session") {
1065
1211
  if (msg.id && sm.sessions.has(msg.id)) {
1066
- sm.deleteSession(msg.id);
1212
+ sm.deleteSession(msg.id, usersModule.isMultiUser() ? ws : undefined);
1067
1213
  }
1068
1214
  return;
1069
1215
  }
@@ -1085,6 +1231,7 @@ function createProjectContext(opts) {
1085
1231
  }
1086
1232
 
1087
1233
  if (msg.type === "check_update") {
1234
+ if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return;
1088
1235
  fetchLatestVersion().then(function (v) {
1089
1236
  if (v && isNewer(v, currentVersion)) {
1090
1237
  latestVersion = v;
@@ -1095,6 +1242,7 @@ function createProjectContext(opts) {
1095
1242
  }
1096
1243
 
1097
1244
  if (msg.type === "update_now") {
1245
+ if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return;
1098
1246
  send({ type: "update_started", version: latestVersion || "" });
1099
1247
  var _ipc = require("./ipc");
1100
1248
  var _config = require("./config");
@@ -1353,7 +1501,7 @@ function createProjectContext(opts) {
1353
1501
  onProcessingChanged();
1354
1502
 
1355
1503
  sm.saveSessionFile(session);
1356
- sm.switchSession(session.localId);
1504
+ sm.switchSession(session.localId, usersModule.isMultiUser() ? ws : undefined);
1357
1505
  sm.sendAndRecord(session, { type: "rewind_complete", mode: mode });
1358
1506
  sm.broadcastSessionList();
1359
1507
  } catch (err) {
@@ -1672,7 +1820,18 @@ function createProjectContext(opts) {
1672
1820
  return;
1673
1821
  }
1674
1822
 
1675
- // --- Daemon config (server settings) ---
1823
+ // --- Daemon config / server management (admin-only in multi-user mode) ---
1824
+ if (msg.type === "get_daemon_config" || msg.type === "set_pin" || msg.type === "set_keep_awake" ||
1825
+ msg.type === "shutdown_server" || msg.type === "restart_server") {
1826
+ if (usersModule.isMultiUser()) {
1827
+ var _wsUser = ws._clayUser;
1828
+ if (!_wsUser || _wsUser.role !== "admin") {
1829
+ sendTo(ws, { type: "error", message: "Admin access required" });
1830
+ return;
1831
+ }
1832
+ }
1833
+ }
1834
+
1676
1835
  if (msg.type === "get_daemon_config") {
1677
1836
  if (typeof opts.onGetDaemonConfig === "function") {
1678
1837
  var daemonConfig = opts.onGetDaemonConfig();
@@ -1815,6 +1974,11 @@ function createProjectContext(opts) {
1815
1974
 
1816
1975
  if (msg.type === "set_project_env") {
1817
1976
  if (typeof opts.onSetProjectEnv === "function") {
1977
+ var envError = validateEnvString(msg.envrc || "");
1978
+ if (envError) {
1979
+ sendTo(ws, { type: "set_project_env_result", ok: false, slug: msg.slug, error: envError });
1980
+ return;
1981
+ }
1818
1982
  var setResult = opts.onSetProjectEnv(msg.slug, msg.envrc || "");
1819
1983
  sendTo(ws, { type: "set_project_env_result", ok: setResult.ok, slug: msg.slug, error: setResult.error });
1820
1984
  } else {
@@ -1865,6 +2029,11 @@ function createProjectContext(opts) {
1865
2029
 
1866
2030
  if (msg.type === "set_shared_env") {
1867
2031
  if (typeof opts.onSetSharedEnv === "function") {
2032
+ var sharedEnvError = validateEnvString(msg.envrc || "");
2033
+ if (sharedEnvError) {
2034
+ sendTo(ws, { type: "set_shared_env_result", ok: false, error: sharedEnvError });
2035
+ return;
2036
+ }
1868
2037
  var sharedSetResult = opts.onSetSharedEnv(msg.envrc || "");
1869
2038
  sendTo(ws, { type: "set_shared_env_result", ok: sharedSetResult.ok, error: sharedSetResult.error });
1870
2039
  } else {
@@ -2475,6 +2644,33 @@ function createProjectContext(opts) {
2475
2644
  sm.broadcastSessionList();
2476
2645
  }
2477
2646
 
2647
+ // --- Session presence (who is viewing which session) ---
2648
+ function broadcastPresence() {
2649
+ if (!usersModule.isMultiUser()) return;
2650
+ var presence = {};
2651
+ for (var c of clients) {
2652
+ if (!c._clayUser || !c._clayActiveSession) continue;
2653
+ var sid = c._clayActiveSession;
2654
+ if (!presence[sid]) presence[sid] = [];
2655
+ var u = c._clayUser;
2656
+ var p = u.profile || {};
2657
+ // Deduplicate: skip if this user is already listed for this session
2658
+ var dominated = false;
2659
+ for (var di = 0; di < presence[sid].length; di++) {
2660
+ if (presence[sid][di].id === u.id) { dominated = true; break; }
2661
+ }
2662
+ if (dominated) continue;
2663
+ presence[sid].push({
2664
+ id: u.id,
2665
+ displayName: p.name || u.displayName || u.username,
2666
+ username: u.username,
2667
+ avatarStyle: p.avatarStyle || "thumbs",
2668
+ avatarSeed: p.avatarSeed || u.username,
2669
+ });
2670
+ }
2671
+ send({ type: "session_presence", presence: presence });
2672
+ }
2673
+
2478
2674
  // --- WS disconnection handler ---
2479
2675
  function handleDisconnection(ws) {
2480
2676
  tm.detachAll(ws);
@@ -2484,6 +2680,7 @@ function createProjectContext(opts) {
2484
2680
  stopAllDirWatches();
2485
2681
  }
2486
2682
  broadcastClientCount();
2683
+ broadcastPresence();
2487
2684
  }
2488
2685
 
2489
2686
  // --- Handle project-scoped HTTP requests ---
@@ -2635,6 +2832,18 @@ function createProjectContext(opts) {
2635
2832
  res.end('{"error":"missing url, skill, or scope"}');
2636
2833
  return;
2637
2834
  }
2835
+ // Validate skill name: alphanumeric, hyphens, underscores only
2836
+ if (!/^[a-zA-Z0-9_-]+$/.test(skill)) {
2837
+ res.writeHead(400, { "Content-Type": "application/json" });
2838
+ res.end('{"error":"invalid skill name"}');
2839
+ return;
2840
+ }
2841
+ // Validate URL: must be https://
2842
+ if (!/^https:\/\//i.test(url)) {
2843
+ res.writeHead(400, { "Content-Type": "application/json" });
2844
+ res.end('{"error":"only https:// URLs are allowed"}');
2845
+ return;
2846
+ }
2638
2847
  var spawnCwd = scope === "global" ? os.homedir() : cwd;
2639
2848
  var child = spawn("npx", ["skills", "add", url, "--skill", skill], {
2640
2849
  cwd: spawnCwd,
@@ -2679,6 +2888,12 @@ function createProjectContext(opts) {
2679
2888
  res.end('{"error":"missing skill or scope"}');
2680
2889
  return;
2681
2890
  }
2891
+ // Validate skill name
2892
+ if (!/^[a-zA-Z0-9_-]+$/.test(skill)) {
2893
+ res.writeHead(400, { "Content-Type": "application/json" });
2894
+ res.end('{"error":"invalid skill name"}');
2895
+ return;
2896
+ }
2682
2897
  var baseDir = scope === "global" ? os.homedir() : cwd;
2683
2898
  var skillDir = path.join(baseDir, ".claude", "skills", skill);
2684
2899
  // Safety: ensure skillDir is inside the expected .claude/skills directory
@@ -2823,7 +3038,7 @@ function createProjectContext(opts) {
2823
3038
  sm.sessions.forEach(function (s) {
2824
3039
  if (s.isProcessing) hasProcessing = true;
2825
3040
  });
2826
- return {
3041
+ var status = {
2827
3042
  slug: slug,
2828
3043
  path: cwd,
2829
3044
  project: project,
@@ -2833,6 +3048,26 @@ function createProjectContext(opts) {
2833
3048
  sessions: sessionCount,
2834
3049
  isProcessing: hasProcessing,
2835
3050
  };
3051
+ if (usersModule.isMultiUser()) {
3052
+ var seen = {};
3053
+ var onlineUsers = [];
3054
+ for (var c of clients) {
3055
+ if (!c._clayUser) continue;
3056
+ var u = c._clayUser;
3057
+ if (seen[u.id]) continue;
3058
+ seen[u.id] = true;
3059
+ var p = u.profile || {};
3060
+ onlineUsers.push({
3061
+ id: u.id,
3062
+ displayName: p.name || u.displayName || u.username,
3063
+ username: u.username,
3064
+ avatarStyle: p.avatarStyle || "thumbs",
3065
+ avatarSeed: p.avatarSeed || u.username,
3066
+ });
3067
+ }
3068
+ status.onlineUsers = onlineUsers;
3069
+ }
3070
+ return status;
2836
3071
  }
2837
3072
 
2838
3073
  function setTitle(newTitle) {
@@ -2853,6 +3088,11 @@ function createProjectContext(opts) {
2853
3088
  sdk: sdk,
2854
3089
  send: send,
2855
3090
  sendTo: sendTo,
3091
+ forEachClient: function (fn) {
3092
+ for (var ws of clients) {
3093
+ if (ws.readyState === 1) fn(ws);
3094
+ }
3095
+ },
2856
3096
  handleConnection: handleConnection,
2857
3097
  handleMessage: handleMessage,
2858
3098
  handleDisconnection: handleDisconnection,
@@ -2863,6 +3103,17 @@ function createProjectContext(opts) {
2863
3103
  removeSchedule: function (id) { return loopRegistry.remove(id); },
2864
3104
  setTitle: setTitle,
2865
3105
  setIcon: setIcon,
3106
+ refreshUserProfile: function (userId) {
3107
+ var user = usersModule.findUserById(userId);
3108
+ if (!user) return;
3109
+ for (var ws of clients) {
3110
+ if (ws._clayUser && ws._clayUser.id === userId) {
3111
+ ws._clayUser = user;
3112
+ }
3113
+ }
3114
+ broadcastClientCount();
3115
+ broadcastPresence();
3116
+ },
2866
3117
  warmup: function () {
2867
3118
  sdk.warmup();
2868
3119
  // Auto-install clay-ralph skill globally if not present
@@ -2904,4 +3155,4 @@ function parseJsonBody(req) {
2904
3155
  });
2905
3156
  }
2906
3157
 
2907
- module.exports = { createProjectContext: createProjectContext };
3158
+ module.exports = { createProjectContext: createProjectContext, safePath: safePath, validateEnvString: validateEnvString };
package/lib/public/app.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { showToast, copyToClipboard, escapeHtml } from './modules/utils.js';
2
2
  import { refreshIcons, iconHtml, randomThinkingVerb } from './modules/icons.js';
3
- import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks, closeMermaidModal } from './modules/markdown.js';
4
- import { initSidebar, renderSessionList, handleSearchResults, updatePageTitle, getActiveSearchQuery, buildSearchTimeline, removeSearchTimeline, populateCliSessionList, renderIconStrip, initIconStrip, getEmojiCategories } from './modules/sidebar.js';
3
+ import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks, closeMermaidModal, parseEmojis } from './modules/markdown.js';
4
+ import { initSidebar, renderSessionList, handleSearchResults, updateSessionPresence, updatePageTitle, getActiveSearchQuery, buildSearchTimeline, removeSearchTimeline, populateCliSessionList, renderIconStrip, renderSidebarPresence, initIconStrip, getEmojiCategories } from './modules/sidebar.js';
5
5
  import { initRewind, setRewindMode, showRewindModal, clearPendingRewindUuid, addRewindButton } from './modules/rewind.js';
6
6
  import { initNotifications, showDoneNotification, playDoneSound, isNotifAlertEnabled, isNotifSoundEnabled } from './modules/notifications.js';
7
7
  import { initInput, clearPendingImages, handleInputSync, autoResize, builtinCommands, sendMessage } from './modules/input.js';
@@ -19,6 +19,7 @@ import { initAsciiLogo, startLogoAnimation, stopLogoAnimation } from './modules/
19
19
  import { initPlaybook, openPlaybook, getPlaybooks, getPlaybookForTip, isCompleted as isPlaybookCompleted } from './modules/playbook.js';
20
20
  import { initSTT } from './modules/stt.js';
21
21
  import { initProfile } from './modules/profile.js';
22
+ import { initAdmin, checkAdminAccess } from './modules/admin.js';
22
23
 
23
24
  // --- Base path for multi-project routing ---
24
25
  var slugMatch = location.pathname.match(/^\/p\/([a-z0-9_-]+)/);
@@ -598,12 +599,37 @@ import { initProfile } from './modules/profile.js';
598
599
  } else if (projectHint) {
599
600
  projectHint.classList.add("hidden");
600
601
  }
602
+ // Update topbar with server-wide presence
603
+ if (msg.serverUsers) {
604
+ renderTopbarPresence(msg.serverUsers);
605
+ }
606
+ }
607
+
608
+ function renderTopbarPresence(serverUsers) {
609
+ var countEl = document.getElementById("client-count");
610
+ if (!countEl) return;
611
+ if (serverUsers.length > 1) {
612
+ countEl.innerHTML = "";
613
+ for (var cui = 0; cui < serverUsers.length; cui++) {
614
+ var cu = serverUsers[cui];
615
+ var cuImg = document.createElement("img");
616
+ cuImg.className = "client-avatar";
617
+ cuImg.src = "https://api.dicebear.com/9.x/" + (cu.avatarStyle || "thumbs") + "/svg?seed=" + encodeURIComponent(cu.avatarSeed || cu.username) + "&size=24";
618
+ cuImg.alt = cu.displayName;
619
+ cuImg.dataset.tip = cu.displayName + " (@" + cu.username + ")";
620
+ if (cui > 0) cuImg.style.marginLeft = "-6px";
621
+ countEl.appendChild(cuImg);
622
+ }
623
+ countEl.classList.remove("hidden");
624
+ } else {
625
+ countEl.classList.add("hidden");
626
+ }
601
627
  }
602
628
 
603
629
  function renderProjectList() {
604
630
  // Render icon strip projects
605
631
  var iconStripProjects = cachedProjects.map(function (p) {
606
- return { slug: p.slug, name: p.title || p.project, icon: p.icon || null, isProcessing: p.isProcessing };
632
+ return { slug: p.slug, name: p.title || p.project, icon: p.icon || null, isProcessing: p.isProcessing, onlineUsers: p.onlineUsers || [] };
607
633
  });
608
634
  renderIconStrip(iconStripProjects, currentSlug);
609
635
  // Update title bar project name and icon if it changed
@@ -617,7 +643,7 @@ import { initProfile } from './modules/profile.js';
617
643
  var pIcon = cachedProjects[pi].icon || null;
618
644
  if (pIcon) {
619
645
  tbIcon.textContent = pIcon;
620
-
646
+ parseEmojis(tbIcon);
621
647
  tbIcon.classList.add("has-icon");
622
648
  try { localStorage.setItem("clay-project-icon-" + (currentSlug || "default"), pIcon); } catch (e) {}
623
649
  } else {
@@ -736,7 +762,7 @@ import { initProfile } from './modules/profile.js';
736
762
  var _tbi = $("title-bar-project-icon");
737
763
  if (_tbi) {
738
764
  _tbi.textContent = _cachedProjectIcon;
739
-
765
+ parseEmojis(_tbi);
740
766
  _tbi.classList.add("has-icon");
741
767
  }
742
768
  }
@@ -936,6 +962,8 @@ import { initProfile } from './modules/profile.js';
936
962
  showHomeHub: function () { showHomeHub(); },
937
963
  openRalphWizard: function () { openRalphWizard(); },
938
964
  getUpcomingSchedules: getUpcomingSchedules,
965
+ get multiUser() { return isMultiUserMode; },
966
+ get myUserId() { return myUserId; },
939
967
  };
940
968
  initSidebar(sidebarCtx);
941
969
  initIconStrip(sidebarCtx);
@@ -2615,14 +2643,20 @@ import { initProfile } from './modules/profile.js';
2615
2643
  break;
2616
2644
 
2617
2645
  case "client_count":
2618
- var countEl = document.getElementById("client-count");
2619
- if (countEl) {
2620
- if (msg.count > 1) {
2621
- countEl.textContent = msg.count;
2622
- countEl.dataset.tip = msg.count + " devices connected";
2623
- countEl.classList.remove("hidden");
2624
- } else {
2625
- countEl.classList.add("hidden");
2646
+ // Sidebar presence: current project's online users
2647
+ if (msg.users) {
2648
+ renderSidebarPresence(msg.users);
2649
+ }
2650
+ // Non-multi-user mode: simple count in topbar
2651
+ if (!msg.users) {
2652
+ var countEl = document.getElementById("client-count");
2653
+ if (countEl) {
2654
+ if (msg.count > 1) {
2655
+ countEl.textContent = msg.count;
2656
+ countEl.classList.remove("hidden");
2657
+ } else {
2658
+ countEl.classList.add("hidden");
2659
+ }
2626
2660
  }
2627
2661
  }
2628
2662
  break;
@@ -2693,6 +2727,10 @@ import { initProfile } from './modules/profile.js';
2693
2727
  renderSessionList(msg.sessions || []);
2694
2728
  break;
2695
2729
 
2730
+ case "session_presence":
2731
+ updateSessionPresence(msg.presence || {});
2732
+ break;
2733
+
2696
2734
  case "session_io":
2697
2735
  blinkSessionDot(msg.id);
2698
2736
  break;
@@ -3453,6 +3491,26 @@ import { initProfile } from './modules/profile.js';
3453
3491
  basePath: basePath,
3454
3492
  });
3455
3493
 
3494
+ // --- Admin (multi-user mode) ---
3495
+ var isMultiUserMode = false;
3496
+ var myUserId = null;
3497
+ initAdmin({
3498
+ get projectList() { return cachedProjects; },
3499
+ });
3500
+ fetch("/api/me").then(function (r) { return r.json(); }).then(function (d) {
3501
+ if (d.multiUser) isMultiUserMode = true;
3502
+ if (d.user && d.user.id) myUserId = d.user.id;
3503
+ }).catch(function () {});
3504
+ // Hide server settings and update controls for non-admin users in multi-user mode
3505
+ checkAdminAccess().then(function (isAdmin) {
3506
+ if (isMultiUserMode && !isAdmin) {
3507
+ var settingsBtn = document.getElementById("server-settings-btn");
3508
+ if (settingsBtn) settingsBtn.style.display = "none";
3509
+ var updatePill = document.getElementById("update-pill-wrap");
3510
+ if (updatePill) updatePill.style.display = "none";
3511
+ }
3512
+ });
3513
+
3456
3514
  // --- Notifications module (viewport, banners, notifications, debug, service worker) ---
3457
3515
  initNotifications({
3458
3516
  $: $,
@@ -3725,12 +3783,14 @@ import { initProfile } from './modules/profile.js';
3725
3783
 
3726
3784
  function updateRalphBars() {
3727
3785
  var onCraftingSession = ralphCraftingSessionId && activeSessionId === ralphCraftingSessionId;
3786
+ // If approval phase but no craftingSessionId (recovered after server restart), show bar anyway
3787
+ var recoveredApproval = ralphPhase === "approval" && !ralphCraftingSessionId;
3728
3788
  if (ralphPhase === "crafting" && onCraftingSession) {
3729
3789
  showRalphCraftingBar(true);
3730
3790
  } else {
3731
3791
  showRalphCraftingBar(false);
3732
3792
  }
3733
- if (ralphPhase === "approval" && onCraftingSession) {
3793
+ if (ralphPhase === "approval" && (onCraftingSession || recoveredApproval)) {
3734
3794
  showRalphApprovalBar(true);
3735
3795
  } else {
3736
3796
  showRalphApprovalBar(false);