clay-server 2.11.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -776,12 +776,12 @@ function promptPin(callback) {
776
776
  * Enter with empty input returns placeholder value.
777
777
  * Tab completes directory paths.
778
778
  */
779
- function promptText(title, placeholder, callback) {
779
+ function promptText(title, placeholder, callback, opts) {
780
780
  var prefix = " " + sym.bar + " ";
781
781
  var hintLine = "";
782
782
  var lineCount = 2;
783
-
784
- log(sym.pointer + " " + a.bold + title + a.reset + " " + a.dim + "(esc to go back)" + a.reset);
783
+ var escHint = (!title || (opts && opts.noEsc)) ? "" : " " + a.dim + "(esc to go back)" + a.reset;
784
+ log(sym.pointer + " " + a.bold + title + a.reset + escHint);
785
785
  process.stdout.write(prefix + a.dim + placeholder + a.reset);
786
786
  // Move cursor to start of placeholder
787
787
  process.stdout.write("\r" + prefix);
@@ -1527,7 +1527,7 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1527
1527
  return;
1528
1528
  }
1529
1529
 
1530
- // Enable multi-user mode if requested
1530
+ // Enable/disable multi-user mode based on startup config
1531
1531
  if (config.mode === "multi") {
1532
1532
  var muResult = enableMultiUser();
1533
1533
  if (muResult.setupCode) {
@@ -1537,6 +1537,8 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1537
1537
  log(sym.bar + " Open Clay in your browser and enter this code to create the admin account.");
1538
1538
  log("");
1539
1539
  }
1540
+ } else if (isMultiUser()) {
1541
+ disableMultiUser();
1540
1542
  }
1541
1543
 
1542
1544
  // Headless mode — print status and exit immediately
@@ -1636,6 +1638,16 @@ async function devMode(mode, keepAwake, existingPinHash) {
1636
1638
  ensureConfigDir();
1637
1639
  saveConfig(config);
1638
1640
 
1641
+ // Enable/disable multi-user mode based on startup config
1642
+ if (config.mode === "multi") {
1643
+ var muResult = enableMultiUser();
1644
+ if (muResult.setupCode) {
1645
+ console.log("\x1b[38;2;0;183;133m[dev]\x1b[0m Multi-user mode enabled. Setup code: " + muResult.setupCode);
1646
+ }
1647
+ } else if (isMultiUser()) {
1648
+ disableMultiUser();
1649
+ }
1650
+
1639
1651
  var daemonScript = path.join(__dirname, "..", "lib", "daemon.js");
1640
1652
  var libDir = path.join(__dirname, "..", "lib");
1641
1653
  var child = null;
package/lib/daemon.js CHANGED
@@ -16,6 +16,9 @@ if (nodeMajor < 20) {
16
16
  if (!Symbol.dispose) Symbol.dispose = Symbol("Symbol.dispose");
17
17
  if (!Symbol.asyncDispose) Symbol.asyncDispose = Symbol("Symbol.asyncDispose");
18
18
 
19
+ // Increase listener limit for projects with many worktrees
20
+ process.setMaxListeners(50);
21
+
19
22
  // Remove CLAUDECODE env var so the SDK can spawn Claude Code child processes
20
23
  // (prevents "cannot be launched inside another Claude Code session" error)
21
24
  delete process.env.CLAUDECODE;
@@ -27,6 +30,7 @@ var { createIPCServer } = require("./ipc");
27
30
  var { createServer, generateAuthToken } = require("./server");
28
31
  var { grantProjectAccess, revokeProjectAccess, provisionAllUsers, provisionLinuxUser, grantAllUsersAccess, deactivateLinuxUser, ensureProjectsDir } = require("./os-users");
29
32
  var usersModule = require("./users");
33
+ var { scanWorktrees, createWorktree, removeWorktree, isWorktree } = require("./worktree");
30
34
 
31
35
  var configFile = process.env.CLAY_CONFIG || process.env.CLAUDE_RELAY_CONFIG || require("./config").configPath();
32
36
  var config;
@@ -155,6 +159,8 @@ var relay = createServer({
155
159
  }
156
160
  }
157
161
  }
162
+ // Discover and register worktrees for the new project
163
+ scanAndRegisterWorktrees(absPath, slug, null, wsUser && wsUser.id && wsUser.role !== "admin" ? wsUser.id : null);
158
164
  // Broadcast updated project list to all clients
159
165
  relay.broadcastAll({
160
166
  type: "projects_updated",
@@ -313,11 +319,41 @@ var relay = createServer({
313
319
  });
314
320
  },
315
321
  onRemoveProject: function (slug, userId) {
322
+ // Check if this is a worktree project (ephemeral)
323
+ if (isWorktreeSlug(slug)) {
324
+ var wtParent = slug.split("--")[0];
325
+ var wtDirName = slug.split("--").slice(1).join("--");
326
+ // Find parent project path
327
+ var parentProject = null;
328
+ for (var pi = 0; pi < config.projects.length; pi++) {
329
+ if (config.projects[pi].slug === wtParent) { parentProject = config.projects[pi]; break; }
330
+ }
331
+ if (parentProject) {
332
+ var rmResult = removeWorktree(parentProject.path, wtDirName);
333
+ if (!rmResult.ok) {
334
+ console.log("[daemon] Failed to remove worktree:", slug, rmResult.error);
335
+ return { ok: false, error: rmResult.error };
336
+ }
337
+ }
338
+ relay.removeProject(slug);
339
+ if (worktreeRegistry[wtParent]) {
340
+ worktreeRegistry[wtParent] = worktreeRegistry[wtParent].filter(function (s) { return s !== slug; });
341
+ }
342
+ console.log("[daemon] Removed worktree (web):", slug);
343
+ relay.broadcastAll({
344
+ type: "projects_updated",
345
+ projects: relay.getProjects(),
346
+ projectCount: config.projects.length,
347
+ });
348
+ return { ok: true };
349
+ }
316
350
  var found = null;
317
351
  for (var j = 0; j < config.projects.length; j++) {
318
352
  if (config.projects[j].slug === slug) { found = config.projects[j]; break; }
319
353
  }
320
354
  if (!found) return { ok: false, error: "Project not found" };
355
+ // Cascade remove worktrees belonging to this parent
356
+ cleanupWorktreesForParent(slug);
321
357
  // Save to removedProjects for re-add functionality
322
358
  if (!config.removedProjects) config.removedProjects = [];
323
359
  config.removedProjects.push({
@@ -756,8 +792,135 @@ var relay = createServer({
756
792
  if (!config.osUsers || !linuxUser) return;
757
793
  deactivateLinuxUser(linuxUser);
758
794
  },
795
+ onCreateWorktree: function (parentSlug, branchName, baseBranch) {
796
+ // Find the parent project
797
+ var parent = null;
798
+ for (var j = 0; j < config.projects.length; j++) {
799
+ if (config.projects[j].slug === parentSlug) { parent = config.projects[j]; break; }
800
+ }
801
+ if (!parent) return { ok: false, error: "Parent project not found" };
802
+ if (isWorktree(parent.path)) return { ok: false, error: "Cannot create worktrees from a worktree project" };
803
+ var result = createWorktree(parent.path, branchName, baseBranch);
804
+ if (!result.ok) return result;
805
+ // Register the new worktree as ephemeral project
806
+ var wtSlug = parentSlug + "--" + branchName;
807
+ var wtMeta = { parentSlug: parentSlug, branch: branchName, accessible: true };
808
+ relay.addProject(result.path, wtSlug, branchName, parent.icon, parent.ownerId, wtMeta);
809
+ if (!worktreeRegistry[parentSlug]) worktreeRegistry[parentSlug] = [];
810
+ worktreeRegistry[parentSlug].push(wtSlug);
811
+ console.log("[daemon] Created worktree:", wtSlug, "->", result.path);
812
+ relay.broadcastAll({
813
+ type: "projects_updated",
814
+ projects: relay.getProjects(),
815
+ projectCount: config.projects.length,
816
+ });
817
+ return { ok: true, slug: wtSlug, path: result.path };
818
+ },
759
819
  });
760
820
 
821
+ // --- Worktree tracking ---
822
+ var worktreeRegistry = {}; // parentSlug -> [wtSlug, ...]
823
+ var worktreeTimers = {}; // parentSlug -> intervalId
824
+ var worktreeScanning = {}; // parentSlug -> boolean (mutex)
825
+
826
+ function isWorktreeSlug(slug) {
827
+ return slug.indexOf("--") !== -1;
828
+ }
829
+
830
+ function scanAndRegisterWorktrees(parentPath, parentSlug, parentIcon, parentOwnerId) {
831
+ // Skip if this project is itself a worktree (not the main working tree)
832
+ if (isWorktree(parentPath)) return;
833
+ var worktrees = scanWorktrees(parentPath);
834
+ if (worktrees.length === 0) return;
835
+ if (!worktreeRegistry[parentSlug]) worktreeRegistry[parentSlug] = [];
836
+ for (var i = 0; i < worktrees.length; i++) {
837
+ var wt = worktrees[i];
838
+ var wtSlug = parentSlug + "--" + wt.dirName;
839
+ // Skip if already registered
840
+ var alreadyRegistered = false;
841
+ for (var j = 0; j < worktreeRegistry[parentSlug].length; j++) {
842
+ if (worktreeRegistry[parentSlug][j] === wtSlug) { alreadyRegistered = true; break; }
843
+ }
844
+ if (alreadyRegistered) continue;
845
+ var wtMeta = { parentSlug: parentSlug, branch: wt.branch || wt.dirName, accessible: wt.accessible };
846
+ // Only add as a full project if accessible, otherwise still track for UI display
847
+ relay.addProject(wt.path, wtSlug, wt.branch || wt.dirName, parentIcon, parentOwnerId, wtMeta);
848
+ worktreeRegistry[parentSlug].push(wtSlug);
849
+ console.log("[daemon] Registered worktree:", wtSlug, "->", wt.path, wt.accessible ? "(accessible)" : "(inaccessible)");
850
+ }
851
+ // Start periodic rescan if not already running
852
+ if (!worktreeTimers[parentSlug]) {
853
+ worktreeTimers[parentSlug] = setInterval(function () {
854
+ rescanWorktrees(parentPath, parentSlug, parentIcon, parentOwnerId);
855
+ }, 10000);
856
+ }
857
+ }
858
+
859
+ function rescanWorktrees(parentPath, parentSlug, parentIcon, parentOwnerId) {
860
+ if (worktreeScanning[parentSlug]) return;
861
+ worktreeScanning[parentSlug] = true;
862
+ try {
863
+ var discovered = scanWorktrees(parentPath);
864
+ var changed = false;
865
+ var existingSlugs = worktreeRegistry[parentSlug] || [];
866
+ // Build set of discovered dirNames
867
+ var discoveredNames = {};
868
+ for (var i = 0; i < discovered.length; i++) {
869
+ discoveredNames[discovered[i].dirName] = discovered[i];
870
+ }
871
+ // Add new worktrees
872
+ for (var di = 0; di < discovered.length; di++) {
873
+ var wt = discovered[di];
874
+ var wtSlug = parentSlug + "--" + wt.dirName;
875
+ var found = false;
876
+ for (var ei = 0; ei < existingSlugs.length; ei++) {
877
+ if (existingSlugs[ei] === wtSlug) { found = true; break; }
878
+ }
879
+ if (!found) {
880
+ var wtMeta = { parentSlug: parentSlug, branch: wt.branch || wt.dirName, accessible: wt.accessible };
881
+ relay.addProject(wt.path, wtSlug, wt.branch || wt.dirName, parentIcon, parentOwnerId, wtMeta);
882
+ if (!worktreeRegistry[parentSlug]) worktreeRegistry[parentSlug] = [];
883
+ worktreeRegistry[parentSlug].push(wtSlug);
884
+ console.log("[daemon] Rescan: added worktree:", wtSlug);
885
+ changed = true;
886
+ }
887
+ }
888
+ // Remove stale worktrees
889
+ for (var si = existingSlugs.length - 1; si >= 0; si--) {
890
+ var sSlug = existingSlugs[si];
891
+ var dirName = sSlug.split("--").slice(1).join("--");
892
+ if (!discoveredNames[dirName]) {
893
+ relay.removeProject(sSlug);
894
+ existingSlugs.splice(si, 1);
895
+ console.log("[daemon] Rescan: removed stale worktree:", sSlug);
896
+ changed = true;
897
+ }
898
+ }
899
+ if (changed) {
900
+ relay.broadcastAll({
901
+ type: "projects_updated",
902
+ projects: relay.getProjects(),
903
+ projectCount: config.projects.length,
904
+ });
905
+ }
906
+ } finally {
907
+ worktreeScanning[parentSlug] = false;
908
+ }
909
+ }
910
+
911
+ function cleanupWorktreesForParent(parentSlug) {
912
+ var wtSlugs = worktreeRegistry[parentSlug] || [];
913
+ for (var i = 0; i < wtSlugs.length; i++) {
914
+ relay.removeProject(wtSlugs[i]);
915
+ console.log("[daemon] Cascade removed worktree:", wtSlugs[i]);
916
+ }
917
+ delete worktreeRegistry[parentSlug];
918
+ if (worktreeTimers[parentSlug]) {
919
+ clearInterval(worktreeTimers[parentSlug]);
920
+ delete worktreeTimers[parentSlug];
921
+ }
922
+ }
923
+
761
924
  // --- Register projects ---
762
925
  var projects = config.projects || [];
763
926
  for (var i = 0; i < projects.length; i++) {
@@ -765,6 +928,8 @@ for (var i = 0; i < projects.length; i++) {
765
928
  if (fs.existsSync(p.path)) {
766
929
  console.log("[daemon] Adding project:", p.slug, "→", p.path);
767
930
  relay.addProject(p.path, p.slug, p.title, p.icon, p.ownerId);
931
+ // Discover and register worktrees for this project
932
+ scanAndRegisterWorktrees(p.path, p.slug, p.icon, p.ownerId);
768
933
  } else {
769
934
  console.log("[daemon] Skipping missing project:", p.path);
770
935
  }
@@ -800,6 +965,8 @@ var ipc = createIPCServer(socketPath(), function (msg) {
800
965
  saveConfig(config);
801
966
  try { syncClayrc(config.projects); } catch (e) {}
802
967
  console.log("[daemon] Added project:", slug, "→", absPath);
968
+ // Discover and register worktrees for the new project
969
+ scanAndRegisterWorktrees(absPath, slug, null, null);
803
970
  relay.broadcastAll({
804
971
  type: "projects_updated",
805
972
  projects: relay.getProjects(),
package/lib/project.js CHANGED
@@ -113,6 +113,8 @@ function createProjectContext(opts) {
113
113
  var updateChannel = opts.updateChannel || "stable";
114
114
  var osUsers = opts.osUsers || false;
115
115
  var projectOwnerId = opts.projectOwnerId || null;
116
+ var worktreeMeta = opts.worktreeMeta || null; // { parentSlug, branch, accessible }
117
+ var onCreateWorktree = opts.onCreateWorktree || null;
116
118
  var latestVersion = null;
117
119
 
118
120
  // --- OS-level user isolation helper ---
@@ -1228,7 +1230,8 @@ function createProjectContext(opts) {
1228
1230
  var session = getSessionForWs(ws);
1229
1231
  if (!session || typeof msg.before !== "number") return;
1230
1232
  var before = msg.before;
1231
- var from = sm.findTurnBoundary(session.history, Math.max(0, before - sm.HISTORY_PAGE_SIZE));
1233
+ var targetFrom = typeof msg.target === "number" ? msg.target : before - sm.HISTORY_PAGE_SIZE;
1234
+ var from = sm.findTurnBoundary(session.history, Math.max(0, targetFrom));
1232
1235
  var to = before;
1233
1236
  var items = session.history.slice(from, to);
1234
1237
  sendTo(ws, {
@@ -1410,6 +1413,16 @@ function createProjectContext(opts) {
1410
1413
  return;
1411
1414
  }
1412
1415
 
1416
+ if (msg.type === "search_session_content") {
1417
+ var targetSession = msg.id ? sm.sessions.get(msg.id) : getSessionForWs(ws);
1418
+ if (!targetSession) return;
1419
+ var contentResults = sm.searchSessionContent(targetSession.localId, msg.query || "");
1420
+ var searchResp = { type: "search_content_results", query: msg.query || "", sessionId: targetSession.localId, hits: contentResults.hits, total: contentResults.total };
1421
+ if (msg.source) searchResp.source = msg.source;
1422
+ sendTo(ws, searchResp);
1423
+ return;
1424
+ }
1425
+
1413
1426
  if (msg.type === "set_update_channel") {
1414
1427
  if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return;
1415
1428
  var newChannel = msg.channel === "beta" ? "beta" : "stable";
@@ -1771,6 +1784,29 @@ function createProjectContext(opts) {
1771
1784
  return;
1772
1785
  }
1773
1786
 
1787
+ if (msg.type === "cursor_move" || msg.type === "cursor_leave" || msg.type === "text_select") {
1788
+ if (!usersModule.isMultiUser() || !ws._clayUser) return;
1789
+ var u = ws._clayUser;
1790
+ var p = u.profile || {};
1791
+ var cursorMsg = {
1792
+ type: msg.type,
1793
+ userId: u.id,
1794
+ displayName: p.name || u.displayName || u.username,
1795
+ avatarStyle: p.avatarStyle || "thumbs",
1796
+ avatarSeed: p.avatarSeed || u.username,
1797
+ };
1798
+ if (msg.type === "cursor_move") {
1799
+ cursorMsg.turn = msg.turn;
1800
+ if (msg.rx != null) cursorMsg.rx = msg.rx;
1801
+ if (msg.ry != null) cursorMsg.ry = msg.ry;
1802
+ }
1803
+ if (msg.type === "text_select") {
1804
+ cursorMsg.ranges = msg.ranges || [];
1805
+ }
1806
+ sendToSessionOthers(ws, ws._clayActiveSession, cursorMsg);
1807
+ return;
1808
+ }
1809
+
1774
1810
  if (msg.type === "permission_response") {
1775
1811
  var session = getSessionForWs(ws);
1776
1812
  if (!session) return;
@@ -2022,6 +2058,23 @@ function createProjectContext(opts) {
2022
2058
  return;
2023
2059
  }
2024
2060
 
2061
+ // --- Create worktree from web UI ---
2062
+ if (msg.type === "create_worktree") {
2063
+ var wtBranch = (msg.branch || "").trim();
2064
+ var wtBase = (msg.baseBranch || "").trim() || null;
2065
+ if (!wtBranch || !/^[a-zA-Z0-9_\/.@-]+$/.test(wtBranch)) {
2066
+ sendTo(ws, { type: "create_worktree_result", ok: false, error: "Invalid branch name" });
2067
+ return;
2068
+ }
2069
+ if (typeof onCreateWorktree === "function") {
2070
+ var wtResult = onCreateWorktree(slug, wtBranch, wtBase);
2071
+ sendTo(ws, { type: "create_worktree_result", ok: wtResult.ok, slug: wtResult.slug, error: wtResult.error });
2072
+ } else {
2073
+ sendTo(ws, { type: "create_worktree_result", ok: false, error: "Not supported" });
2074
+ }
2075
+ return;
2076
+ }
2077
+
2025
2078
  // --- Pre-check: does the project have tasks/schedules? ---
2026
2079
  if (msg.type === "remove_project_check") {
2027
2080
  var checkSlug = msg.slug;
@@ -3360,6 +3413,29 @@ function createProjectContext(opts) {
3360
3413
  return true;
3361
3414
  }
3362
3415
 
3416
+ // List branches for worktree modal
3417
+ if (req.method === "GET" && urlPath === "/api/branches") {
3418
+ try {
3419
+ var brRaw = execFileSync("git", ["branch", "-a", "--format=%(refname:short)"], {
3420
+ cwd: cwd, timeout: 5000, encoding: "utf8"
3421
+ });
3422
+ var brList = brRaw.trim().split("\n").filter(Boolean);
3423
+ var defBr = "main";
3424
+ try {
3425
+ var hrRef = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], {
3426
+ cwd: cwd, timeout: 3000, encoding: "utf8"
3427
+ }).trim();
3428
+ defBr = hrRef.replace(/^origin\//, "");
3429
+ } catch (e) {}
3430
+ res.writeHead(200, { "Content-Type": "application/json" });
3431
+ res.end(JSON.stringify({ branches: brList, defaultBranch: defBr }));
3432
+ } catch (e) {
3433
+ res.writeHead(200, { "Content-Type": "application/json" });
3434
+ res.end(JSON.stringify({ branches: ["main"], defaultBranch: "main" }));
3435
+ }
3436
+ return true;
3437
+ }
3438
+
3363
3439
  // Info endpoint
3364
3440
  if (req.method === "GET" && urlPath === "/info") {
3365
3441
  res.writeHead(200, {
@@ -3420,6 +3496,12 @@ function createProjectContext(opts) {
3420
3496
  isProcessing: hasProcessing,
3421
3497
  projectOwnerId: projectOwnerId,
3422
3498
  };
3499
+ if (worktreeMeta) {
3500
+ status.isWorktree = true;
3501
+ status.parentSlug = worktreeMeta.parentSlug;
3502
+ status.branch = worktreeMeta.branch;
3503
+ status.worktreeAccessible = worktreeMeta.accessible;
3504
+ }
3423
3505
  if (usersModule.isMultiUser()) {
3424
3506
  var seen = {};
3425
3507
  var onlineUsers = [];