clay-server 2.11.0-beta.8 → 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.
@@ -15,6 +15,106 @@ var resizeObserver = null;
15
15
  var toolbarBound = false;
16
16
  var termCtxMenu = null;
17
17
 
18
+ // --- Multi-line link provider ---
19
+ // xterm's WebLinksAddon only detects URLs on a single line.
20
+ // This provider reconstructs "logical lines" from wrapped buffer lines
21
+ // and detects URLs that span multiple rows.
22
+ function createMultiLineLinkProvider(xterm) {
23
+ var URL_RE = /https?:\/\/[^\s'"\]>)}{]+/g;
24
+
25
+ function getLogicalLine(buffer, y) {
26
+ // Walk backward to find the start of the logical line
27
+ var startY = y;
28
+ while (startY > 0) {
29
+ var line = buffer.getLine(startY);
30
+ if (!line || !line.isWrapped) break;
31
+ startY--;
32
+ }
33
+
34
+ // Walk forward to collect all wrapped continuation lines
35
+ var endY = startY;
36
+ var cols = xterm.cols;
37
+ while (endY < buffer.length - 1) {
38
+ var next = buffer.getLine(endY + 1);
39
+ if (!next || !next.isWrapped) break;
40
+ endY++;
41
+ }
42
+
43
+ // Build the full logical line text and track row boundaries
44
+ var text = "";
45
+ var rowOffsets = []; // { y, startOffset, length }
46
+ for (var row = startY; row <= endY; row++) {
47
+ var line = buffer.getLine(row);
48
+ if (!line) break;
49
+ var trimRight = (row === endY); // only trim trailing spaces on last row
50
+ var rowText = line.translateToString(trimRight);
51
+ rowOffsets.push({ y: row, startOffset: text.length, length: rowText.length });
52
+ text += rowText;
53
+ }
54
+
55
+ return { text: text, startY: startY, endY: endY, rowOffsets: rowOffsets };
56
+ }
57
+
58
+ function offsetToBufferPos(rowOffsets, offset) {
59
+ for (var i = 0; i < rowOffsets.length; i++) {
60
+ var ro = rowOffsets[i];
61
+ if (offset < ro.startOffset + ro.length) {
62
+ return { x: offset - ro.startOffset + 1, y: ro.y + 1 }; // 1-based
63
+ }
64
+ }
65
+ // Past end, clamp to last row
66
+ var last = rowOffsets[rowOffsets.length - 1];
67
+ return { x: last.length + 1, y: last.y + 1 };
68
+ }
69
+
70
+ return {
71
+ provideLinks: function (y, callback) {
72
+ var buffer = xterm.buffer.active;
73
+ // y is 1-based in provideLinks
74
+ var bufferY = y - 1;
75
+ var logical = getLogicalLine(buffer, bufferY);
76
+
77
+ // Only process if this logical line spans multiple rows
78
+ if (logical.startY === logical.endY) {
79
+ callback(undefined);
80
+ return;
81
+ }
82
+
83
+ // Only trigger on the first row of the logical line to avoid duplicates
84
+ if (bufferY !== logical.startY) {
85
+ callback(undefined);
86
+ return;
87
+ }
88
+
89
+ var links = [];
90
+ var match;
91
+ URL_RE.lastIndex = 0;
92
+ while ((match = URL_RE.exec(logical.text)) !== null) {
93
+ var urlStart = match.index;
94
+ var urlEnd = match.index + match[0].length - 1;
95
+
96
+ var startPos = offsetToBufferPos(logical.rowOffsets, urlStart);
97
+ var endPos = offsetToBufferPos(logical.rowOffsets, urlEnd);
98
+
99
+ // Only include if the URL actually spans multiple rows
100
+ if (startPos.y !== endPos.y) {
101
+ (function (url) {
102
+ links.push({
103
+ range: { start: startPos, end: endPos },
104
+ text: url,
105
+ activate: function () {
106
+ window.open(url, "_blank", "noopener");
107
+ },
108
+ });
109
+ })(match[0]);
110
+ }
111
+ }
112
+
113
+ callback(links.length > 0 ? links : undefined);
114
+ },
115
+ };
116
+ }
117
+
18
118
  // --- Init ---
19
119
  export function initTerminal(_ctx) {
20
120
  ctx = _ctx;
@@ -184,6 +284,8 @@ function activateTab(termId) {
184
284
  // Fit and focus
185
285
  setupListeners();
186
286
  fitTerminal();
287
+ // Re-fit after layout settles (flex may not have computed final size yet)
288
+ setTimeout(fitTerminal, 50);
187
289
 
188
290
  if (tab.xterm) {
189
291
  tab.xterm.focus();
@@ -225,6 +327,14 @@ function createXtermForTab(tab) {
225
327
  xterm.loadAddon(fitAddon);
226
328
  }
227
329
 
330
+ // Web links addon: make URLs clickable (single-line)
331
+ if (typeof WebLinksAddon !== "undefined") {
332
+ xterm.loadAddon(new WebLinksAddon.WebLinksAddon());
333
+ }
334
+
335
+ // Custom multi-line link provider: detect URLs that wrap across lines
336
+ xterm.registerLinkProvider(createMultiLineLinkProvider(xterm));
337
+
228
338
  // Create a container div for this tab's terminal
229
339
  var bodyEl = document.createElement("div");
230
340
  bodyEl.className = "terminal-tab-body";
@@ -239,20 +349,26 @@ function createXtermForTab(tab) {
239
349
  }
240
350
  });
241
351
 
242
- // Cmd/Ctrl+C copy: use clipboard API when text is selected
243
- bodyEl.addEventListener("keydown", function (e) {
352
+ // Cmd/Ctrl+C copy and Cmd/Ctrl+V paste: intercept before xterm swallows the event
353
+ xterm.attachCustomKeyEventHandler(function (e) {
354
+ if (e.type !== "keydown") return true;
355
+ // Cmd/Ctrl+C: copy selection if any, otherwise send SIGINT
244
356
  if ((e.ctrlKey || e.metaKey) && e.key === "c") {
245
- var sel = tab.xterm ? tab.xterm.getSelection() : "";
357
+ var sel = xterm.getSelection();
246
358
  if (sel) {
247
- e.preventDefault();
248
359
  if (navigator.clipboard && navigator.clipboard.writeText) {
249
360
  navigator.clipboard.writeText(sel).catch(function () {});
250
361
  }
362
+ return false; // prevent xterm from handling
251
363
  }
252
- // If no selection, let it pass through as Ctrl+C (SIGINT)
364
+ // No selection on macOS Cmd+C: do nothing (not SIGINT)
365
+ if (e.metaKey) return false;
253
366
  }
254
- // Do NOT preventDefault for Cmd/Ctrl+V here.
255
- // Let the browser fire the paste event, which we handle below.
367
+ // Cmd/Ctrl+V: let browser handle paste event
368
+ if ((e.ctrlKey || e.metaKey) && e.key === "v") {
369
+ return false; // let browser fire paste event
370
+ }
371
+ return true;
256
372
  });
257
373
 
258
374
  // Handle paste via browser paste event (works for Cmd+V, Ctrl+V, right-click paste)
@@ -277,22 +393,28 @@ function createXtermForTab(tab) {
277
393
  }
278
394
 
279
395
  // --- Fit active terminal ---
280
- function fitTerminal() {
281
- if (!activeTabId) return;
282
- var tab = tabs.get(activeTabId);
283
- if (!tab || !tab.fitAddon || !tab.xterm) return;
396
+ var fitRafId = null;
284
397
 
285
- try {
286
- tab.fitAddon.fit();
287
- if (ctx.ws && ctx.connected) {
288
- ctx.ws.send(JSON.stringify({
289
- type: "term_resize",
290
- id: activeTabId,
291
- cols: tab.xterm.cols,
292
- rows: tab.xterm.rows,
293
- }));
294
- }
295
- } catch (e) {}
398
+ function fitTerminal() {
399
+ if (fitRafId) cancelAnimationFrame(fitRafId);
400
+ fitRafId = requestAnimationFrame(function () {
401
+ fitRafId = null;
402
+ if (!activeTabId) return;
403
+ var tab = tabs.get(activeTabId);
404
+ if (!tab || !tab.fitAddon || !tab.xterm) return;
405
+
406
+ try {
407
+ tab.fitAddon.fit();
408
+ if (ctx.ws && ctx.connected) {
409
+ ctx.ws.send(JSON.stringify({
410
+ type: "term_resize",
411
+ id: activeTabId,
412
+ cols: tab.xterm.cols,
413
+ rows: tab.xterm.rows,
414
+ }));
415
+ }
416
+ } catch (e) {}
417
+ });
296
418
  }
297
419
 
298
420
  // --- Setup/cleanup resize listeners ---
@@ -611,10 +733,24 @@ function showTermCtxMenu(e, tab) {
611
733
  var menu = document.createElement("div");
612
734
  menu.className = "term-ctx-menu";
613
735
 
614
- // Copy
736
+ // Copy selection
737
+ var sel = tab.xterm ? tab.xterm.getSelection() : "";
738
+ if (sel) {
739
+ var copySelItem = document.createElement("button");
740
+ copySelItem.className = "term-ctx-item";
741
+ copySelItem.innerHTML = iconHtml("copy") + " <span>Copy</span>";
742
+ copySelItem.addEventListener("click", function (ev) {
743
+ ev.stopPropagation();
744
+ closeTermCtxMenu();
745
+ if (sel) copyToClipboard(sel);
746
+ });
747
+ menu.appendChild(copySelItem);
748
+ }
749
+
750
+ // Copy entire console
615
751
  var copyItem = document.createElement("button");
616
752
  copyItem.className = "term-ctx-item";
617
- copyItem.innerHTML = iconHtml("clipboard-copy") + " <span>Copy Terminal</span>";
753
+ copyItem.innerHTML = iconHtml("clipboard-copy") + " <span>Copy Console</span>";
618
754
  copyItem.addEventListener("click", function (ev) {
619
755
  ev.stopPropagation();
620
756
  closeTermCtxMenu();
@@ -647,7 +783,7 @@ function showTermCtxMenu(e, tab) {
647
783
  // Clear
648
784
  var clearItem = document.createElement("button");
649
785
  clearItem.className = "term-ctx-item";
650
- clearItem.innerHTML = iconHtml("trash-2") + " <span>Clear Terminal</span>";
786
+ clearItem.innerHTML = iconHtml("trash-2") + " <span>Clear Console</span>";
651
787
  clearItem.addEventListener("click", function (ev) {
652
788
  ev.stopPropagation();
653
789
  closeTermCtxMenu();
package/lib/sdk-bridge.js CHANGED
@@ -573,6 +573,38 @@ function createSDKBridge(opts) {
573
573
 
574
574
  var WORKER_SCRIPT = path.join(__dirname, "sdk-worker.js");
575
575
 
576
+ // Ensure the package directory tree is world-readable so OS-level users
577
+ // can access the worker script and its dependencies (the install path
578
+ // may be under /root/.npm/_npx/ which defaults to 700)
579
+ (function ensurePackageReadable() {
580
+ try {
581
+ // Walk up from __dirname to find the package root (where node_modules lives)
582
+ var pkgDir = path.join(__dirname, "..");
583
+ // Open read+execute on each ancestor directory up to and including the
584
+ // npx cache entry so that non-root users can traverse the path
585
+ var dir = pkgDir;
586
+ var dirs = [];
587
+ while (dir !== path.dirname(dir)) {
588
+ dirs.push(dir);
589
+ // Stop once we leave the npm cache tree
590
+ if (dir.indexOf(".npm") === -1 && dir.indexOf("node_modules") === -1) break;
591
+ dir = path.dirname(dir);
592
+ }
593
+ for (var di = 0; di < dirs.length; di++) {
594
+ try {
595
+ var st = fs.statSync(dirs[di]);
596
+ // Add o+rx if not already present
597
+ if ((st.mode & 0o005) !== 0o005) {
598
+ fs.chmodSync(dirs[di], st.mode | 0o005);
599
+ }
600
+ } catch (e) {}
601
+ }
602
+ // Recursively make the package contents readable
603
+ var { execSync: chmodExec } = require("child_process");
604
+ chmodExec("chmod -R o+rX " + JSON.stringify(pkgDir), { stdio: "ignore", timeout: 5000 });
605
+ } catch (e) {}
606
+ })();
607
+
576
608
  // resolveLinuxUser delegates to shared os-users utility
577
609
  function resolveLinuxUser(username) {
578
610
  return resolveOsUserInfo(username);
@@ -658,12 +690,27 @@ function createSDKBridge(opts) {
658
690
  worker.process.stdout.on("data", function(data) {
659
691
  console.log("[sdk-worker:" + linuxUser + "] " + data.toString().trim());
660
692
  });
693
+ worker._stderrBuf = "";
661
694
  worker.process.stderr.on("data", function(data) {
662
- console.error("[sdk-worker:" + linuxUser + "] " + data.toString().trim());
695
+ var text = data.toString().trim();
696
+ worker._stderrBuf += text + "\n";
697
+ console.error("[sdk-worker:" + linuxUser + "] " + text);
663
698
  });
664
699
 
665
700
  worker.process.on("exit", function(code, signal) {
666
701
  console.log("[sdk-bridge] Worker for " + linuxUser + " exited (code=" + code + ", signal=" + signal + ")");
702
+ // Notify message handlers about unexpected exit so sessions don't hang
703
+ if (code !== 0 && code !== null) {
704
+ var stderrText = worker._stderrBuf || "";
705
+ for (var h = 0; h < worker.messageHandlers.length; h++) {
706
+ worker.messageHandlers[h]({
707
+ type: "query_error",
708
+ error: stderrText || "Worker exited with code " + code,
709
+ exitCode: code,
710
+ stderr: stderrText || null,
711
+ });
712
+ }
713
+ }
667
714
  cleanupWorker(worker);
668
715
  });
669
716
  });
@@ -1021,7 +1068,7 @@ function createSDKBridge(opts) {
1021
1068
  worker.kill();
1022
1069
  } else if (msg.type === "warmup_error" && !warmupDone) {
1023
1070
  warmupDone = true;
1024
- send({ type: "error", text: result.error || "Warmup failed" });
1071
+ send({ type: "error", text: msg.error || "Warmup failed" });
1025
1072
  worker.kill();
1026
1073
  }
1027
1074
  });
@@ -1152,14 +1199,13 @@ function createSDKBridge(opts) {
1152
1199
  for (var j = 0; j < candidates.length; j++) {
1153
1200
  var c = candidates[j];
1154
1201
  try {
1155
- var lsof = execSync("lsof -a -p " + c.pid + " -d cwd -F n 2>/dev/null", { encoding: "utf8", timeout: 3000 });
1156
- // lsof -F n output: lines starting with 'n' contain the path
1157
- var cwdMatch = lsof.match(/\nn(.+)/);
1158
- if (cwdMatch && cwdMatch[1] === cwd) {
1202
+ // Use /proc/<pid>/cwd symlink (always available on Linux, no lsof dependency)
1203
+ var procCwd = fs.readlinkSync("/proc/" + c.pid + "/cwd");
1204
+ if (procCwd === cwd) {
1159
1205
  results.push(c);
1160
1206
  }
1161
1207
  } catch (e) {
1162
- // lsof failed — include as candidate anyway (conservative)
1208
+ // /proc read failed — include as candidate anyway (conservative)
1163
1209
  results.push(c);
1164
1210
  }
1165
1211
  }
package/lib/server.js CHANGED
@@ -353,6 +353,7 @@ function createServer(opts) {
353
353
  var onSetPin = opts.onSetPin || null;
354
354
  var onSetKeepAwake = opts.onSetKeepAwake || null;
355
355
  var onShutdown = opts.onShutdown || null;
356
+ var onRestart = opts.onRestart || null;
356
357
  var onSetUpdateChannel = opts.onSetUpdateChannel || null;
357
358
  var onUpgradePin = opts.onUpgradePin || null;
358
359
  var onSetProjectVisibility = opts.onSetProjectVisibility || null;
@@ -360,6 +361,7 @@ function createServer(opts) {
360
361
  var onGetProjectAccess = opts.onGetProjectAccess || null;
361
362
  var onUserProvisioned = opts.onUserProvisioned || null;
362
363
  var onUserDeleted = opts.onUserDeleted || null;
364
+ var getRemovedProjects = opts.getRemovedProjects || function () { return []; };
363
365
 
364
366
  var authToken = pinHash || null;
365
367
  var realVersion = require("../package.json").version;
@@ -1118,6 +1120,48 @@ function createServer(opts) {
1118
1120
  return;
1119
1121
  }
1120
1122
 
1123
+ // Reset user PIN (admin only) — generates a new temp PIN
1124
+ if (req.method === "POST" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/reset-pin$/)) {
1125
+ if (!users.isMultiUser()) {
1126
+ res.writeHead(404, { "Content-Type": "application/json" });
1127
+ res.end('{"error":"Not found"}');
1128
+ return;
1129
+ }
1130
+ var mu = getMultiUserFromReq(req);
1131
+ if (!mu || mu.role !== "admin") {
1132
+ res.writeHead(403, { "Content-Type": "application/json" });
1133
+ res.end('{"error":"Admin access required"}');
1134
+ return;
1135
+ }
1136
+ var urlParts = fullUrl.split("/");
1137
+ var targetUserId = urlParts[4]; // /api/admin/users/{userId}/reset-pin
1138
+ var targetUser = users.findUserById(targetUserId);
1139
+ if (!targetUser) {
1140
+ res.writeHead(404, { "Content-Type": "application/json" });
1141
+ res.end('{"error":"User not found"}');
1142
+ return;
1143
+ }
1144
+ var newPin = users.generatePin();
1145
+ var pinResult = users.updateUserPin(targetUserId, newPin);
1146
+ if (pinResult.error) {
1147
+ res.writeHead(400, { "Content-Type": "application/json" });
1148
+ res.end(JSON.stringify({ error: pinResult.error }));
1149
+ return;
1150
+ }
1151
+ // Mark as must change on next login
1152
+ var data = users.loadUsers();
1153
+ for (var i = 0; i < data.users.length; i++) {
1154
+ if (data.users[i].id === targetUserId) {
1155
+ data.users[i].mustChangePin = true;
1156
+ users.saveUsers(data);
1157
+ break;
1158
+ }
1159
+ }
1160
+ res.writeHead(200, { "Content-Type": "application/json" });
1161
+ res.end(JSON.stringify({ ok: true, tempPin: newPin }));
1162
+ return;
1163
+ }
1164
+
1121
1165
  // Set Linux user mapping (admin only, OS-level multi-user)
1122
1166
  if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/linux-user$/)) {
1123
1167
  if (!users.isMultiUser()) {
@@ -1920,6 +1964,7 @@ function createServer(opts) {
1920
1964
 
1921
1965
  var ctx = projects.get(wsSlug);
1922
1966
  if (!ctx) {
1967
+ if (debug) console.log("[server] WS rejected: project not found for slug", wsSlug);
1923
1968
  socket.destroy();
1924
1969
  return;
1925
1970
  }
@@ -1931,8 +1976,10 @@ function createServer(opts) {
1931
1976
  // Check project access for multi-user mode
1932
1977
  if (wsUser && onGetProjectAccess) {
1933
1978
  var projectAccess = onGetProjectAccess(wsSlug);
1979
+ if (debug) console.log("[server] WS access check:", wsSlug, "user:", wsUser.id, "role:", wsUser.role, "visibility:", projectAccess && projectAccess.visibility, "ownerId:", projectAccess && projectAccess.ownerId, "allowed:", projectAccess && projectAccess.allowedUsers);
1934
1980
  if (projectAccess && !projectAccess.error) {
1935
1981
  if (!users.canAccessProject(wsUser.id, projectAccess)) {
1982
+ if (debug) console.log("[server] WS rejected: access denied for", wsUser.id, "on", wsSlug);
1936
1983
  socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1937
1984
  socket.destroy();
1938
1985
  return;
@@ -1966,23 +2013,51 @@ function createServer(opts) {
1966
2013
  return origEmit.apply(ws, arguments);
1967
2014
  };
1968
2015
  ws._clayUser = wsUser; // attach user context
2016
+ // Clear cross-project unread for this project when client connects
2017
+ var unreadMap = getCrossProjectUnread(ws);
2018
+ if (unreadMap[wsSlug]) {
2019
+ unreadMap[wsSlug] = 0;
2020
+ }
1969
2021
  ctx.handleConnection(ws, wsUser);
1970
2022
  });
1971
2023
  });
1972
2024
 
2025
+ // --- Cross-project unread tracking ---
2026
+ // WeakMap<ws, { slug: count }> tracks how many done events happened in other projects
2027
+ var crossProjectUnread = new WeakMap();
2028
+
2029
+ function getCrossProjectUnread(ws) {
2030
+ var map = crossProjectUnread.get(ws);
2031
+ if (!map) { map = {}; crossProjectUnread.set(ws, map); }
2032
+ return map;
2033
+ }
2034
+
2035
+ function onSessionDone(sourceSlug) {
2036
+ // Increment unread for all clients NOT connected to sourceSlug
2037
+ projects.forEach(function (ctx, projSlug) {
2038
+ if (projSlug === sourceSlug) return;
2039
+ ctx.forEachClient(function (ws) {
2040
+ var map = getCrossProjectUnread(ws);
2041
+ map[sourceSlug] = (map[sourceSlug] || 0) + 1;
2042
+ });
2043
+ });
2044
+ // Trigger a projects_updated broadcast so clients get updated unread counts
2045
+ broadcastProcessingChange();
2046
+ }
2047
+
1973
2048
  // --- Debounced broadcast for processing status changes ---
1974
2049
  var processingUpdateTimer = null;
1975
2050
  function broadcastProcessingChange() {
1976
2051
  if (processingUpdateTimer) clearTimeout(processingUpdateTimer);
1977
2052
  processingUpdateTimer = setTimeout(function () {
1978
2053
  processingUpdateTimer = null;
1979
- if (users.isMultiUser() && onGetProjectAccess) {
1980
- // Per-client filtered project list
1981
- var allProjectsList = getProjects();
1982
- projects.forEach(function (ctx) {
1983
- ctx.forEachClient(function (ws) {
2054
+ var allProjectsList = getProjects();
2055
+ // Always send per-client to include cross-project unread counts
2056
+ projects.forEach(function (ctx, projSlug) {
2057
+ ctx.forEachClient(function (ws) {
2058
+ var filtered = allProjectsList;
2059
+ if (users.isMultiUser() && onGetProjectAccess) {
1984
2060
  var wsUser = ws._clayUser;
1985
- var filtered = allProjectsList;
1986
2061
  if (wsUser) {
1987
2062
  filtered = allProjectsList.filter(function (p) {
1988
2063
  var access = onGetProjectAccess(p.slug);
@@ -1990,20 +2065,31 @@ function createServer(opts) {
1990
2065
  return users.canAccessProject(wsUser.id, access);
1991
2066
  });
1992
2067
  }
2068
+ }
2069
+ // Attach per-project unread counts for this client
2070
+ var unreadMap = getCrossProjectUnread(ws);
2071
+ var projectsWithUnread = filtered.map(function (p) {
2072
+ var copy = {};
2073
+ var keys = Object.keys(p);
2074
+ for (var i = 0; i < keys.length; i++) copy[keys[i]] = p[keys[i]];
2075
+ // For the current project, use session-level unread total
2076
+ if (p.slug === projSlug) {
2077
+ copy.unread = ctx.sm.getTotalUnread(ws);
2078
+ } else {
2079
+ copy.unread = unreadMap[p.slug] || 0;
2080
+ }
2081
+ return copy;
2082
+ });
2083
+ if (ws.readyState === 1) {
1993
2084
  ws.send(JSON.stringify({
1994
2085
  type: "projects_updated",
1995
- projects: filtered,
1996
- projectCount: filtered.length,
2086
+ projects: projectsWithUnread,
2087
+ projectCount: projectsWithUnread.length,
2088
+ removedProjects: getRemovedProjects(ws._clayUser ? ws._clayUser.id : null),
1997
2089
  }));
1998
- });
1999
- });
2000
- } else {
2001
- broadcastAll({
2002
- type: "projects_updated",
2003
- projects: getProjects(),
2004
- projectCount: projects.size,
2090
+ }
2005
2091
  });
2006
- }
2092
+ });
2007
2093
  }, 200);
2008
2094
  }
2009
2095
 
@@ -2099,6 +2185,7 @@ function createServer(opts) {
2099
2185
  },
2100
2186
  onPresenceChange: broadcastPresenceChange,
2101
2187
  onProcessingChanged: broadcastProcessingChange,
2188
+ onSessionDone: function () { onSessionDone(slug); },
2102
2189
  onAddProject: onAddProject,
2103
2190
  onCreateProject: onCreateProject,
2104
2191
  onCloneProject: onCloneProject,
@@ -2125,6 +2212,7 @@ function createServer(opts) {
2125
2212
  onSetUpdateChannel: onSetUpdateChannel,
2126
2213
  updateChannel: onGetDaemonConfig ? (onGetDaemonConfig().updateChannel || "stable") : "stable",
2127
2214
  onShutdown: onShutdown,
2215
+ onRestart: onRestart,
2128
2216
  onDmMessage: handleDmMessage,
2129
2217
  });
2130
2218
  projects.set(slug, ctx);
@@ -2217,6 +2305,41 @@ function createServer(opts) {
2217
2305
  });
2218
2306
  return;
2219
2307
  }
2308
+
2309
+ if (msg.type === "dm_add_favorite") {
2310
+ if (!msg.targetUserId) return;
2311
+ users.removeDmHidden(userId, msg.targetUserId);
2312
+ var updatedFavorites = users.addDmFavorite(userId, msg.targetUserId);
2313
+ var allUsersList = users.getAllUsers().map(function (u) {
2314
+ var p = u.profile || {};
2315
+ return {
2316
+ id: u.id,
2317
+ displayName: p.name || u.displayName || u.username,
2318
+ username: u.username,
2319
+ role: u.role,
2320
+ avatarStyle: p.avatarStyle || "thumbs",
2321
+ avatarSeed: p.avatarSeed || u.username,
2322
+ avatarColor: p.avatarColor || "#7c3aed",
2323
+ };
2324
+ });
2325
+ ws.send(JSON.stringify({
2326
+ type: "dm_favorites_updated",
2327
+ dmFavorites: updatedFavorites,
2328
+ allUsers: allUsersList,
2329
+ }));
2330
+ return;
2331
+ }
2332
+
2333
+ if (msg.type === "dm_remove_favorite") {
2334
+ if (!msg.targetUserId) return;
2335
+ users.addDmHidden(userId, msg.targetUserId);
2336
+ var updatedFavorites = users.removeDmFavorite(userId, msg.targetUserId);
2337
+ ws.send(JSON.stringify({
2338
+ type: "dm_favorites_updated",
2339
+ dmFavorites: updatedFavorites,
2340
+ }));
2341
+ return;
2342
+ }
2220
2343
  }
2221
2344
 
2222
2345
  function removeProject(slug) {
@@ -2296,7 +2419,7 @@ function createServer(opts) {
2296
2419
  // Sends per-user filtered project lists + server-wide user list
2297
2420
  var presenceTimer = null;
2298
2421
  function broadcastPresenceChange() {
2299
- if (presenceTimer) return;
2422
+ if (presenceTimer) clearTimeout(presenceTimer);
2300
2423
  presenceTimer = setTimeout(function () {
2301
2424
  presenceTimer = null;
2302
2425
  if (!users.isMultiUser()) {
@@ -2304,6 +2427,7 @@ function createServer(opts) {
2304
2427
  type: "projects_updated",
2305
2428
  projects: getProjects(),
2306
2429
  projectCount: projects.size,
2430
+ removedProjects: getRemovedProjects(),
2307
2431
  });
2308
2432
  return;
2309
2433
  }
@@ -2340,12 +2464,27 @@ function createServer(opts) {
2340
2464
  }
2341
2465
  filteredProjects.push(status);
2342
2466
  });
2467
+ // Per-user DM data
2468
+ var userDmFavorites = userId ? users.getDmFavorites(userId) : [];
2469
+ var userDmHidden = userId ? users.getDmHidden(userId) : [];
2470
+ var userDmConversations = [];
2471
+ if (userId) {
2472
+ var dmList = dm.getDmList(userId);
2473
+ for (var di = 0; di < dmList.length; di++) {
2474
+ if (userDmHidden.indexOf(dmList[di].otherUserId) === -1) {
2475
+ userDmConversations.push(dmList[di].otherUserId);
2476
+ }
2477
+ }
2478
+ }
2343
2479
  var msgStr = JSON.stringify({
2344
2480
  type: "projects_updated",
2345
2481
  projects: filteredProjects,
2346
2482
  projectCount: projects.size,
2347
2483
  serverUsers: serverUsers,
2348
2484
  allUsers: allUsers,
2485
+ dmFavorites: userDmFavorites,
2486
+ dmConversations: userDmConversations,
2487
+ removedProjects: getRemovedProjects(userId),
2349
2488
  });
2350
2489
  sentUsers[key] = msgStr;
2351
2490
  ws.send(msgStr);