clay-server 2.11.0 → 2.12.0-beta.1

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.
@@ -0,0 +1,123 @@
1
+ // --- Global JS tooltip system ---
2
+ // Unified tooltip using [data-tip] attribute.
3
+ // Usage: initTooltips() auto-binds, converts [title] to [data-tip].
4
+ // Call registerTooltip(el, text) for dynamic elements.
5
+
6
+ var tooltipEl = null;
7
+ var showTimer = null;
8
+ var SHOW_DELAY = 120;
9
+
10
+ function initTooltips() {
11
+ // Create singleton tooltip element
12
+ tooltipEl = document.createElement("div");
13
+ tooltipEl.className = "tooltip";
14
+ document.body.appendChild(tooltipEl);
15
+
16
+ // Convert existing title attributes to data-tip in target areas
17
+ convertTitles();
18
+
19
+ // Delegate hover events on document for [data-tip]
20
+ document.addEventListener("mouseover", function (e) {
21
+ var target = e.target.closest("[data-tip]");
22
+ if (!target) return;
23
+ scheduleShow(target);
24
+ });
25
+
26
+ document.addEventListener("mouseout", function (e) {
27
+ var target = e.target.closest("[data-tip]");
28
+ if (!target) return;
29
+ cancelShow();
30
+ hideTooltip();
31
+ });
32
+
33
+ document.addEventListener("pointerdown", function () {
34
+ cancelShow();
35
+ hideTooltip();
36
+ }, true);
37
+
38
+ document.addEventListener("scroll", function () {
39
+ cancelShow();
40
+ hideTooltip();
41
+ }, true);
42
+ }
43
+
44
+ function convertTitles() {
45
+ var selectors = [
46
+ "#top-bar [title]",
47
+ ".title-bar-content [title]",
48
+ "#input-area [title]",
49
+ ];
50
+ var els = document.querySelectorAll(selectors.join(", "));
51
+ for (var i = 0; i < els.length; i++) {
52
+ var el = els[i];
53
+ if (!el.getAttribute("data-tip")) {
54
+ el.setAttribute("data-tip", el.getAttribute("title"));
55
+ el.removeAttribute("title");
56
+ }
57
+ }
58
+ }
59
+
60
+ function registerTooltip(el, text) {
61
+ el.setAttribute("data-tip", text);
62
+ el.removeAttribute("title");
63
+ }
64
+
65
+ function scheduleShow(el) {
66
+ cancelShow();
67
+ showTimer = setTimeout(function () {
68
+ showTooltipAt(el);
69
+ }, SHOW_DELAY);
70
+ }
71
+
72
+ function cancelShow() {
73
+ if (showTimer) {
74
+ clearTimeout(showTimer);
75
+ showTimer = null;
76
+ }
77
+ }
78
+
79
+ function showTooltipAt(target) {
80
+ if (!tooltipEl) return;
81
+ var text = target.getAttribute("data-tip");
82
+ if (!text) return;
83
+
84
+ tooltipEl.textContent = text;
85
+ tooltipEl.style.top = "-9999px";
86
+ tooltipEl.style.left = "0";
87
+ tooltipEl.style.right = "";
88
+ tooltipEl.classList.add("visible");
89
+
90
+ // Position after layout
91
+ var tipW = tooltipEl.offsetWidth;
92
+ var tipH = tooltipEl.offsetHeight;
93
+ var rect = target.getBoundingClientRect();
94
+ var gap = 8;
95
+ var winW = window.innerWidth;
96
+ var winH = window.innerHeight;
97
+
98
+ // Prefer bottom, fallback to top
99
+ var top = rect.bottom + gap;
100
+ if (top + tipH > winH - 8) {
101
+ top = rect.top - tipH - gap;
102
+ }
103
+
104
+ // Center horizontally, clamp to viewport
105
+ var centerX = rect.left + rect.width / 2;
106
+ var left = centerX - tipW / 2;
107
+ if (left + tipW > winW - 8) {
108
+ tooltipEl.style.left = "";
109
+ tooltipEl.style.right = "8px";
110
+ } else {
111
+ tooltipEl.style.left = Math.max(8, left) + "px";
112
+ tooltipEl.style.right = "";
113
+ }
114
+ tooltipEl.style.top = top + "px";
115
+ }
116
+
117
+ function hideTooltip() {
118
+ if (tooltipEl) {
119
+ tooltipEl.classList.remove("visible");
120
+ }
121
+ }
122
+
123
+ export { initTooltips, registerTooltip };
@@ -22,3 +22,5 @@
22
22
  @import url("css/stt.css");
23
23
  @import url("css/profile.css");
24
24
  @import url("css/admin.css");
