clay-server 4.0.0-beta.18 → 4.0.0-beta.19

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.
@@ -29,9 +29,9 @@ function isWorktreeSlug(slug) {
29
29
  * @param {string} parentIcon - icon of parent project
30
30
  * @param {string} parentOwnerId - owner user ID
31
31
  */
32
- function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, projectKnowledgeId) {
33
- if (isWorktree(parentPath)) return;
34
- var worktrees = scanWorktrees(parentPath);
32
+ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, projectKnowledgeId, osUserInfo) {
33
+ if (isWorktree(parentPath, osUserInfo)) return;
34
+ var worktrees = scanWorktrees(parentPath, osUserInfo);
35
35
  if (worktrees.length === 0) return;
36
36
  if (!worktreeRegistry[parentSlug]) worktreeRegistry[parentSlug] = [];
37
37
  for (var i = 0; i < worktrees.length; i++) {
@@ -42,14 +42,14 @@ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, par
42
42
  if (worktreeRegistry[parentSlug][j] === wtSlug) { alreadyRegistered = true; break; }
43
43
  }
44
44
  if (alreadyRegistered) continue;
45
- var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName });
45
+ var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName }, osUserInfo);
46
46
  var wtMeta = Object.assign({ parentSlug: parentSlug, external: wt.external, projectKnowledgeId: projectKnowledgeId }, context);
47
47
  relay.addProject(wt.path, wtSlug, wt.branch || wt.dirName, parentIcon, parentOwnerId, wtMeta);
48
48
  worktreeRegistry[parentSlug].push(wtSlug);
49
49
  console.log("[daemon] Registered worktree:", wtSlug, "->", wt.path, wt.external ? "(external)" : "(inside project folder)");
50
50
  }
51
51
  daemonSync.register(getWorktreeSyncKey(parentSlug), function () {
52
- rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, null, projectKnowledgeId);
52
+ rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, null, projectKnowledgeId, osUserInfo);
53
53
  });
54
54
  }
55
55
 
@@ -62,11 +62,11 @@ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, par
62
62
  * @param {string} parentOwnerId - owner user ID
63
63
  * @param {object} [config] - daemon config (optional, for broadcasting project count)
64
64
  */
65
- function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, config, projectKnowledgeId) {
65
+ function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, config, projectKnowledgeId, osUserInfo) {
66
66
  if (worktreeScanning[parentSlug]) return;
67
67
  worktreeScanning[parentSlug] = true;
68
68
  try {
69
- var discovered = scanWorktrees(parentPath);
69
+ var discovered = scanWorktrees(parentPath, osUserInfo);
70
70
  var changed = false;
71
71
  var existingSlugs = worktreeRegistry[parentSlug] || [];
72
72
  var discoveredNames = {};
@@ -81,7 +81,7 @@ function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerI
81
81
  if (existingSlugs[ei] === wtSlug) { found = true; break; }
82
82
  }
83
83
  if (!found) {
84
- var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName });
84
+ var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName }, osUserInfo);
85
85
  var wtMeta = Object.assign({ parentSlug: parentSlug, external: wt.external, projectKnowledgeId: projectKnowledgeId }, context);
86
86
  relay.addProject(wt.path, wtSlug, wt.branch || wt.dirName, parentIcon, parentOwnerId, wtMeta);
87
87
  if (!worktreeRegistry[parentSlug]) worktreeRegistry[parentSlug] = [];
package/lib/daemon.js CHANGED
@@ -173,6 +173,25 @@ function projectRuntimeOptions(project) {
173
173
  return { projectKnowledgeId: project.projectKnowledgeId };
174
174
  }
175
175
 
