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.
- package/lib/daemon-projects.js +8 -8
- package/lib/daemon.js +42 -10
- package/lib/git-cli.js +1 -0
- package/lib/os-users.js +3 -1
- package/lib/project-log-context.js +24 -23
- package/lib/project-sessions.js +5 -1
- package/lib/public/css/sidebar.css +11 -0
- package/lib/public/modules/app-home-hub.js +19 -7
- package/lib/public/modules/app-projects.js +5 -3
- package/lib/public/modules/sidebar-session-hierarchy.js +5 -6
- package/lib/public/modules/sidebar-sessions.js +1 -3
- package/lib/server-global-ws.js +8 -1
- package/lib/server.js +11 -7
- package/lib/session-hygiene.js +3 -0
- package/lib/users.js +16 -16
- package/lib/worktree.js +25 -38
- package/package.json +1 -1
package/lib/daemon-projects.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
408
|
-
var
|
|
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
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/lib/project-sessions.js
CHANGED
|
@@ -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", !
|
|
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 =
|
|
408
|
+
var slug = getHomeReturnSlug();
|
|
394
409
|
if (!slug || !homeHubVisible) return;
|
|
395
410
|
rememberHomePrimarySurface("project");
|
|
396
|
-
|
|
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 (
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
117
|
-
|
|
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 (
|
|
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 }));
|
package/lib/server-global-ws.js
CHANGED
|
@@ -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
|
-
// -
|
|
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) {
|
package/lib/session-hygiene.js
CHANGED
|
@@ -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
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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 =
|
|
34
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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