clay-server 2.40.1-beta.1 → 2.41.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.
@@ -0,0 +1,189 @@
1
+ // --- Global WebSocket handler (no project context) ---
2
+ //
3
+ // Lets a logged-in user with no projects yet (or no accessible projects)
4
+ // load the regular app shell and create / add / clone their first project,
5
+ // instead of being trapped on a static "no projects" page.
6
+ //
7
+ // Bound to the slug-less `/ws` endpoint by lib/server.js. Only handles the
8
+ // small set of messages needed to bootstrap into a project context:
9
+ // - ping -> pong keep-alive
10
+ // - browse_dir -> directory picker for the add-project modal
11
+ // - add_project -> register an existing directory
12
+ // - create_project -> make a new empty project
13
+ // - clone_project -> clone a git repo into a new project
14
+ //
15
+ // All four mirror the same handlers in lib/project-sessions.js, but with
16
+ // the per-project `ctx` dropped since none of them need it.
17
+
18
+ var fs = require("fs");
19
+ var path = require("path");
20
+ var config = require("./config");
21
+
22
+ // Mirrored from lib/project.js — keep in sync if entries change there.
23
+ var IGNORED_DIRS = new Set([
24
+ "node_modules", ".git", ".next", "__pycache__",
25
+ ".cache", "dist", "build", ".clay", ".claude-relay",
26
+ ]);
27
+
28
+ function sendTo(ws, msg) {
29
+ if (ws && ws.readyState === 1) {
30
+ try { ws.send(JSON.stringify(msg)); } catch (e) {}
31
+ }
32
+ }
33
+
34
+ function attachGlobalWs(opts) {
35
+ var osUsers = opts.osUsers || false;
36
+ var usersModule = opts.usersModule;
37
+ var onAddProject = opts.onAddProject;
38
+ var onCreateProject = opts.onCreateProject;
39
+ var onCloneProject = opts.onCloneProject;
40
+
41
+ function handleMessage(ws, msg) {
42
+ if (!msg || typeof msg !== "object") return;
43
+
44
+ if (msg.type === "ping") {
45
+ sendTo(ws, { type: "pong" });
46
+ return;
47
+ }
48
+
49
+ // --- Directory picker for the add-project modal ---
50
+ if (msg.type === "browse_dir") {
51
+ var rawPath = (msg.path || "").replace(/^~/, config.REAL_HOME);
52
+ var absTarget = path.resolve(rawPath);
53
+ // Multi-user mode: non-admins can only browse their home directory.
54
+ if (osUsers && ws._clayUser && ws._clayUser.role !== "admin") {
55
+ var browseHome = ws._clayUser.linuxUser ? "/home/" + ws._clayUser.linuxUser : null;
56
+ if (!browseHome || (absTarget !== browseHome && (absTarget + "/").indexOf(browseHome + "/") !== 0)) {
57
+ sendTo(ws, { type: "browse_dir_result", path: msg.path, entries: [], error: "Access restricted to your home directory" });
58
+ return;
59
+ }
60
+ }
61
+ var parentDir, prefix;
62
+ try {
63
+ var stat = fs.statSync(absTarget);
64
+ if (stat.isDirectory()) {
65
+ // Existing directory -- list its children.
66
+ parentDir = absTarget;
67
+ prefix = "";
68
+ } else {
69
+ parentDir = path.dirname(absTarget);
70
+ prefix = path.basename(absTarget).toLowerCase();
71
+ }
72
+ } catch (e) {
73
+ // Doesn't exist -- list parent and filter by typed prefix.
74
+ parentDir = path.dirname(absTarget);
75
+ prefix = path.basename(absTarget).toLowerCase();
76
+ }
77
+ try {
78
+ var dirItems = fs.readdirSync(parentDir, { withFileTypes: true });
79
+ var dirEntries = [];
80
+ for (var di = 0; di < dirItems.length; di++) {
81
+ var d = dirItems[di];
82
+ if (!d.isDirectory()) continue;
83
+ if (d.name.charAt(0) === ".") continue;
84
+ if (IGNORED_DIRS.has(d.name)) continue;
85
+ if (prefix && !d.name.toLowerCase().startsWith(prefix)) continue;
86
+ dirEntries.push({ name: d.name, path: path.join(parentDir, d.name) });
87
+ }
88
+ dirEntries.sort(function (a, b) { return a.name.localeCompare(b.name); });
89
+ sendTo(ws, { type: "browse_dir_result", path: msg.path, entries: dirEntries });
90
+ } catch (e) {
91
+ sendTo(ws, { type: "browse_dir_result", path: msg.path, entries: [], error: e.message });
92
+ }
93
+ return;
94
+ }
95
+
96
+ // --- Register an existing directory as a project ---
97
+ if (msg.type === "add_project") {
98
+ var addPath = (msg.path || "").replace(/^~/, config.REAL_HOME);
99
+ var addAbs = path.resolve(addPath);
100
+ if (osUsers && ws._clayUser && ws._clayUser.role !== "admin") {
101
+ if (!ws._clayUser.linuxUser) {
102
+ sendTo(ws, { type: "add_project_result", ok: false, error: "No Linux user assigned" });
103
+ return;
104
+ }
105
+ var userHome = "/home/" + ws._clayUser.linuxUser;
106
+ if (addAbs !== userHome && (addAbs + "/").indexOf(userHome + "/") !== 0) {
107
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Path not allowed. You can only add directories under " + userHome });
108
+ return;
109
+ }
110
+ }
111
+ try {
112
+ var addStat = fs.statSync(addAbs);
113
+ if (!addStat.isDirectory()) {
114
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Not a directory" });
115
+ return;
116
+ }
117
+ } catch (e) {
118
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Directory not found" });
119
+ return;
120
+ }
121
+ if (typeof onAddProject === "function") {
122
+ var result = onAddProject(addAbs, ws._clayUser);
123
+ sendTo(ws, { type: "add_project_result", ok: result.ok, slug: result.slug, error: result.error, existing: result.existing });
124
+ } else {
125
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Not supported" });
126
+ }
127
+ return;
128
+ }
129
+
130
+ // Permission gate shared by create_project and clone_project.
131
+ if (msg.type === "create_project" || msg.type === "clone_project") {
132
+ if (ws._clayUser && usersModule) {
133
+ var cpPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
134
+ if (!cpPerms.createProject) {
135
+ sendTo(ws, { type: "add_project_result", ok: false, error: "You do not have permission to create projects" });
136
+ return;
137
+ }
138
+ }
139
+ }
140
+
141
+ if (msg.type === "create_project") {
142
+ var createName = (msg.name || "").trim();
143
+ if (!createName || !/^[a-zA-Z0-9_-]+$/.test(createName)) {
144
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Invalid name. Use only letters, numbers, dashes, and underscores." });
145
+ return;
146
+ }
147
+ if (typeof onCreateProject === "function") {
148
+ var createResult = onCreateProject(createName, ws._clayUser);
149
+ sendTo(ws, { type: "add_project_result", ok: createResult.ok, slug: createResult.slug, error: createResult.error });
150
+ } else {
151
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Not supported" });
152
+ }
153
+ return;
154
+ }
155
+
156
+ if (msg.type === "clone_project") {
157
+ var cloneUrl = (msg.url || "").trim();
158
+ if (!cloneUrl || (!/^https?:\/\//.test(cloneUrl) && !/^git@/.test(cloneUrl))) {
159
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Invalid URL. Use https:// or git@ format." });
160
+ return;
161
+ }
162
+ sendTo(ws, { type: "clone_project_progress", status: "cloning" });
163
+ if (typeof onCloneProject === "function") {
164
+ onCloneProject(cloneUrl, ws._clayUser, function (cloneResult) {
165
+ sendTo(ws, { type: "add_project_result", ok: cloneResult.ok, slug: cloneResult.slug, error: cloneResult.error });
166
+ });
167
+ } else {
168
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Not supported" });
169
+ }
170
+ return;
171
+ }
172
+ }
173
+
174
+ function handleConnection(ws, wsUser) {
175
+ ws._clayUser = wsUser || null;
176
+ ws.on("message", function (data) {
177
+ var msg;
178
+ try { msg = JSON.parse(data); } catch (e) { return; }
179
+ handleMessage(ws, msg);
180
+ });
181
+ }
182
+
183
+ return {
184
+ handleConnection: handleConnection,
185
+ handleMessage: handleMessage,
186
+ };
187
+ }
188
+
189
+ module.exports = { attachGlobalWs: attachGlobalWs };
@@ -56,10 +56,10 @@ function attachSettings(ctx) {
56
56
  if (dc.terminalFont && typeof dc.terminalFont === "object") {
57
57
  profile.terminalFont = {
58
58
  family: dc.terminalFont.family || "'SF Mono', Menlo, Monaco, 'Courier New', monospace",
59
- size: typeof dc.terminalFont.size === "number" ? dc.terminalFont.size : 13,
59
+ size: typeof dc.terminalFont.size === "number" ? dc.terminalFont.size : 14,
60
60
  };
61
61
  } else {
62
- profile.terminalFont = { family: "'SF Mono', Menlo, Monaco, 'Courier New', monospace", size: 13 };
62
+ profile.terminalFont = { family: "'SF Mono', Menlo, Monaco, 'Courier New', monospace", size: 14 };
63
63
  }
64
64
  }
65
65
  // Check if custom avatar file exists
package/lib/server.js CHANGED
@@ -18,6 +18,7 @@ var serverAdmin = require("./server-admin");
18
18
  var serverSettings = require("./server-settings");
19
19
  var serverPalette = require("./server-palette");
20
20
  var serverEmail = require("./server-email");
21
+ var serverGlobalWs = require("./server-global-ws");
21
22
 
22
23
  var { CONFIG_DIR } = require("./config");
23
24
  var { provisionLinuxUser } = require("./os-users");
@@ -684,14 +685,23 @@ function createServer(opts) {
684
685
  return;
685
686
  }
686
687
  }