25
+ @import url("css/session-search.css");
26
+ @import url("css/tooltip.css");
package/lib/server.js CHANGED
@@ -220,12 +220,37 @@ function isAuthed(req, authToken) {
220
220
  }
221
221
 
222
222
  // --- Multi-user auth helpers ---
223
- // Multi-user auth tokens: userId:randomToken stored in memory
223
+ // Multi-user auth tokens: persisted to disk so they survive restarts
224
+ var TOKENS_FILE = path.join(CONFIG_DIR, "auth-tokens.json");
224
225
  var multiUserTokens = {}; // token → userId
225
226
 
227
+ function loadTokens() {
228
+ try {
229
+ var raw = fs.readFileSync(TOKENS_FILE, "utf8");
230
+ var data = JSON.parse(raw);
231
+ if (data && typeof data === "object") {
232
+ multiUserTokens = data;
233
+ }
234
+ } catch (e) {
235
+ multiUserTokens = {};
236
+ }
237
+ }
238
+
239
+ function saveTokens() {
240
+ try {
241
+ fs.mkdirSync(path.dirname(TOKENS_FILE), { recursive: true });
242
+ var tmpPath = TOKENS_FILE + ".tmp";
243
+ fs.writeFileSync(tmpPath, JSON.stringify(multiUserTokens));
244
+ fs.renameSync(tmpPath, TOKENS_FILE);
245
+ } catch (e) {}
246
+ }
247
+
248
+ loadTokens();
249
+
226
250
  function createMultiUserSession(userId, tlsOptions) {
227
251
  var token = users.generateUserAuthToken(userId);
228
252
  multiUserTokens[token] = userId;
253
+ saveTokens();
229
254
  var cookie = "relay_auth_user=" + token + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : "");
230
255
  return { token: token, cookie: cookie };
231
256
  }
@@ -244,6 +269,17 @@ function isMultiUserAuthed(req) {
244
269
  return !!getMultiUserFromReq(req);
245
270
  }
246
271
 
272
+ function revokeUserTokens(userId) {
273
+ var changed = false;
274
+ for (var token in multiUserTokens) {
275
+ if (multiUserTokens[token] === userId) {
276
+ delete multiUserTokens[token];
277
+ changed = true;
278
+ }
279
+ }
280
+ if (changed) saveTokens();
281
+ }
282
+
247
283
  // --- PIN rate limiting ---
248
284
  var pinAttempts = {}; // ip → { count, lastAttempt }
249
285
  var PIN_MAX_ATTEMPTS = 5;
