clay-server 2.35.1 → 2.36.0-beta.2

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.
@@ -9,10 +9,14 @@ var ctx;
9
9
  // --- State ---
10
10
  var mentionActive = false; // @ autocomplete is visible
11
11
  var mentionAtIdx = -1; // position of the @ in input
12
- var mentionFiltered = []; // filtered mate list
12
+ var mentionFiltered = []; // filtered candidate list (mates + users)
13
13
  var mentionActiveIdx = -1; // highlighted item in dropdown
14
- var selectedMateId = null; // selected mate for pending send
15
- var selectedMateName = null; // display name of selected mate
14
+ // "kind" = "mate" | "user". Both keep the same chip UX so most existing code
15
+ // branches only on the kind at send time. Variable names retain "Mate" prefix
16
+ // for backwards compatibility with consumers like input.js / app-messages.js.
17
+ var selectedMateKind = null; // "mate" | "user" | null
18
+ var selectedMateId = null; // mate id OR user id
19
+ var selectedMateName = null; // display name
16
20
  var selectedMateColor = null; // avatar color for sticky re-apply
17
21
  var selectedMateAvatar = null; // avatar src for sticky re-apply
18
22
 
@@ -53,27 +57,109 @@ export function checkForMention(value, cursorPos) {
53
57
  }
54
58
 
55
59
  // --- Autocomplete dropdown ---