687
- // No accessible projects show info page
688
+ // No accessible projects. Users who can create one fall through to
689
+ // the regular app shell (index.html) — the slug-less /ws and the
690
+ // sidebar's + button take over from there. The static "ask an admin"
691
+ // page is only for multi-user users who genuinely can't do anything
692
+ // (no createProject permission and no projects shared with them).
688
693
  if (users.isMultiUser()) {
689
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
690
- res.end(pages.noProjectsPageHtml());
691
- return;
694
+ var rootUser = getMultiUserFromReq(req);
695
+ var rootPerms = rootUser ? users.getEffectivePermissions(rootUser, osUsers) : null;
696
+ if (!rootPerms || !rootPerms.createProject) {
697
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
698
+ res.end(pages.noProjectsPageHtml());
699
+ return;
700
+ }
692
701
  }
693
- res.writeHead(200, { "Content-Type": "text/plain" });
694
- res.end("No projects registered.");
702
+ if (serveStatic("/index.html", res)) return;
703
+ res.writeHead(500, { "Content-Type": "text/plain" });
704
+ res.end("App shell missing.");
695
705
  return;
696
706
  }
697
707
 
@@ -868,6 +878,18 @@ function createServer(opts) {
868
878
  // --- WebSocket ---
869
879
  var wss = new WebSocketServer({ noServer: true });
870
880
 
881
+ // Slug-less /ws handler: lets a user with no projects yet load the regular
882
+ // app shell and create/add/clone their first one. Only knows about the
883
+ // small set of bootstrap messages (browse_dir, add_project, create_project,
884
+ // clone_project, ping).
885
+ var globalWs = serverGlobalWs.attachGlobalWs({
886
+ osUsers: osUsers,
887
+ usersModule: users,
888
+ onAddProject: onAddProject,
889
+ onCreateProject: onCreateProject,
890
+ onCloneProject: onCloneProject,
891
+ });
892
+
871
893
  server.on("upgrade", function (req, socket, head) {
872
894
  // Origin validation (CSRF prevention)
873
895
  var origin = req.headers.origin;
@@ -907,7 +929,18 @@ function createServer(opts) {
907
929
  // Extract slug from WS URL: /p/{slug}/ws
908
930
  var wsSlug = extractSlug(req.url);
909
931
  if (!wsSlug) {
910
- socket.destroy();
932
+ // Slug-less /ws: bootstrap channel for a client that hasn't entered
933
+ // any project yet (no projects exist, or none accessible to this user
934
+ // but they can still create one). Anything other than exactly /ws is
935
+ // rejected.
936
+ if (req.url !== "/ws") {
937
+ socket.destroy();
938
+ return;
939
+ }
940
+ var globalUser = users.isMultiUser() ? getMultiUserFromReq(req) : null;
941
+ wss.handleUpgrade(req, socket, head, function (ws) {
942
+ globalWs.handleConnection(ws, globalUser);
943
+ });
911
944
  return;
912
945
  }
913
946
 
@@ -0,0 +1,125 @@
1
+ // tui-transcript-index.js
2
+ //
3
+ // Build a per-session index of assistant text messages from a Claude Code
4
+ // TUI transcript (~/.claude/projects/{encoded-cwd}/{cliSessionId}.jsonl).
5
+ //
6
+ // The TUI message-grab feature uses this index on the client to map a
7
+ // hovered xterm region back to its original markdown source. We only
8
+ // extract assistant text blocks. Tool calls and tool results render in
9
+ // the terminal in a shape that's quite different from their JSONL form,
10
+ // so matching them would be noisy and not particularly useful.
11
+ //
12
+ // Codex sessions don't write transcripts at all; callers should skip
13
+ // indexing for non-claude vendors.
14
+
15
+ var fs = require("fs");
16
+ var path = require("path");
17
+ var utils = require("./utils");
18
+ var { REAL_HOME } = require("./config");
19
+
20
+ var encodeCwd = utils.encodeCwd;
21
+
22
+ // Min length of a normalized assistant message to bother indexing.
23
+ // Anything shorter ("ok", "sure", "yes") would generate too many false
24
+ // positives when matched against a hovered terminal block.
25
+ var MIN_MATCH_LEN = 20;
26
+
27
+ function transcriptFilePath(cwd, cliSessionId) {
28
+ if (!cwd || !cliSessionId) return null;
29
+ return path.join(REAL_HOME, ".claude", "projects", encodeCwd(cwd), cliSessionId + ".jsonl");
30
+ }
31
+
32
+ // matchKey is just whitespace-normalized raw markdown. We intentionally
33
+ // do NOT strip markdown decoration here.
34
+ //
35
+ // Reasoning: TUI rendering drops markdown markers (**bold** -> bold,
36
+ // `code` -> code, [text](url) -> text, etc.) and the visible text is
37
+ // therefore always a substring of the JSONL source's character stream.
38
+ // If we strip server-side we risk getting the rules wrong on corner
39
+ // cases (double-backtick code spans, list-bullet rendering quirks,
40
+ // nested formatting) and the resulting matchKey diverges from both
41
+ // sides at once. Leaving the source alone and letting the client do a
42
+ // multi-offset substring probe against block text is more robust and
43
+ // much less code.
44
+ function normalizeForMatch(s) {
45
+ if (!s) return "";
46
+ return s.replace(/\s+/g, " ").trim();
47
+ }
48
+
49
+ // Stable per-message id. Claude's JSONL usually carries one of these but
50
+ // fall back to the parsed line index so the client always has a handle.
51
+ function messageId(obj, index) {
52
+ if (obj && obj.uuid) return String(obj.uuid);
53
+ if (obj && obj.message && obj.message.id) return String(obj.message.id);
54
+ return "msg-" + index;
55
+ }
56
+
57
+ // Parse a single JSONL line into one assistant-text record, or null
58
+ // when the line isn't one we index (user prompts, tool_use blocks,
59
+ // tool_result records, etc.). Text-only assistant blocks get joined
60
+ // in order so a multi-block response surfaces as a single grabbable
61
+ // message.
62
+ function parseAssistantTextLine(line, index) {
63
+ var obj;
64
+ try { obj = JSON.parse(line); } catch (e) { return null; }
65
+ if (!obj || !obj.message) return null;
66
+ if (obj.message.role !== "assistant") return null;
67
+ if (!Array.isArray(obj.message.content)) return null;
68
+
69
+ var text = "";
70
+ for (var i = 0; i < obj.message.content.length; i++) {
71
+ var block = obj.message.content[i];
72
+ if (block && block.type === "text" && typeof block.text === "string") {
73
+ text += block.text;
74
+ }
75
+ }
76
+ text = text.trim();
77
+ if (!text) return null;
78
+
79
+ var normalized = normalizeForMatch(text);
80
+ if (normalized.length < MIN_MATCH_LEN) return null;
81
+
82
+ return {
83
+ id: messageId(obj, index),
84
+ text: text,
85
+ matchKey: normalized,
86
+ };
87
+ }
88
+
89
+ // Read the whole transcript and return the assistant text index. The
90
+ // transcripts are small enough (KB to a few MB) that a sync read on
91
+ // session open or on a watcher-driven update is fine. We also return
92
+ // the mtime/size so callers can short-circuit re-reads when nothing
93
+ // changed.
94
+ function readAssistantIndex(cwd, cliSessionId) {
95
+ var file = transcriptFilePath(cwd, cliSessionId);
96
+ if (!file) return { messages: [], mtimeMs: 0, byteLength: 0 };
97
+ var raw;
98
+ var stat;
99
+ try {
100
+ raw = fs.readFileSync(file, "utf8");
101
+ stat = fs.statSync(file);
102
+ } catch (e) {
103
+ return { messages: [], mtimeMs: 0, byteLength: 0 };
104
+ }
105
+ var lines = raw.split("\n");
106
+ var messages = [];
107
+ for (var i = 0; i < lines.length; i++) {
108
+ if (!lines[i]) continue;
109
+ var entry = parseAssistantTextLine(lines[i], i);
110
+ if (entry) messages.push(entry);
111
+ }
112
+ return {
113
+ messages: messages,
114
+ mtimeMs: stat.mtimeMs || 0,
115
+ byteLength: stat.size || raw.length,
116
+ };
117
+ }
118
+
119
+ module.exports = {
120
+ MIN_MATCH_LEN: MIN_MATCH_LEN,
121
+ transcriptFilePath: transcriptFilePath,
122
+ readAssistantIndex: readAssistantIndex,
123
+ parseAssistantTextLine: parseAssistantTextLine,
124
+ normalizeForMatch: normalizeForMatch,
125
+ };
@@ -412,7 +412,7 @@ function attachPreferences(deps) {
412
412
  // single object so the two values stay together.
413
413
 
414
414
  var DEFAULT_TERM_FONT_FAMILY = "'SF Mono', Menlo, Monaco, 'Courier New', monospace";
415
- var DEFAULT_TERM_FONT_SIZE = 13;
415
+ var DEFAULT_TERM_FONT_SIZE = 14;
416
416
  var MIN_TERM_FONT_SIZE = 9;
417
417
  var MAX_TERM_FONT_SIZE = 32;
418
418
 
package/lib/ws-schema.js CHANGED
@@ -31,6 +31,7 @@ var schema = {
31
31
  "load_more_history": { direction: "c2s", handler: "lib/project-sessions.js", description: "Request older history entries for the current session" },
32
32
  "fork_session": { direction: "c2s", handler: "lib/project-sessions.js", description: "Fork a session from a given message UUID" },
33
33
  "input_sync": { direction: "c2s", handler: "lib/project-sessions.js", description: "Sync the current input field text to other clients" },
34
+ "tui_transcript_request": { direction: "c2s", handler: "lib/project-sessions.js", description: "Ask for the assistant text index of a Claude TUI session (for hover-to-grab)" },
34
35
 
35
36
  "session_list": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Full list of sessions for the sidebar" },
36
37
  "session_switched": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Confirmation that active session changed" },
@@ -42,6 +43,7 @@ var schema = {
42
43
  "search_content_results": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Full-text content search results" },
43
44
  "fork_complete": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Fork succeeded, includes new session ID" },
44
45
  "input_sync_broadcast": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Broadcast input text from another client" },
46
+ "tui_transcript_state": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Assistant text index for a Claude TUI session (full replace; sent on request and after each new assistant message)" },
45
47
 
46
48
  // -----------------------------------------------------------------------
47
49
  // History replay
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.40.1-beta.1",
3
+ "version": "2.41.0",
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",