@@ -359,6 +395,7 @@ function createServer(opts) {
359
395
  var onSetProjectVisibility = opts.onSetProjectVisibility || null;
360
396
  var onSetProjectAllowedUsers = opts.onSetProjectAllowedUsers || null;
361
397
  var onGetProjectAccess = opts.onGetProjectAccess || null;
398
+ var onCreateWorktree = opts.onCreateWorktree || null;
362
399
  var onUserProvisioned = opts.onUserProvisioned || null;
363
400
  var onUserDeleted = opts.onUserDeleted || null;
364
401
  var getRemovedProjects = opts.getRemovedProjects || function () { return []; };
@@ -1050,6 +1087,8 @@ function createServer(opts) {
1050
1087
  res.end(JSON.stringify({ error: result.error }));
1051
1088
  return;
1052
1089
  }
1090
+ // Remove auth tokens for deleted user
1091
+ revokeUserTokens(targetUserId);
1053
1092
  // Deactivate the Linux account if applicable
1054
1093
  if (onUserDeleted && targetLinuxUser) {
1055
1094
  onUserDeleted(targetUserId, targetLinuxUser);
@@ -1975,7 +2014,9 @@ function createServer(opts) {
1975
2014
  wsUser = getMultiUserFromReq(req);
1976
2015
  // Check project access for multi-user mode
1977
2016
  if (wsUser && onGetProjectAccess) {
1978
- var projectAccess = onGetProjectAccess(wsSlug);
2017
+ // For worktree projects, inherit access from parent
2018
+ var accessSlug = (wsSlug.indexOf("--") !== -1) ? wsSlug.split("--")[0] : wsSlug;
2019
+ var projectAccess = onGetProjectAccess(accessSlug);
1979
2020
  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);
1980
2021
  if (projectAccess && !projectAccess.error) {
1981
2022
  if (!users.canAccessProject(wsUser.id, projectAccess)) {
@@ -2094,7 +2135,7 @@ function createServer(opts) {
2094
2135
  }
2095
2136
 
2096
2137
  // --- Project management ---
2097
- function addProject(cwd, slug, title, icon, projectOwnerId) {
2138
+ function addProject(cwd, slug, title, icon, projectOwnerId, worktreeMeta) {
2098
2139
  if (projects.has(slug)) return false;
2099
2140
  var ctx = createProjectContext({
2100
2141
  cwd: cwd,
@@ -2102,6 +2143,7 @@ function createServer(opts) {
2102
2143
  title: title || null,
2103
2144
  icon: icon || null,
2104
2145
  projectOwnerId: projectOwnerId || null,
2146
+ worktreeMeta: worktreeMeta || null,
2105
2147
  pushModule: pushModule,
2106
2148
  debug: debug,
2107
2149
  dangerouslySkipPermissions: dangerouslySkipPermissions,
@@ -2190,6 +2232,7 @@ function createServer(opts) {
2190
2232
  onCreateProject: onCreateProject,
2191
2233
  onCloneProject: onCloneProject,
2192
2234
  onRemoveProject: onRemoveProject,
2235
+ onCreateWorktree: onCreateWorktree,
2193
2236
  onReorderProjects: onReorderProjects,
2194
2237
  onSetProjectTitle: onSetProjectTitle,
2195
2238
  onSetProjectIcon: onSetProjectIcon,
package/lib/sessions.js CHANGED
@@ -540,6 +540,42 @@ function createSessionManager(opts) {
540
540
  return results;
541
541
  }
542
542
 
543
+ function searchSessionContent(localId, query) {
544
+ if (!query) return { hits: [], total: 0 };
545
+ var session = sessions.get(localId);
546
+ if (!session) return { hits: [], total: 0 };
547
+ var q = query.toLowerCase();
548
+ var history = session.history;
549
+ var hits = [];
550
+ var lastAssistantHitTurn = -1; // track current assistant turn to deduplicate delta hits
551
+ var currentTurnStart = -1;
552
+ for (var i = 0; i < history.length; i++) {
553
+ var entry = history[i];
554
+ if (entry.type === "user_message") {
555
+ currentTurnStart = i;
556
+ lastAssistantHitTurn = -1;
557
+ }
558
+ if ((entry.type === "delta" || entry.type === "user_message") && entry.text) {
559
+ // Skip duplicate delta hits within the same assistant turn
560
+ if (entry.type === "delta" && currentTurnStart === lastAssistantHitTurn) continue;
561
+ var text = entry.text;
562
+ var lowerText = text.toLowerCase();
563
+ var idx = lowerText.indexOf(q);
564
+ if (idx === -1) continue;
565
+ var start = Math.max(0, idx - 15);
566
+ var end = Math.min(text.length, idx + query.length + 15);
567
+ var snippet = (start > 0 ? "\u2026" : "") + text.substring(start, end) + (end < text.length ? "\u2026" : "");
568
+ if (entry.type === "delta") lastAssistantHitTurn = currentTurnStart;
569
+ hits.push({
570
+ historyIndex: i,
571
+ snippet: snippet,
572
+ role: entry.type === "user_message" ? "user" : "assistant",
573
+ });
574
+ }
575
+ }
576
+ return { hits: hits, total: history.length };
577
+ }
578
+
543
579
  function migrateSessionTitles(getSDK, migrateCwd) {
544
580
  var toMigrate = [];
545
581
  sessions.forEach(function(s) {
@@ -597,6 +633,7 @@ function createSessionManager(opts) {
597
633
  findTurnBoundary: findTurnBoundary,
598
634
  replayHistory: replayHistory,
599
635
  searchSessions: searchSessions,
636
+ searchSessionContent: searchSessionContent,
600
637
  setResolveLoopInfo: setResolveLoopInfo,
601
638
  migrateSessionTitles: migrateSessionTitles,
602
639
  setSessionVisibility: function (localId, visibility) {
@@ -0,0 +1,134 @@
1
+ var { execFileSync } = require("child_process");
2
+ var path = require("path");
3
+
4
+ // Parse `git worktree list --porcelain` output into structured objects
5
+ function parseWorktreeOutput(output) {
6
+ var worktrees = [];
7
+ var current = null;
8
+ var lines = output.split("\n");
9
+ for (var i = 0; i < lines.length; i++) {
10
+ var line = lines[i];
11
+ if (line.indexOf("worktree ") === 0) {
12
+ if (current) worktrees.push(current);
13
+ current = { path: line.slice(9), branch: null, bare: false, detached: false };
14
+ } else if (line.indexOf("branch ") === 0 && current) {
15
+ // refs/heads/feat/login -> feat/login
16
+ var ref = line.slice(7);
17
+ var headsIdx = ref.indexOf("refs/heads/");
18
+ current.branch = headsIdx === 0 ? ref.slice(11) : ref;
19
+ } else if (line === "bare" && current) {
20
+ current.bare = true;
21
+ } else if (line === "detached" && current) {
22
+ current.detached = true;
23
+ }
24
+ }
25
+ if (current) worktrees.push(current);
26
+ return worktrees;
27
+ }
28
+
29
+ // Check if a given path is itself a worktree (not the main working tree)
30
+ function isWorktree(projectPath) {
31
+ try {
32
+ var gitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
33
+ cwd: projectPath, encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
34
+ }).trim();
35
+ var commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
36
+ cwd: projectPath, encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
37
+ }).trim();
38
+ var absGit = path.resolve(projectPath, gitDir);
39
+ var absCommon = path.resolve(projectPath, commonDir);
40
+ return absGit !== absCommon;
41
+ } catch (e) {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ // Scan worktrees for a given project path
47
+ // Returns array of { path, branch, bare, detached, accessible }
48
+ // accessible = true if worktree path is inside parentPath
49
+ function scanWorktrees(projectPath) {
50
+ var resolvedParent = path.resolve(projectPath);
51
+ try {
52
+ var output = execFileSync("git", ["worktree", "list", "--porcelain"], {
53
+ cwd: resolvedParent,
54
+ encoding: "utf8",
55
+ timeout: 5000,
56
+ stdio: ["pipe", "pipe", "pipe"],
57
+ });
58
+ var all = parseWorktreeOutput(output);
59
+ // Filter out bare worktrees and the main worktree itself
60
+ var results = [];
61
+ for (var i = 0; i < all.length; i++) {
62
+ var wt = all[i];
63
+ if (wt.bare) continue;
64
+ var resolvedWt = path.resolve(wt.path);
65
+ if (resolvedWt === resolvedParent) continue;
66
+ wt.accessible = resolvedWt.indexOf(resolvedParent + path.sep) === 0;
67
+ wt.dirName = path.basename(wt.path);
68
+ results.push(wt);
69
+ }
70
+ return results;
71
+ } catch (e) {
72
+ return [];
73
+ }
74
+ }
75
+
76
+ // Create a new worktree inside the parent project directory
77
+ // Returns { ok, path, error }
78
+ function createWorktree(projectPath, branchName, baseBranch) {
79
+ var resolvedParent = path.resolve(projectPath);
80
+ var wtPath = path.join(resolvedParent, branchName);
81
+ var base = baseBranch || "main";
82
+ // Try creating with -b (new branch)
83
+ try {
84
+ execFileSync("git", ["worktree", "add", wtPath, "-b", branchName, base], {
85
+ cwd: resolvedParent,
86
+ encoding: "utf8",
87
+ timeout: 15000,
88
+ stdio: ["pipe", "pipe", "pipe"],
89
+ });
90
+ return { ok: true, path: wtPath };
91
+ } catch (e) {
92
+ // Branch may already exist, try without -b
93
+ try {
94
+ execFileSync("git", ["worktree", "add", wtPath, branchName], {
95
+ cwd: resolvedParent,
96
+ encoding: "utf8",
97
+ timeout: 15000,
98
+ stdio: ["pipe", "pipe", "pipe"],
99
+ });
100
+ return { ok: true, path: wtPath };
101
+ } catch (e2) {
102
+ return { ok: false, error: e2.message || "Failed to create worktree" };
103
+ }
104
+ }
105
+ }
106
+
107
+ // Remove a worktree
108
+ // Returns { ok, error }
109
+ function removeWorktree(projectPath, worktreeDirName) {
110
+ var resolvedParent = path.resolve(projectPath);
111
+ var wtPath = path.join(resolvedParent, worktreeDirName);
112
+ // Try normal remove first
113
+ try {
114
+ execFileSync("git", ["worktree", "remove", wtPath], {
115
+ cwd: resolvedParent,
116
+ encoding: "utf8",
117
+ timeout: 15000,
118
+ stdio: ["pipe", "pipe", "pipe"],
119
+ });
120
+ return { ok: true };
121
+ } catch (e) {
122
+ var errMsg = (e.stderr || e.message || "").toString();
123
+ // If dirty, report to user
124
+ if (errMsg.indexOf("modified") !== -1 || errMsg.indexOf("untracked") !== -1) {
125
+ return { ok: false, error: "Worktree has uncommitted changes. Commit or discard them first." };
126
+ }
127
+ if (errMsg.indexOf("locked") !== -1) {
128
+ return { ok: false, error: "Worktree is locked. Unlock it first with: git worktree unlock" };
129
+ }
130
+ return { ok: false, error: errMsg || "Failed to remove worktree" };
131
+ }
132
+ }
133
+
134
+ module.exports = { scanWorktrees: scanWorktrees, createWorktree: createWorktree, removeWorktree: removeWorktree, isWorktree: isWorktree };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.11.0",
3
+ "version": "2.12.0-beta.1",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",