176
+ function worktreeOsUserInfo(project, requestUser) {
177
+ if (!config.osUsers) return null;
178
+ var owner = project && project.ownerId ? usersModule.findUserById(project.ownerId) : null;
179
+ var executionUser = owner && owner.linuxUser ? owner : requestUser;
180
+ if (!executionUser || !executionUser.linuxUser) {
181
+ throw new Error("The project owner does not have a provisioned Linux account");
182
+ }
183
+ return osUsersMod.resolveOsUserInfo(executionUser.linuxUser);
184
+ }
185
+
186
+ function registerProjectWorktrees(project, requestUser) {
187
+ try {
188
+ var osUserInfo = worktreeOsUserInfo(project, requestUser);
189
+ scanAndRegisterWorktrees(relay, project.path, project.slug, project.icon, project.ownerId, project.projectKnowledgeId, osUserInfo);
190
+ } catch (e) {
191
+ console.error("[daemon] Failed to scan worktrees for " + project.slug + ": " + e.message);
192
+ }
193
+ }
194
+
176
195
  var relay = createServer({
177
196
  tlsOptions: tlsOptions,
178
197
  caPath: caRoot,
@@ -219,7 +238,7 @@ var relay = createServer({
219
238
  }
220
239
  }
221
240
  // Discover and register worktrees for the new project
222
- scanAndRegisterWorktrees(relay, absPath, slug, null, wsUser && wsUser.id && wsUser.role !== "admin" ? wsUser.id : null, projectEntry.projectKnowledgeId);
241
+ registerProjectWorktrees(projectEntry, wsUser);
223
242
  // Broadcast updated project list to all clients
224
243
  relay.broadcastAll({
225
244
  type: "projects_updated",
@@ -404,8 +423,15 @@ var relay = createServer({
404
423
  }
405
424
  if (parentProject) {
406
425
  var wtPath = path.join(parentProject.path, wtDirName);
407
- var contextStatus = projectLogContext.isMerged(parentProject.path, wtPath) ? "merged" : "archived";
408
- var rmResult = removeWorktree(parentProject.path, wtDirName);
426
+ var removeUser = userId ? usersModule.findUserById(userId) : null;
427
+ var removeOsUserInfo;
428
+ try {
429
+ removeOsUserInfo = worktreeOsUserInfo(parentProject, removeUser);
430
+ } catch (e) {
431
+ return { ok: false, error: e.message };
432
+ }
433
+ var contextStatus = projectLogContext.isMerged(parentProject.path, wtPath, removeOsUserInfo) ? "merged" : "archived";
434
+ var rmResult = removeWorktree(parentProject.path, wtDirName, removeOsUserInfo);
409
435
  if (!rmResult.ok) {
410
436
  console.log("[daemon] Failed to remove worktree:", slug, rmResult.error);
411
437
  return { ok: false, error: rmResult.error };
@@ -1003,23 +1029,29 @@ var relay = createServer({
1003
1029
  if (!config.osUsers || !linuxUser) return;
1004
1030
  deactivateLinuxUser(linuxUser);
1005
1031
  },
1006
- onCreateWorktree: function (parentSlug, branchName, dirName, baseBranch) {
1032
+ onCreateWorktree: function (parentSlug, branchName, dirName, baseBranch, requestUser) {
1007
1033
  // Find the parent project
1008
1034
  var parent = null;
1009
1035
  for (var j = 0; j < config.projects.length; j++) {
1010
1036
  if (config.projects[j].slug === parentSlug) { parent = config.projects[j]; break; }
1011
1037
  }
1012
1038
  if (!parent) return { ok: false, error: "Parent project not found" };
1013
- if (isWorktree(parent.path)) return { ok: false, error: "Cannot create worktrees from a worktree project" };
1014
- var baseCommit = projectLogContext.commit(parent.path, baseBranch || "HEAD");
1015
- var result = createWorktree(parent.path, branchName, dirName, baseBranch);
1039
+ var osUserInfo;
1040
+ try {
1041
+ osUserInfo = worktreeOsUserInfo(parent, requestUser);
1042
+ } catch (e) {
1043
+ return { ok: false, error: e.message };
1044
+ }
1045
+ if (isWorktree(parent.path, osUserInfo)) return { ok: false, error: "Cannot create worktrees from a worktree project" };
1046
+ var baseCommit = projectLogContext.commit(parent.path, baseBranch || "HEAD", osUserInfo);
1047
+ var result = createWorktree(parent.path, branchName, dirName, baseBranch, osUserInfo);
1016
1048
  if (!result.ok) return result;
1017
1049
  // Register the new worktree as ephemeral project
1018
1050
  var wtSlug = parentSlug + "--" + dirName;
1019
1051
  var context = projectLogContext.resolveWorktreeContext(result.path, parent.projectKnowledgeId, {
1020
1052
  branch: branchName,
1021
1053
  baseCommit: baseCommit,
1022
- });
1054
+ }, osUserInfo);
1023
1055
  var wtMeta = Object.assign({ parentSlug: parentSlug, external: false, projectKnowledgeId: parent.projectKnowledgeId }, context);
1024
1056
  relay.addProject(result.path, wtSlug, branchName, parent.icon, parent.ownerId, wtMeta);
1025
1057
  registerWorktreeSlug(parentSlug, wtSlug);
@@ -1114,7 +1146,7 @@ var ipc = createIPCServer(socketPath(), function (msg) {
1114
1146
  try { syncClayrc(config.projects); } catch (e) {}
1115
1147
  console.log("[daemon] Added project:", slug, "→", absPath);
1116
1148
  // Discover and register worktrees for the new project
1117
- scanAndRegisterWorktrees(relay, absPath, slug, null, null, projectEntry.projectKnowledgeId);
1149
+ registerProjectWorktrees(projectEntry, null);
1118
1150
  relay.broadcastAll({
1119
1151
  type: "projects_updated",
1120
1152
  projects: relay.getProjects(),
@@ -1345,7 +1377,7 @@ for (var i = 0; i < projects.length; i++) {
1345
1377
  console.log("[daemon] Adding project:", p.slug, "→", p.path);
1346
1378
  relay.addProject(p.path, p.slug, p.title, p.icon, p.ownerId, null, projectRuntimeOptions(p));
1347
1379
  // Discover and register worktrees for this project
1348
- scanAndRegisterWorktrees(relay, p.path, p.slug, p.icon, p.ownerId, p.projectKnowledgeId);
1380
+ registerProjectWorktrees(p, null);
1349
1381
  } else {
1350
1382
  console.log("[daemon] Skipping missing project:", p.path);
1351
1383
  }
package/lib/git-cli.js CHANGED
@@ -458,6 +458,7 @@ module.exports = {
458
458
  parsePorcelainV2: parsePorcelainV2,
459
459
  parseWorktrees: parseWorktrees,
460
460
  readWorkingTreeFile: readWorkingTreeFile,
461
+ runGitSync: runGitSync,
461
462
  runAction: runAction,
462
463
  shortRemoteLabel: shortRemoteLabel,
463
464
  };
package/lib/os-users.js CHANGED
@@ -161,7 +161,9 @@ function fsAsUser(op, args, osUserInfo) {
161
161
  "var fs = require('fs');",
162
162
  "var f = " + JSON.stringify(args.file) + ";",
163
163
  "var content = " + JSON.stringify(args.content || "") + ";",
164
- "fs.writeFileSync(f, content, 'utf8');",
164
+ "var flag = " + JSON.stringify(args.flag === "wx" ? "wx" : "w") + ";",
165
+ "var mode = " + JSON.stringify(typeof args.mode === "number" ? args.mode : 0o666) + ";",
166
+ "fs.writeFileSync(f, content, { encoding: 'utf8', flag: flag, mode: mode });",
165
167
  "process.stdout.write(JSON.stringify({ ok: true }));",
166
168
  ].join(" ");
167
169
  } else if (op === "mkdir") {
@@ -9,7 +9,8 @@
9
9
  var crypto = require("crypto");
10
10
  var fs = require("fs");
11
11
  var path = require("path");
12
- var execFileSync = require("child_process").execFileSync;
12
+ var { runGitSync } = require("./git-cli");
13
+ var { fsAsUser } = require("./os-users");
13
14
  var logsRoot = require("./project-logs-root");
14
15
  var utils = require("./utils");
15
16
 
@@ -47,23 +48,18 @@ function createProjectKnowledgeId() {
47
48
  return newId("pk");
48
49
  }
49
50
 
50
- function git(cwd, args) {
51
- return execFileSync("git", args, {
52
- cwd: cwd,
53
- encoding: "utf8",
54
- timeout: 5000,
55
- stdio: ["pipe", "pipe", "pipe"],
56
- }).trim();
51
+ function git(cwd, args, osUserInfo) {
52
+ return runGitSync(cwd, args, null, osUserInfo).trim();
57
53
  }
58
54
 
59
- function commit(cwd, ref) {
60
- try { return git(cwd, ["rev-parse", ref || "HEAD"]); }
55
+ function commit(cwd, ref, osUserInfo) {
56
+ try { return git(cwd, ["rev-parse", ref || "HEAD"], osUserInfo); }
61
57
  catch (e) { return null; }
62
58
  }
63
59
 
64
- function markerPath(cwd) {
60
+ function markerPath(cwd, osUserInfo) {
65
61
  try {
66
- var gitDir = git(cwd, ["rev-parse", "--git-dir"]);
62
+ var gitDir = git(cwd, ["rev-parse", "--git-dir"], osUserInfo);
67
63
  return path.join(path.resolve(cwd, gitDir), "clay-change-set.json");
68
64
  } catch (e) {
69
65
  return null;
@@ -86,9 +82,18 @@ function deterministicFallback(knowledgeId, cwd) {
86
82
  return "cs_" + digest.substring(0, 22);
87
83
  }
88
84
 
89
- function resolveWorktreeContext(cwd, knowledgeId, details) {
85
+ function writeMarker(filePath, marker, osUserInfo) {
86
+ var content = JSON.stringify(marker, null, 2) + "\n";
87
+ if (osUserInfo) {
88
+ fsAsUser("write", { file: filePath, content: content, flag: "wx", mode: 0o600 }, osUserInfo);
89
+ return;
90
+ }
91
+ fs.writeFileSync(filePath, content, { flag: "wx", mode: 0o600 });
92
+ }
93
+
94
+ function resolveWorktreeContext(cwd, knowledgeId, details, osUserInfo) {
90
95
  var input = details || {};
91
- var filePath = markerPath(cwd);
96
+ var filePath = markerPath(cwd, osUserInfo);
92
97
  var marker = readMarker(filePath, knowledgeId);
93
98
  if (!marker) {
94
99
  marker = {
@@ -99,7 +104,7 @@ function resolveWorktreeContext(cwd, knowledgeId, details) {
99
104
  };
100
105
  if (filePath) {
101
106
  try {
102
- fs.writeFileSync(filePath, JSON.stringify(marker, null, 2) + "\n", { flag: "wx", mode: 0o600 });
107
+ writeMarker(filePath, marker, osUserInfo);
103
108
  } catch (e) {
104
109
  marker = readMarker(filePath, knowledgeId) || marker;
105
110
  }
@@ -113,20 +118,16 @@ function resolveWorktreeContext(cwd, knowledgeId, details) {
113
118
  changeSetId: marker.changeSetId,
114
119
  branch: input.branch || null,
115
120
  baseCommit: marker.baseCommit || input.baseCommit || null,
116
- headCommit: commit(cwd, "HEAD"),
121
+ headCommit: commit(cwd, "HEAD", osUserInfo),
117
122
  status: "active",
118
123
  };
119
124
  }
120
125
 
121
- function isMerged(parentCwd, worktreeCwd) {
122
- var head = commit(worktreeCwd, "HEAD");
126
+ function isMerged(parentCwd, worktreeCwd, osUserInfo) {
127
+ var head = commit(worktreeCwd, "HEAD", osUserInfo);
123
128
  if (!head) return false;
124
129
  try {
125
- execFileSync("git", ["merge-base", "--is-ancestor", head, "HEAD"], {
126
- cwd: parentCwd,
127
- timeout: 5000,
128
- stdio: ["pipe", "pipe", "pipe"],
129
- });
130
+ runGitSync(parentCwd, ["merge-base", "--is-ancestor", head, "HEAD"], null, osUserInfo);
130
131
  return true;
131
132
  } catch (e) {
132
133
  return false;
@@ -1777,8 +1777,12 @@ function attachSessions(ctx) {
1777
1777
  sendTo(ws, { type: "create_worktree_result", ok: false, error: "Invalid branch name" });
1778
1778
  return true;
1779
1779
  }
1780
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(wtDirName)) {
1781
+ sendTo(ws, { type: "create_worktree_result", ok: false, error: "Invalid worktree directory name" });
1782
+ return true;
1783
+ }
1780
1784
  if (typeof onCreateWorktree === "function") {
1781
- var wtResult = onCreateWorktree(slug, wtBranch, wtDirName, wtBase);
1785
+ var wtResult = onCreateWorktree(slug, wtBranch, wtDirName, wtBase, ws._clayUser);
1782
1786
  sendTo(ws, { type: "create_worktree_result", ok: wtResult.ok, slug: wtResult.slug, error: wtResult.error });
1783
1787
  } else {
1784
1788
  sendTo(ws, { type: "create_worktree_result", ok: false, error: "Not supported" });
@@ -930,6 +930,17 @@
930
930
  position: relative;
931
931
  }
932
932
 
933
+ .session-driver-header {
934
+ display: flex;
935
+ align-items: center;
936
+ }
937
+
938
+ .session-driver-header > .session-item {
939
+ min-width: 0;
940
+ flex: 1;
941
+ padding-left: 0;
942
+ }
943
+
933
944
  .session-driver-toggle {
934
945
  width: 18px;
935
946
  height: 24px;
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { store } from './store.js';
4
4
  import { getWs } from './ws-ref.js';
5
- import { getCachedProjects } from './app-projects.js';
5
+ import { getCachedProjects, switchProject } from './app-projects.js';
6
6
  import { mateAvatarUrl } from './avatar.js';
7
7
  import { exitDmMode } from './app-dm.js';
8
8
  import { closeHomeDock, initHomeDock, renderDock, requestHomeDockPreference } from './home-dock.js';
@@ -41,7 +41,22 @@ function syncHomePresentation() {
41
41
  function syncHomeCloseControl() {
42
42
  var close = document.getElementById("home-close-control");
43
43
  if (!close) return;
44
- close.classList.toggle("hidden", !store.get('currentSlug'));
44
+ close.classList.toggle("hidden", !getHomeReturnSlug());
45
+ }
46
+
47
+ function getHomeReturnSlug() {
48
+ var projects = getCachedProjects() || [];
49
+ var candidates = [store.get('currentSlug'), store.get('homeSurfaceProjectSlug')];
50
+ for (var ci = 0; ci < candidates.length; ci++) {
51
+ if (!candidates[ci]) continue;
52
+ for (var pi = 0; pi < projects.length; pi++) {
53
+ if (projects[pi] && !projects[pi].isMate && projects[pi].slug === candidates[ci]) return candidates[ci];
54
+ }
55
+ }
56
+ for (var i = 0; i < projects.length; i++) {
57
+ if (projects[i] && !projects[i].isMate && projects[i].slug) return projects[i].slug;
58
+ }
59
+ return null;
45
60
  }
46
61
 
47
62
  function getVisibleMates() {
@@ -390,13 +405,10 @@ export function hideHomeHub() {
390
405
  }
391
406
 
392
407
  export function minimizeHomeHub() {
393
- var slug = store.get('currentSlug');
408
+ var slug = getHomeReturnSlug();
394
409
  if (!slug || !homeHubVisible) return;
395
410
  rememberHomePrimarySurface("project");
396
- hideHomeHub();
397
- var route = "/p/" + slug + "/";
398
- if (document.documentElement.classList.contains("pwa-standalone")) history.replaceState(null, "", route);
399
- else history.pushState(null, "", route);
411
+ switchProject(slug);
400
412
  var projectInput = document.getElementById("input");
401
413
  if (projectInput && !projectInput.disabled) projectInput.focus({ preventScroll: true });
402
414
  }
@@ -417,13 +417,15 @@ export function switchProject(slug) {
417
417
  if (!slug) return;
418
418
  document.cookie = "clay_last_project=" + encodeURIComponent(slug) + "; Path=/; SameSite=Strict; Max-Age=31536000";
419
419
  var st = store.snap();
420
+ var targetWsPath = "/p/" + slug + "/ws";
421
+ var alreadyInProject = slug === st.currentSlug && st.wsPath === targetWsPath;
420
422
  var wasDm = st.dmMode;
421
423
  var wasMate = st.dmMode && st.dmTargetUser && st.dmTargetUser.isMate;
422
424
  if (st.dmMode) exitDmMode(wasMate);
423
425
  closeWhatsNewArticle();
424
426
  if (isHomeHubVisible()) {
425
427
  hideHomeHub();
426
- if (slug === store.get('currentSlug')) {
428
+ if (alreadyInProject) {
427
429
  if (document.documentElement.classList.contains("pwa-standalone")) {
428
430
  history.replaceState(null, "", "/p/" + slug + "/");
429
431
  } else {
@@ -432,7 +434,7 @@ export function switchProject(slug) {
432
434
  return;
433
435
  }
434
436
  }
435
- if (slug === store.get('currentSlug')) {
437
+ if (alreadyInProject) {
436
438
  var ws = getWs();
437
439
  if (wasDm && ws && ws.readyState === 1) {
438
440
  ws.send(JSON.stringify({ type: "switch_session", id: store.get('activeSessionId') }));
@@ -446,7 +448,7 @@ export function switchProject(slug) {
446
448
  resetScheduler(slug);
447
449
  store.set({ currentSlug: slug });
448
450
  store.set({ basePath: "/p/" + slug + "/" });
449
- store.set({ wsPath: "/p/" + slug + "/ws" });
451
+ store.set({ wsPath: targetWsPath });
450
452
  if (document.documentElement.classList.contains("pwa-standalone")) {
451
453
  history.replaceState(null, "", "/p/" + slug + "/");
452
454
  } else {
@@ -55,10 +55,6 @@ export function hierarchyItemMatches(item, matchIds) {
55
55
  return true;
56
56
  }
57
57
 
58
- export function isDesktopHierarchyToggleEvent(event) {
59
- return !!(event && event.target && typeof event.target.closest === "function" && event.target.closest(".session-driver-toggle"));
60
- }
61
-
62
58
  function expanded(surface, key, workers, matchIds) {
63
59
  var state = expansionBySurface[surface];
64
60
  if (state.has(key)) return state.get(key);
@@ -88,6 +84,8 @@ export function renderDesktopDriverHierarchy(root, renderSession, rerender, matc
88
84
  var key = "driver:" + root.driver.id;
89
85
  var isExpanded = expanded("desktop", key, root.workers, matchIds);
90
86
  var childrenId = "session-workers-" + root.driver.id;
87
+ var header = document.createElement("div");
88
+ header.className = "session-driver-header";
91
89
  var row = renderSession(root.driver);
92
90
  row.classList.add("session-driver-item");
93
91
  var control = document.createElement("button");
@@ -113,8 +111,9 @@ export function renderDesktopDriverHierarchy(root, renderSession, rerender, matc
113
111
  children.hidden = !nextExpanded;
114
112
  isExpanded = nextExpanded;
115
113
  });
116
- row.insertBefore(control, row.firstChild);
117
- wrapper.appendChild(row);
114
+ header.appendChild(control);
115
+ header.appendChild(row);
116
+ wrapper.appendChild(header);
118
117
 
119
118
  var current = currentWorkerIds();
120
119
  for (var i = 0; i < root.workers.length; i++) {
@@ -18,7 +18,6 @@ import { groupedSessionIds } from './split-group-helpers.js';
18
18
  import { openPairDialog } from './split-pair-ui.js';
19
19
  import {
20
20
  hierarchyItemMatches,
21
- isDesktopHierarchyToggleEvent,
22
21
  prepareSidebarHierarchy,
23
22
  renderDesktopDriverHierarchy,
24
23
  renderDesktopOrphanHierarchy
@@ -1334,8 +1333,7 @@ function renderSessionItem(s, options) {
1334
1333
  appendSessionCloseButton(el, s);
1335
1334
 
1336
1335
  el.addEventListener("click", (function (id) {
1337
- return function (event) {
1338
- if (isDesktopHierarchyToggleEvent(event)) return;
1336
+ return function () {
1339
1337
  if (getWs() && store.get('connected')) {
1340
1338
  var pendingQuery = searchQuery || "";
1341
1339
  getWs().send(JSON.stringify({ type: "switch_session", id: id }));
@@ -7,7 +7,7 @@
7
7
  // Bound to the slug-less `/ws` endpoint by lib/server.js. Only handles the
8
8
  // small set of messages needed to bootstrap into a project context:
9
9
  // - ping -> pong keep-alive
10
- // - home_* -> user-scoped Home preferences
10
+ // - Home and Mate -> the same user-scoped handlers as project sockets
11
11
  // - browse_dir -> directory picker for the add-project modal
12
12
  // - add_project -> register an existing directory
13
13
  // - create_project -> make a new empty project
@@ -39,6 +39,7 @@ function attachGlobalWs(opts) {
39
39
  var onCreateProject = opts.onCreateProject;
40
40
  var onCloneProject = opts.onCloneProject;
41
41
  var onHomePreferenceMessage = opts.onHomePreferenceMessage;
42
+ var onAppMessage = opts.onAppMessage;
42
43
 
43
44
  function handleMessage(ws, msg) {
44
45
  if (!msg || typeof msg !== "object") return;
@@ -48,6 +49,12 @@ function attachGlobalWs(opts) {
48
49
  return;
49
50
  }
50
51
 
52
+ // A user does not need an ordinary project before Home becomes useful.
53
+ // Route Mate discovery, Home chat/debates, and tools through the same
54
+ // handlers used by project sockets. This also lets mate_list repair an
55
+ // existing account whose built-ins were missing from its final storage.
56
+ if (typeof onAppMessage === "function" && onAppMessage(ws, msg)) return;
57
+
51
58
  if (typeof onHomePreferenceMessage === "function" && onHomePreferenceMessage(ws, msg)) return;
52
59
 
53
60
  // --- Directory picker for the add-project modal ---
package/lib/server.js CHANGED
@@ -1011,6 +1011,9 @@ function createServer(opts) {
1011
1011
  onHomePreferenceMessage: function (ws, msg) {
1012
1012
  return homePreferencesHandler ? homePreferencesHandler.handleMessage(ws, msg) : false;
1013
1013
  },
1014
+ onAppMessage: function (ws, msg) {
1015
+ return handleDmMessage(ws, msg);
1016
+ },
1014
1017
  });
1015
1018
 
1016
1019
  server.on("upgrade", function (req, socket, head) {
@@ -1484,13 +1487,14 @@ function createServer(opts) {
1484
1487
  }
1485
1488
 
1486
1489
  function handleDmMessage(ws, msg, routedProjectSlug) {
1487
- if (assignmentService.handleMessage(ws, msg, routedProjectSlug)) return;
1488
- if (dmHandler.handleMessage(ws, msg)) return;
1489
- if (mateHandler && mateHandler.handleMessage(ws, msg)) return;
1490
- if (emailHandler.handleMessage(ws, msg)) return;
1491
- if (homeChatHandler.handleMessage(ws, msg)) return;
1492
- if (homePreferencesHandler.handleMessage(ws, msg)) return;
1493
- if (toolsHandler.handleMessage(ws, msg)) return;
1490
+ if (assignmentService.handleMessage(ws, msg, routedProjectSlug)) return true;
1491
+ if (dmHandler.handleMessage(ws, msg)) return true;
1492
+ if (mateHandler && mateHandler.handleMessage(ws, msg)) return true;
1493
+ if (emailHandler.handleMessage(ws, msg)) return true;
1494
+ if (homeChatHandler.handleMessage(ws, msg)) return true;
1495
+ if (homePreferencesHandler.handleMessage(ws, msg)) return true;
1496
+ if (toolsHandler.handleMessage(ws, msg)) return true;
1497
+ return false;
1494
1498
  }
1495
1499
 
1496
1500
  function removeProject(slug) {
@@ -2,6 +2,8 @@
2
2
  // "New session" request and which abandoned blanks are safe to sweep.
3
3
  // Pure decision logic only -- deletion/switching stays in sessions.js.
4
4
 
5
+ var sessionProvenance = require("./session-provenance");
6
+
5
7
  var BLANK_SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
6
8
  var CLAUDE_INTERRUPTED_TEXT = "[Request interrupted by user]";
7
9
 
@@ -19,6 +21,7 @@ function isBlankSession(s) {
19
21
  && !s.bookmarked
20
22
  && !s.spawn
21
23
  && !s.loop
24
+ && !sessionProvenance.isWorker(s)
22
25
  && (s.mode || "gui") !== "tui"
23
26
  && s.terminalId == null;
24
27
  }
package/lib/users.js CHANGED
@@ -55,6 +55,16 @@ function generateUserId() {
55
55
  return crypto.randomUUID();
56
56
  }
57
57
 
58
+ function ensureUserBuiltinMates(userId) {
59
+ try {
60
+ var mates = require("./mates");
61
+ var mateCtx = mates.buildMateCtx(userId);
62
+ mates.ensureBuiltinMates(mateCtx);
63
+ } catch (e) {
64
+ console.error("[users] Failed to seed built-in mates for user " + userId + ":", e.message);
65
+ }
66
+ }
67
+
58
68
  function createUser(opts) {
59
69
  var data = loadUsers();
60
70
  // Check username uniqueness
@@ -92,14 +102,9 @@ function createUser(opts) {
92
102
  data.users.push(user);
93
103
  saveUsers(data);
94
104
 
95
- // Seed built-in mates for the new user
96
- try {
97
- var mates = require("./mates");
98
- var mateCtx = mates.buildMateCtx(user.id);
99
- mates.ensureBuiltinMates(mateCtx);
100
- } catch (e) {
101
- console.error("[users] Failed to seed built-in mates for user " + user.id + ":", e.message);
102
- }
105
+ // Seed immediately for ordinary multi-user storage. OS-user provisioning
106
+ // calls this again after the Linux mapping exists, targeting its final home.
107
+ ensureUserBuiltinMates(user.id);
103
108
 
104
109
  return { ok: true, user: user };
105
110
  }
@@ -246,6 +251,7 @@ function updateLinuxUser(userId, linuxUsername) {
246
251
  if (data.users[i].id === userId) {
247
252
  data.users[i].linuxUser = null;
248
253
  saveUsers(data);
254
+ ensureUserBuiltinMates(userId);
249
255
  return { ok: true };
250
256
  }
251
257
  }
@@ -269,6 +275,7 @@ function updateLinuxUser(userId, linuxUsername) {
269
275
  if (data.users[i].id === userId) {
270
276
  data.users[i].linuxUser = linuxUsername;
271
277
  saveUsers(data);
278
+ ensureUserBuiltinMates(userId);
272
279
  return { ok: true };
273
280
  }
274
281
  }
@@ -326,14 +333,7 @@ function createUserWithoutPin(opts) {
326
333
  data.users.push(user);
327
334
  saveUsers(data);
328
335
 
329
- // Seed built-in mates for the new user
330
- try {
331
- var mates = require("./mates");
332
- var mateCtx = mates.buildMateCtx(user.id);
333
- mates.ensureBuiltinMates(mateCtx);
334
- } catch (e) {
335
- console.error("[users] Failed to seed built-in mates for user " + user.id + ":", e.message);
336
- }
336
+ ensureUserBuiltinMates(user.id);
337
337
 
338
338
  return { ok: true, user: user };
339
339
  }
package/lib/worktree.js CHANGED
@@ -1,6 +1,6 @@
1
- var { execFileSync } = require("child_process");
2
1
  var fs = require("fs");
3
2
  var path = require("path");
3
+ var { runGitSync } = require("./git-cli");
4
4
 
5
5
  // Parse `git worktree list --porcelain` output into structured objects
6
6
  function parseWorktreeOutput(output) {
@@ -28,14 +28,10 @@ function parseWorktreeOutput(output) {
28
28
  }
29
29
 
30
30
  // Check if a given path is itself a worktree (not the main working tree)
31
- function isWorktree(projectPath) {
31
+ function isWorktree(projectPath, osUserInfo) {
32
32
  try {
33
- var gitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
34
- cwd: projectPath, encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
35
- }).trim();
36
- var commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
37
- cwd: projectPath, encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
38
- }).trim();
33
+ var gitDir = runGitSync(projectPath, ["rev-parse", "--git-dir"], null, osUserInfo).trim();
34
+ var commonDir = runGitSync(projectPath, ["rev-parse", "--git-common-dir"], null, osUserInfo).trim();
39
35
  var absGit = path.resolve(projectPath, gitDir);
40
36
  var absCommon = path.resolve(projectPath, commonDir);
41
37
  return absGit !== absCommon;
@@ -49,18 +45,21 @@ function isPathInside(parentPath, candidatePath) {
49
45
  return relative !== "" && relative !== ".." && relative.indexOf(".." + path.sep) !== 0 && !path.isAbsolute(relative);
50
46
  }
51
47
 
48
+ function worktreePath(projectPath, dirName) {
49
+ if (typeof dirName !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(dirName)) return null;
50
+ var resolvedParent = path.resolve(projectPath);
51
+ var resolvedWorktree = path.resolve(resolvedParent, dirName);
52
+ return isPathInside(resolvedParent, resolvedWorktree) ? resolvedWorktree : null;
53
+ }
54
+
52
55
  // Scan worktrees for a given project path
53
56
  // Returns array of { path, branch, bare, detached, external }
54
57
  // external = true when Git registered the worktree outside the main project folder
55
- function scanWorktrees(projectPath) {
58
+ function scanWorktrees(projectPath, osUserInfo) {
56
59
  var resolvedParent = path.resolve(projectPath);
60
+ try { resolvedParent = fs.realpathSync(resolvedParent); } catch (e) {}
57
61
  try {
58
- var output = execFileSync("git", ["worktree", "list", "--porcelain"], {
59
- cwd: resolvedParent,
60
- encoding: "utf8",
61
- timeout: 5000,
62
- stdio: ["pipe", "pipe", "pipe"],
63
- });
62
+ var output = runGitSync(resolvedParent, ["worktree", "list", "--porcelain"], null, osUserInfo);
64
63
  var all = parseWorktreeOutput(output);
65
64
  // Filter out bare worktrees and the main worktree itself
66
65
  var results = [];
@@ -68,10 +67,11 @@ function scanWorktrees(projectPath) {
68
67
  var wt = all[i];
69
68
  if (wt.bare) continue;
70
69
  var resolvedWt = path.resolve(wt.path);
71
- if (resolvedWt === resolvedParent) continue;
72
70
  // Git retains removed worktrees until `git worktree prune` runs. Do not
73
71
  // register those stale paths as Clay projects.
74
72
  if (!fs.existsSync(resolvedWt)) continue;
73
+ try { resolvedWt = fs.realpathSync(resolvedWt); } catch (e) {}
74
+ if (resolvedWt === resolvedParent) continue;
75
75
  wt.external = !isPathInside(resolvedParent, resolvedWt);
76
76
  wt.dirName = path.basename(wt.path);
77
77
  results.push(wt);
@@ -84,28 +84,19 @@ function scanWorktrees(projectPath) {
84
84
 
85
85
  // Create a new worktree inside the parent project directory
86
86
  // Returns { ok, path, error }
87
- function createWorktree(projectPath, branchName, dirName, baseBranch) {
87
+ function createWorktree(projectPath, branchName, dirName, baseBranch, osUserInfo) {
88
88
  var resolvedParent = path.resolve(projectPath);
89
- var wtPath = path.join(resolvedParent, dirName || branchName);
89
+ var wtPath = worktreePath(resolvedParent, dirName || branchName);
90
+ if (!wtPath) return { ok: false, error: "Invalid worktree directory name" };
90
91
  var base = baseBranch || "main";
91
92
  // Try creating with -b (new branch)
92
93
  try {
93
- execFileSync("git", ["worktree", "add", wtPath, "-b", branchName, base], {
94
- cwd: resolvedParent,
95
- encoding: "utf8",
96
- timeout: 15000,
97
- stdio: ["pipe", "pipe", "pipe"],
98
- });
94
+ runGitSync(resolvedParent, ["worktree", "add", wtPath, "-b", branchName, base], { timeout: 15000 }, osUserInfo);
99
95
  return { ok: true, path: wtPath };
100
96
  } catch (e) {
101
97
  // Branch may already exist, try without -b
102
98
  try {
103
- execFileSync("git", ["worktree", "add", wtPath, branchName], {
104
- cwd: resolvedParent,
105
- encoding: "utf8",
106
- timeout: 15000,
107
- stdio: ["pipe", "pipe", "pipe"],
108
- });
99
+ runGitSync(resolvedParent, ["worktree", "add", wtPath, branchName], { timeout: 15000 }, osUserInfo);
109
100
  return { ok: true, path: wtPath };
110
101
  } catch (e2) {
111
102
  return { ok: false, error: e2.message || "Failed to create worktree" };
@@ -115,17 +106,13 @@ function createWorktree(projectPath, branchName, dirName, baseBranch) {
115
106
 
116
107
  // Remove a worktree
117
108
  // Returns { ok, error }
118
- function removeWorktree(projectPath, worktreeDirName) {
109
+ function removeWorktree(projectPath, worktreeDirName, osUserInfo) {
119
110
  var resolvedParent = path.resolve(projectPath);
120
- var wtPath = path.join(resolvedParent, worktreeDirName);
111
+ var wtPath = worktreePath(resolvedParent, worktreeDirName);
112
+ if (!wtPath) return { ok: false, error: "Invalid worktree directory name" };
121
113
  // Try normal remove first
122
114
  try {
123
- execFileSync("git", ["worktree", "remove", wtPath], {
124
- cwd: resolvedParent,
125
- encoding: "utf8",
126
- timeout: 15000,
127
- stdio: ["pipe", "pipe", "pipe"],
128
- });
115
+ runGitSync(resolvedParent, ["worktree", "remove", wtPath], { timeout: 15000 }, osUserInfo);
129
116
  return { ok: true };
130
117
  } catch (e) {
131
118
  var errMsg = (e.stderr || e.message || "").toString();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.0.0-beta.18",
3
+ "version": "4.0.0-beta.19",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",