56
- export function showMentionMenu(query) {
57
- // Mates UI gate: if the user has turned Mates off in User Settings,
58
- // the @-mention dropdown is the entry point for sending a message to a
59
- // mate from the regular session input, so suppress it entirely. Once
60
- // we surface user-to-user DMs from this dropdown we can scope this
61
- // check tighter, but for now the dropdown is mate-only.
62
- if (store.get('matesEnabled') === false) {
63
- hideMentionMenu();
64
- return;
60
+ // Build the unified candidate list of mates + users. Each candidate is
61
+ // normalized to { kind, id, name, color, avatarSrc, vendor, bio, primary, raw }.
62
+ // Plain @mention targets. Vendor-only entries with no persona or memory; the
63
+ // server-side handler (lib/project-mate-interaction.js) recognises these IDs
64
+ // and routes them through the matching vendor adapter directly.
65
+ var PLAIN_MENTION_CANDIDATES = [
66
+ {
67
+ kind: "mate",
68
+ id: "plain:claude",
69
+ name: "Claude Code",
70
+ color: "#cc785c",
71
+ avatarSrc: "/claude-code-avatar.png",
72
+ vendor: "claude",
73
+ bio: "Plain Claude Code, no persona, no memory",
74
+ primary: false,
75
+ plain: true,
76
+ raw: null,
77
+ },
78
+ {
79
+ kind: "mate",
80
+ id: "plain:codex",
81
+ name: "Codex",
82
+ color: "#10a37f",
83
+ avatarSrc: "/codex-avatar.png",
84
+ vendor: "codex",
85
+ bio: "Plain Codex, no persona, no memory",
86
+ primary: false,
87
+ plain: true,
88
+ raw: null,
89
+ },
90
+ ];
91
+
92
+ function buildMentionCandidates() {
93
+ var candidates = [];
94
+
95
+ // Plain vendor mentions (always available, regardless of Mates toggle).
96
+ // Hide the plain entry that matches the current session's vendor: there is
97
+ // no point asking Claude for a second opinion while you are already in a
98
+ // Claude session, and likewise for Codex.
99
+ var sessionVendor = store.get('currentVendor') || "claude";
100
+ for (var pi = 0; pi < PLAIN_MENTION_CANDIDATES.length; pi++) {
101
+ if (PLAIN_MENTION_CANDIDATES[pi].vendor === sessionVendor) continue;
102
+ candidates.push(PLAIN_MENTION_CANDIDATES[pi]);
103
+ }
104
+
105
+ // Mates: only when the Mates UI is enabled
106
+ if (store.get('matesEnabled') !== false) {
107
+ var mates = ctx.matesList ? ctx.matesList() : [];
108
+ for (var mi = 0; mi < mates.length; mi++) {
109
+ var m = mates[mi];
110
+ if (m.status === "interviewing") continue;
111
+ candidates.push({
112
+ kind: "mate",
113
+ id: m.id,
114
+ name: (m.profile && m.profile.displayName) || m.name || "Mate",
115
+ color: (m.profile && m.profile.avatarColor) || "#6c5ce7",
116
+ avatarSrc: mateAvatarUrl(m, 24),
117
+ vendor: m.vendor || "claude",
118
+ bio: m.bio || (m.profile && m.profile.bio) || "",
119
+ primary: !!m.primary,
120
+ raw: m,
121
+ });
122
+ }
65
123
  }
66
- var mates = ctx.matesList ? ctx.matesList() : [];
67
- if (!mates || mates.length === 0) {
124
+
125
+ // Users: every other signed-in user (always available, even if Mates UI is off).
126
+ // We use the cached user list pushed by the server in projects_updated.
127
+ var allUsers = ctx.allUsers ? ctx.allUsers() : [];
128
+ var myId = ctx.myUserId ? ctx.myUserId() : null;
129
+ for (var ui = 0; ui < allUsers.length; ui++) {
130
+ var u = allUsers[ui];
131
+ if (!u || !u.id) continue;
132
+ if (myId && u.id === myId) continue; // never @-mention yourself
133
+ candidates.push({
134
+ kind: "user",
135
+ id: u.id,
136
+ name: u.displayName || u.username || "User",
137
+ color: u.avatarColor || "#7c3aed",
138
+ // Build dicebear url from style+seed; matches userAvatarUrl shape.
139
+ avatarSrc: "https://api.dicebear.com/7.x/" + (u.avatarStyle || "thumbs") + "/svg?seed=" + encodeURIComponent(u.avatarSeed || u.username || u.id),
140
+ vendor: null,
141
+ bio: u.username ? "@" + u.username : "",
142
+ primary: false,
143
+ raw: u,
144
+ });
145
+ }
146
+
147
+ return candidates;
148
+ }
149
+
150
+ export function showMentionMenu(query) {
151
+ var candidates = buildMentionCandidates();
152
+ if (candidates.length === 0) {
68
153
  hideMentionMenu();
69
154
  return;
70
155
  }
71
156
 
72
- var lowerQuery = query.toLowerCase();
73
- mentionFiltered = mates.filter(function (m) {
74
- if (m.status === "interviewing") return false;
75
- var name = ((m.profile && m.profile.displayName) || m.name || "").toLowerCase();
76
- return name.indexOf(lowerQuery) !== -1;
157
+ var lowerQuery = (query || "").toLowerCase();
158
+ mentionFiltered = candidates.filter(function (c) {
159
+ if (!lowerQuery) return true;
160
+ if (c.name.toLowerCase().indexOf(lowerQuery) !== -1) return true;
161
+ if (c.kind === "user" && c.raw && c.raw.username && c.raw.username.toLowerCase().indexOf(lowerQuery) !== -1) return true;
162
+ return false;
77
163
  });
78
164
 
79
165
  if (mentionFiltered.length === 0) {
@@ -87,24 +173,21 @@ export function showMentionMenu(query) {
87
173
  var menuEl = document.getElementById("mention-menu");
88
174
  if (!menuEl) return;
89
175
 
90
- menuEl.innerHTML = '<div class="mention-hint">Mention a Mate to get advice on your current session<button class="mention-close-btn" aria-label="Close">&times;</button></div>' +
91
- mentionFiltered.map(function (m, i) {
92
- var name = (m.profile && m.profile.displayName) || m.name || "Mate";
93
- var color = (m.profile && m.profile.avatarColor) || "#6c5ce7";
94
- var bio = m.bio || (m.profile && m.profile.bio) || "";
95
- var avatarSrc = mateAvatarUrl(m, 24);
96
- var mVendor = m.vendor || "claude";
176
+ menuEl.innerHTML = '<div class="mention-hint">Mention a Mate or teammate &mdash; the coding agent stays out of this exchange until your next message<button class="mention-close-btn" aria-label="Close">&times;</button></div>' +
177
+ mentionFiltered.map(function (c, i) {
97
178
  var vendorIcons = { claude: "/claude-code-avatar.png", codex: "/codex-avatar.png" };
98
- var vendorBadge = vendorIcons[mVendor] ? '<img class="mention-item-vendor-badge" src="' + vendorIcons[mVendor] + '" alt="' + mVendor + '">' : '';
179
+ var vendorBadge = (c.kind === "mate" && vendorIcons[c.vendor]) ? '<img class="mention-item-vendor-badge" src="' + vendorIcons[c.vendor] + '" alt="' + escapeHtml(c.vendor) + '">' : '';
180
+ var kindBadge = c.kind === "user"
181
+ ? ' <span class="mention-item-badge mention-item-badge-user">USER</span>'
182
+ : (c.plain ? ' <span class="mention-item-badge">PLAIN</span>'
183
+ : (c.primary ? ' <span class="mention-item-badge">SYSTEM</span>' : ''));
99
184
  return '<div class="mention-item' + (i === 0 ? ' active' : '') + '" data-idx="' + i + '">' +
100
- '<div class="mention-item-avatar-wrap"><img class="mention-item-avatar" src="' + escapeHtml(avatarSrc) + '" width="24" height="24" />' + vendorBadge + '</div>' +
185
+ '<div class="mention-item-avatar-wrap"><img class="mention-item-avatar" src="' + escapeHtml(c.avatarSrc) + '" width="24" height="24" />' + vendorBadge + '</div>' +
101
186
  '<div class="mention-item-info">' +
102
- '<span class="mention-item-name">' + escapeHtml(name) +
103
- (m.primary ? ' <span class="mention-item-badge">SYSTEM</span>' : '') +
104
- '</span>' +
105
- (bio ? '<span class="mention-item-bio">' + escapeHtml(bio) + '</span>' : '') +
187
+ '<span class="mention-item-name">' + escapeHtml(c.name) + kindBadge + '</span>' +
188
+ (c.bio ? '<span class="mention-item-bio">' + escapeHtml(c.bio) + '</span>' : '') +
106
189
  '</div>' +
107
- '<span class="mention-item-dot" style="background:' + escapeHtml(color) + '"></span>' +
190
+ '<span class="mention-item-dot" style="background:' + escapeHtml(c.color) + '"></span>' +
108
191
  '</div>';
109
192
  }).join("");
110
193
  menuEl.classList.add("visible");
@@ -175,14 +258,15 @@ export function mentionMenuKeydown(e) {
175
258
 
176
259
  function selectMentionItem(idx) {
177
260
  if (idx < 0 || idx >= mentionFiltered.length) return;
178
- var mate = mentionFiltered[idx];
179
- var name = (mate.profile && mate.profile.displayName) || mate.name || "Mate";
180
- var color = (mate.profile && mate.profile.avatarColor) || "#6c5ce7";
181
- var avatarSrc = mateAvatarUrl(mate, 20);
182
-
183
- selectedMateId = mate.id;
184
- selectedMateName = name;
185
- selectedMateColor = color;
261
+ var c = mentionFiltered[idx];
262
+ // mentionFiltered now holds normalized candidates ({ kind, id, name, color, avatarSrc, ... }).
263
+ // Use a smaller avatar for the chip than the dropdown (20 vs 24).
264
+ var avatarSrc = c.kind === "mate" && c.raw ? mateAvatarUrl(c.raw, 20) : c.avatarSrc;
265
+
266
+ selectedMateKind = c.kind;
267
+ selectedMateId = c.id;
268
+ selectedMateName = c.name;
269
+ selectedMateColor = c.color;
186
270
  selectedMateAvatar = avatarSrc;
187
271
 
188
272
  // Remove the @query text from the textarea, keep remaining text
@@ -197,7 +281,7 @@ function selectMentionItem(idx) {
197
281
  }
198
282
 
199
283
  // Show visual chip in input area
200
- showInputMentionChip(name, color, avatarSrc);
284
+ showInputMentionChip(selectedMateName, selectedMateColor, avatarSrc);
201
285
 
202
286
  hideMentionMenu();
203
287
  }
@@ -230,6 +314,10 @@ function color_mix_lighten(r, g, b, amount) {
230
314
  .map(function (v) { return v.toString(16).padStart(2, "0"); }).join("");
231
315
  }
232
316
 
317
+ // Saved textarea placeholder so we can restore it when the chip is removed.
318
+ // Set on first chip show, cleared on chip removal.
319
+ var savedInputPlaceholder = null;
320
+
233
321
  function showInputMentionChip(name, color, avatarSrc) {
234
322
  removeInputMentionChip();
235
323
  var textColor = ensureChipContrast(color);
@@ -248,6 +336,16 @@ function showInputMentionChip(name, color, avatarSrc) {
248
336
  inputRow.insertBefore(chip, textareaWrap);
249
337
  }
250
338
 
339
+ // Swap the textarea placeholder so the user sees "Ask @{name}..." instead of
340
+ // the session-vendor placeholder ("Message Claude Code...") while a mention
341
+ // is queued. Save the original once so re-shows don't clobber it.
342
+ if (ctx && ctx.inputEl) {
343
+ if (savedInputPlaceholder === null) {
344
+ savedInputPlaceholder = ctx.inputEl.placeholder || "";
345
+ }
346
+ ctx.inputEl.placeholder = "Ask @" + name + "...";
347
+ }
348
+
251
349
  chip.querySelector(".input-mention-chip-remove").addEventListener("click", function (e) {
252
350
  e.preventDefault();
253
351
  e.stopPropagation();
@@ -258,10 +356,15 @@ function showInputMentionChip(name, color, avatarSrc) {
258
356
  function removeInputMentionChip() {
259
357
  var existing = document.getElementById("input-mention-chip");
260
358
  if (existing) existing.remove();
359
+ if (ctx && ctx.inputEl && savedInputPlaceholder !== null) {
360
+ ctx.inputEl.placeholder = savedInputPlaceholder;
361
+ savedInputPlaceholder = null;
362
+ }
261
363
  }
262
364
 
263
365
  export function removeMentionChip() {
264
366
  removeInputMentionChip();
367
+ selectedMateKind = null;
265
368
  selectedMateId = null;
266
369
  selectedMateName = null;
267
370
  selectedMateColor = null;
@@ -285,16 +388,27 @@ export function setMentionAtIdx(idx) {
285
388
  }
286
389
 
287
390
  // --- Mention send ---
288
- // Returns { mateId, mateName, text } if input has an @mention, or null
391
+ // Returns { kind, mateId, userId, mateName, text } if input has an @mention, or null.
392
+ // `mateId` and `userId` are kept as separate fields so call sites can dispatch
393
+ // to the right server message type without inspecting `kind` (legacy callers
394
+ // that only read `mateId` keep working for mate mentions).
289
395
  export function parseMentionFromInput(text) {
290
396
  if (!selectedMateId || !selectedMateName) return null;
291
397
  // The chip is shown separately; textarea contains only the message text
292
- var mentionText = text.trim();
398
+ var mentionText = (text || "").trim();
293
399
  if (!mentionText) return null;
294
- return { mateId: selectedMateId, mateName: selectedMateName, text: mentionText };
400
+ var kind = selectedMateKind || "mate";
401
+ return {
402
+ kind: kind,
403
+ mateId: kind === "mate" ? selectedMateId : null,
404
+ userId: kind === "user" ? selectedMateId : null,
405
+ mateName: selectedMateName,
406
+ text: mentionText,
407
+ };
295
408
  }
296
409
 
297
410
  export function clearMentionState() {
411
+ selectedMateKind = null;
298
412
  selectedMateId = null;
299
413
  selectedMateName = null;
300
414
  selectedMateColor = null;
@@ -303,17 +417,19 @@ export function clearMentionState() {
303
417
  removeInputMentionChip();
304
418
  }
305
419
 
306
- // Re-apply the same mate mention after sending (sticky mention).
307
- // Keeps the chip visible so the next message also goes to the same mate.
420
+ // Re-apply the same mention after sending (sticky mention).
421
+ // Keeps the chip visible so the next message also goes to the same target.
308
422
  export function stickyReapplyMention() {
309
423
  if (!selectedMateId || !selectedMateName) return;
424
+ var kind = selectedMateKind;
310
425
  var id = selectedMateId;
311
426
  var name = selectedMateName;
312
427
  var color = selectedMateColor || "#6c5ce7";
313
428
  var avatarSrc = selectedMateAvatar || "";
314
- // Reset index but keep mate selection
429
+ // Reset index but keep selection
315
430
  mentionAtIdx = -1;
316
431
  removeInputMentionChip();
432
+ selectedMateKind = kind;
317
433
  selectedMateId = id;
318
434
  selectedMateName = name;
319
435
  selectedMateColor = color;
@@ -321,6 +437,7 @@ export function stickyReapplyMention() {
321
437
  showInputMentionChip(name, color, avatarSrc);
322
438
  }
323
439
 
440
+ // Send a mate @mention. Existing call sites keep using this verbatim.
324
441
  export function sendMention(mateId, text, pastes, images) {
325
442
  if (!ctx.ws || !ctx.connected) return;
326
443
  var payload = { type: "mention", mateId: mateId, text: text };
@@ -329,6 +446,17 @@ export function sendMention(mateId, text, pastes, images) {
329
446
  ctx.ws.send(JSON.stringify(payload));
330
447
  }
331
448
 
449
+ // Send a user-to-user @mention. Server stores it in session.history, broadcasts
450
+ // to other session viewers, fires an alarm-center notification + push for the
451
+ // target user, and queues the transcript for the next coding-agent turn.
452
+ export function sendUserMention(userId, text, pastes, images) {
453
+ if (!ctx.ws || !ctx.connected) return;
454
+ var payload = { type: "user_mention", targetUserId: userId, text: text };
455
+ if (pastes && pastes.length > 0) payload.pastes = pastes;
456
+ if (images && images.length > 0) payload.images = images;
457
+ ctx.ws.send(JSON.stringify(payload));
458
+ }
459
+
332
460
  // --- Mention response rendering ---
333
461
 
334
462
  // Recreate the mention block if it was lost (e.g. session switch)
@@ -647,6 +775,120 @@ function buildMentionAvatarUrl(meta) {
647
775
  return "https://api.dicebear.com/7.x/" + (meta.avatarStyle || "bottts") + "/svg?seed=" + encodeURIComponent(meta.avatarSeed || meta.mateId);
648
776
  }
649
777
 
778
+ function buildUserAvatarUrlFromEntry(entry, who) {
779
+ // who = "from" | "target"
780
+ var style = entry[who + "AvatarStyle"] || "thumbs";
781
+ var seed = entry[who + "AvatarSeed"] || entry[who === "from" ? "fromName" : "targetName"] || entry[who === "from" ? "from" : "targetUserId"];
782
+ return "https://api.dicebear.com/7.x/" + style + "/svg?seed=" + encodeURIComponent(seed || "user");
783
+ }
784
+
785
+ // Render a user-to-user @mention bubble (live receive AND history replay).
786
+ // Shape mirrors renderMentionUser (the user-side @mate render) but tags the
787
+ // target user explicitly so it's clear who the side conversation is between.
788
+ // `entry` shape:
789
+ // { type: "user_mention", from, fromName, targetUserId, targetName,
790
+ // targetUsername?, targetAvatarStyle?, targetAvatarSeed?,
791
+ // text, pastes?, images?, _ts? }
792
+ export function renderUserMention(entry) {
793
+ var div = document.createElement("div");
794
+ div.className = "msg-user msg-user-mention";
795
+
796
+ var bubble = document.createElement("div");
797
+ bubble.className = "bubble";
798
+ bubble.dir = "auto";
799
+
800
+ // Images
801
+ if (entry.images && entry.images.length > 0) {
802
+ var imgRow = document.createElement("div");
803
+ imgRow.className = "bubble-images";
804
+ for (var ii = 0; ii < entry.images.length; ii++) {
805
+ var img = document.createElement("img");
806
+ if (entry.images[ii].url) {
807
+ img.src = entry.images[ii].url;
808
+ } else if (entry.images[ii].data) {
809
+ img.src = "data:" + entry.images[ii].mediaType + ";base64," + entry.images[ii].data;
810
+ }
811
+ img.loading = "lazy";
812
+ img.className = "bubble-img";
813
+ img.addEventListener("click", function () {
814
+ if (ctx.showImageModal) ctx.showImageModal(this.src);
815
+ });
816
+ img.addEventListener("error", function () {
817
+ var placeholder = document.createElement("div");
818
+ placeholder.className = "bubble-img-expired";
819
+ placeholder.textContent = "Image deleted";
820
+ this.parentNode.replaceChild(placeholder, this);
821
+ });
822
+ imgRow.appendChild(img);
823
+ }
824
+ bubble.appendChild(imgRow);
825
+ }
826
+
827
+ // Pastes
828
+ if (entry.pastes && entry.pastes.length > 0) {
829
+ var pasteRow = document.createElement("div");
830
+ pasteRow.className = "bubble-pastes";
831
+ for (var pi = 0; pi < entry.pastes.length; pi++) {
832
+ (function (pasteText) {
833
+ var chip = document.createElement("div");
834
+ chip.className = "bubble-paste";
835
+ var preview = pasteText.substring(0, 60).replace(/\n/g, " ");
836
+ if (pasteText.length > 60) preview += "...";
837
+ chip.innerHTML = '<span class="bubble-paste-preview">' + escapeHtml(preview) + '</span><span class="bubble-paste-label">PASTED</span>';
838
+ chip.addEventListener("click", function (e) {
839
+ e.stopPropagation();
840
+ if (ctx.showPasteModal) ctx.showPasteModal(pasteText);
841
+ });
842
+ pasteRow.appendChild(chip);
843
+ })(entry.pastes[pi]);
844
+ }
845
+ bubble.appendChild(pasteRow);
846
+ }
847
+
848
+ var targetName = entry.targetName || "user";
849
+ var fromName = entry.fromName || "Someone";
850
+ var textEl = document.createElement("span");
851
+ textEl.innerHTML =
852
+ '<span class="mention-chip mention-chip-user">@' + escapeHtml(targetName) + '</span> ' +
853
+ escapeHtml(entry.text || "");
854
+ bubble.appendChild(textEl);
855
+
856
+ // Sender avatar (from)
857
+ var avi = document.createElement("img");
858
+ avi.className = "dm-bubble-avatar dm-bubble-avatar-me";
859
+ avi.src = buildUserAvatarUrlFromEntry(entry, "from");
860
+ div.appendChild(avi);
861
+
862
+ var contentWrap = document.createElement("div");
863
+ contentWrap.className = "dm-bubble-content";
864
+
865
+ var header = document.createElement("div");
866
+ header.className = "dm-bubble-header";
867
+ var nameSpan = document.createElement("span");
868
+ nameSpan.className = "dm-bubble-name";
869
+ nameSpan.textContent = fromName;
870
+ header.appendChild(nameSpan);
871
+
872
+ var sideTag = document.createElement("span");
873
+ sideTag.className = "mention-badge mention-badge-user";
874
+ sideTag.textContent = "@" + targetName.toUpperCase();
875
+ sideTag.title = "Side conversation with " + targetName + ". The coding agent will see this on the next message.";
876
+ header.appendChild(sideTag);
877
+
878
+ var ts = document.createElement("span");
879
+ ts.className = "dm-bubble-time";
880
+ ts.textContent = timeStr();
881
+ header.appendChild(ts);
882
+
883
+ contentWrap.appendChild(header);
884
+ contentWrap.appendChild(bubble);
885
+ div.appendChild(contentWrap);
886
+
887
+ if (ctx.addToMessages) ctx.addToMessages(div);
888
+ else if (ctx.messagesEl) ctx.messagesEl.appendChild(div);
889
+ refreshIcons();
890
+ }
891
+
650
892
  // --- History replay: render saved mention entries ---
651
893
  export function renderMentionUser(entry) {
652
894
  // Render user message with @mention indicator
package/lib/server.js CHANGED
@@ -239,6 +239,7 @@ function createServer(opts) {
239
239
  var { attachNotifications: _attachNotifications } = require("./project-notifications");
240
240
  var _globalNotifications = _attachNotifications({
241
241
  broadcastAll: function (msg) { broadcastAll(msg); },
242
+ sendToUser: function (userId, msg) { sendToUser(userId, msg); },
242
243
  pushModule: pushModule,
243
244
  });
244
245
 
@@ -1019,6 +1020,7 @@ function createServer(opts) {
1019
1020
  broadcastAll: broadcastAll,
1020
1021
  notificationsModule: _globalNotifications,
1021
1022
  getProject: function (s) { return projects.get(s) || null; },
1023
+ isUserOnline: isUserOnline,
1022
1024
  });
1023
1025
  projects.set(slug, ctx);
1024
1026
  // ctx.warmup() is now deferred to the first websocket connection into
@@ -1229,6 +1231,38 @@ function createServer(opts) {
1229
1231
  });
1230
1232
  }
1231
1233
 
1234
+ // Send a message to every live ws belonging to a specific user across all projects.
1235
+ // Used by user-targeted notifications (e.g. user-to-user @mentions).
1236
+ function sendToUser(userId, msg) {
1237
+ if (!userId) return;
1238
+ var data = JSON.stringify(msg);
1239
+ projects.forEach(function (ctx) {
1240
+ if (typeof ctx.forEachClient !== "function") return;
1241
+ ctx.forEachClient(function (ws) {
1242
+ if (ws._clayUser && ws._clayUser.id === userId && ws.readyState === 1) {
1243
+ ws.send(data);
1244
+ }
1245
+ });
1246
+ });
1247
+ }
1248
+
1249
+ // True if the user has any live ws across any project.
1250
+ function isUserOnline(userId) {
1251
+ if (!userId) return false;
1252
+ var found = false;
1253
+ projects.forEach(function (ctx) {
1254
+ if (found) return;
1255
+ if (typeof ctx.forEachClient !== "function") return;
1256
+ ctx.forEachClient(function (ws) {
1257
+ if (found) return;
1258
+ if (ws._clayUser && ws._clayUser.id === userId && ws.readyState === 1) {
1259
+ found = true;
1260
+ }
1261
+ });
1262
+ });
1263
+ return found;
1264
+ }
1265
+
1232
1266
  function forEachProject(fn) {
1233
1267
  projects.forEach(function (ctx, slug) {
1234
1268
  fn(ctx, slug);
@@ -24,6 +24,73 @@ if (!socketPath) {
24
24
  process.exit(1);
25
25
  }
26
26
 
27
+ // --- Per-user Claude binary resolver ---
28
+ // In OS-level multi-user mode, the worker runs as a different OS user than the
29
+ // Clay daemon. Each user may have their own `claude` install (nvm, asdf,
30
+ // ~/.npm-global, etc.), and the daemon's resolved path may not be executable
31
+ // by the worker user. Resolve from the worker's own environment, falling back
32
+ // to whatever the daemon passed in.
33
+ var _workerClaudeBinary = null;
34
+ var _workerClaudeBinaryResolved = false;
35
+ function resolveWorkerClaudeBinary() {
36
+ if (_workerClaudeBinaryResolved) return _workerClaudeBinary;
37
+ _workerClaudeBinaryResolved = true;
38
+
39
+ // 1. Explicit env var override
40
+ if (process.env.CLAUDE_CODE_PATH && fs.existsSync(process.env.CLAUDE_CODE_PATH)) {
41
+ _workerClaudeBinary = process.env.CLAUDE_CODE_PATH;
42
+ return _workerClaudeBinary;
43
+ }
44
+
45
+ // 2. `which claude` in the worker user's PATH
46
+ try {
47
+ var which = require("child_process").execSync("which claude", { encoding: "utf8", timeout: 5000 }).trim();
48
+ if (which && fs.existsSync(which)) {
49
+ _workerClaudeBinary = which;
50
+ return _workerClaudeBinary;
51
+ }
52
+ } catch (e) {}
53
+
54
+ // 3. Common per-user and system locations
55
+ var home = process.env.HOME || "";
56
+ var candidates = [];
57
+ if (home) {
58
+ candidates.push(home + "/.npm-global/bin/claude");
59
+ candidates.push(home + "/.local/bin/claude");
60
+ candidates.push(home + "/.volta/bin/claude");
61
+ candidates.push(home + "/.bun/bin/claude");
62
+ candidates.push(home + "/bin/claude");
63
+ }
64
+ candidates.push("/usr/local/bin/claude");
65
+ candidates.push("/usr/bin/claude");
66
+ candidates.push("/opt/homebrew/bin/claude");
67
+ for (var i = 0; i < candidates.length; i++) {
68
+ try { if (fs.existsSync(candidates[i])) { _workerClaudeBinary = candidates[i]; return _workerClaudeBinary; } } catch (e) {}
69
+ }
70
+
71
+ // 4. Resolve via require for the bundled CLI entry
72
+ try {
73
+ var resolved = require.resolve("@anthropic-ai/claude-code/cli.js");
74
+ if (resolved && fs.existsSync(resolved)) {
75
+ _workerClaudeBinary = resolved;
76
+ return _workerClaudeBinary;
77
+ }
78
+ } catch (e) {}
79
+
80
+ return null;
81
+ }
82
+
83
+ function applyWorkerClaudeBinary(options) {
84
+ if (!options) return options;
85
+ var workerBinary = resolveWorkerClaudeBinary();
86
+ if (workerBinary) {
87
+ // Worker's own resolution wins: per-user environment is authoritative
88
+ options.pathToClaudeCodeExecutable = workerBinary;
89
+ }
90
+ // If worker can't resolve, leave whatever the daemon passed (best-effort fallback)
91
+ return options;
92
+ }
93
+
27
94
  // --- State ---
28
95
  var sdkModule = null;
29
96
  var queryInstance = null;
@@ -338,6 +405,7 @@ async function handleQueryStart(msg) {
338
405
  options.abortController = abortController;
339
406
  options.debug = true;
340
407
  options.debugFile = "/tmp/clay-cli-debug-" + process.pid + ".log";
408
+ applyWorkerClaudeBinary(options);
341
409
  if (options.mcpServerDescriptors && options.mcpServerDescriptors.length) {
342
410
  try {
343
411
  var mcpServers = buildMcpServersFromDescriptors(options.mcpServerDescriptors, sdk);
@@ -560,6 +628,7 @@ async function handleWarmup(msg) {
560
628
 
561
629
  var warmupOptions = msg.options || {};
562
630
  warmupOptions.abortController = ac;
631
+ applyWorkerClaudeBinary(warmupOptions);
563
632
 
564
633
  try {
565
634
  var stream = sdk.query({
@@ -943,10 +943,40 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool)
943
943
  // --- Adapter factory ---
944
944
 
945
945
  function resolveClaudeBinaryPath() {
946
+ // 1. Explicit env var override
947
+ if (process.env.CLAUDE_CODE_PATH && fs.existsSync(process.env.CLAUDE_CODE_PATH)) {
948
+ return process.env.CLAUDE_CODE_PATH;
949
+ }
950
+
951
+ // 2. `which claude` in the daemon's PATH
946
952
  try {
947
953
  var result = require("child_process").execSync("which claude", { encoding: "utf8", timeout: 5000 }).trim();
948
954
  if (result && fs.existsSync(result)) return result;
949
955
  } catch (e) {}
956
+
957
+ // 3. Common per-user and system locations (best-effort fallback for the daemon user)
958
+ var home = process.env.HOME || "";
959
+ var candidates = [];
960
+ if (home) {
961
+ candidates.push(home + "/.npm-global/bin/claude");
962
+ candidates.push(home + "/.local/bin/claude");
963
+ candidates.push(home + "/.volta/bin/claude");
964
+ candidates.push(home + "/.bun/bin/claude");
965
+ candidates.push(home + "/bin/claude");
966
+ }
967
+ candidates.push("/usr/local/bin/claude");
968
+ candidates.push("/usr/bin/claude");
969
+ candidates.push("/opt/homebrew/bin/claude");
970
+ for (var i = 0; i < candidates.length; i++) {
971
+ try { if (fs.existsSync(candidates[i])) return candidates[i]; } catch (e) {}
972
+ }
973
+
974
+ // 4. Bundled CLI entry from the SDK's peer
975
+ try {
976
+ var resolved = require.resolve("@anthropic-ai/claude-code/cli.js");
977
+ if (resolved && fs.existsSync(resolved)) return resolved;
978
+ } catch (e) {}
979
+
950
980
  return null;
951
981
  }
952
982