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.
- package/README.md +136 -88
- package/lib/project-image.js +1 -1
- package/lib/project-mate-interaction.js +72 -19
- package/lib/project-notifications.js +28 -2
- package/lib/project-user-mention.js +193 -0
- package/lib/project.js +27 -0
- package/lib/public/app.js +8 -0
- package/lib/public/modules/app-messages.js +13 -1
- package/lib/public/modules/app-notifications.js +40 -4
- package/lib/public/modules/input.js +38 -3
- package/lib/public/modules/mention.js +290 -48
- package/lib/server.js +34 -0
- package/lib/yoke/adapters/claude-worker.js +69 -0
- package/lib/yoke/adapters/claude.js +30 -0
- package/package.json +21 -9
|
@@ -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
|
|
12
|
+
var mentionFiltered = []; // filtered candidate list (mates + users)
|
|
13
13
|
var mentionActiveIdx = -1; // highlighted item in dropdown
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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 =
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
91
|
-
mentionFiltered.map(function (
|
|
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 — the coding agent stays out of this exchange until your next message<button class="mention-close-btn" aria-label="Close">×</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[
|
|
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
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
var avatarSrc = mateAvatarUrl(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
307
|
-
// Keeps the chip visible so the next message also goes to the same
|
|
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
|
|
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
|
|