clay-server 4.0.0-beta.23 → 4.0.0-beta.25

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.
@@ -342,6 +342,7 @@ function attachConnection(ctx) {
342
342
  tm.detachAll(ws);
343
343
  clients.delete(ws);
344
344
  stopFileWatch(ws);
345
+ stopAllDirWatches(ws);
345
346
  if (clients.size === 0) {
346
347
  stopFileWatch();
347
348
  stopAllDirWatches();
@@ -0,0 +1,223 @@
1
+ var path = require("path");
2
+ var execFileSync = require("child_process").execFileSync;
3
+ var wrapSpawnAsUser = require("./os-users").wrapSpawnAsUser;
4
+
5
+ function attachFileHistory(ctx, access) {
6
+ var cwd = ctx.cwd;
7
+ var sm = ctx.sm;
8
+ var sendTo = ctx.sendTo;
9
+
10
+ function runGit(ws, args, options) {
11
+ options = Object.assign({}, options, { stdio: ["ignore", "pipe", "pipe"] });
12
+ var identity = access.osIdentity(ws);
13
+ if (identity) options = Object.assign({}, options, { uid: identity.uid, gid: identity.gid });
14
+ var command = wrapSpawnAsUser("git", args, options);
15
+ return execFileSync(command.command, command.args, command.options);
16
+ }
17
+
18
+ function handleFileHistory(ws, msg) {
19
+ if (!/^fs_(file_history|git_diff|file_at)$/.test(msg.type)) return false;
20
+ if (typeof msg.path !== "string" || !msg.path || path.isAbsolute(msg.path) ||
21
+ path.relative(cwd, path.resolve(cwd, msg.path)).split(path.sep).indexOf("..") !== -1 ||
22
+ (msg.hash && !/^[a-fA-F0-9]{4,64}$/.test(msg.hash)) ||
23
+ (msg.hash2 && !/^[a-fA-F0-9]{4,64}$/.test(msg.hash2))) {
24
+ sendTo(ws, { type: msg.type + "_result", path: msg.path, error: "Invalid file history request" });
25
+ return true;
26
+ }
27
+ // --- File edit history ---
28
+ if (msg.type === "fs_file_history") {
29
+ var histPath = msg.path;
30
+ if (!histPath) {
31
+ sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: [] });
32
+ return true;
33
+ }
34
+ var absHistPath = path.resolve(cwd, histPath);
35
+ var entries = [];
36
+
37
+ // Collect session edits
38
+ sm.sessions.forEach(function (session) {
39
+ if (!access.canReadSession(ws, session)) return;
40
+ var sessionLocalId = session.localId;
41
+ var sessionTitle = session.title || "Untitled";
42
+ var histLen = session.history.length || 1;
43
+
44
+ for (var hi = 0; hi < session.history.length; hi++) {
45
+ var entry = session.history[hi];
46
+ if (entry.type !== "tool_executing") continue;
47
+ if (entry.name !== "Edit" && entry.name !== "Write") continue;
48
+ if (!entry.input || !entry.input.file_path) continue;
49
+ if (entry.input.file_path !== absHistPath) continue;
50
+
51
+ // Find parent assistant UUID + message snippet by scanning backwards
52
+ var assistantUuid = null;
53
+ var uuidIndex = -1;
54
+ for (var hj = hi - 1; hj >= 0; hj--) {
55
+ if (session.history[hj].type === "message_uuid" && session.history[hj].messageType === "assistant") {
56
+ assistantUuid = session.history[hj].uuid;
57
+ uuidIndex = hj;
58
+ break;
59
+ }
60
+ }
61
+
62
+ // Find user prompt by scanning backwards from the assistant uuid
63
+ var messageSnippet = "";
64
+ var searchFrom = uuidIndex >= 0 ? uuidIndex : hi;
65
+ for (var hk = searchFrom - 1; hk >= 0; hk--) {
66
+ if (session.history[hk].type === "user_message" && session.history[hk].text) {
67
+ messageSnippet = session.history[hk].text.trim().substring(0, 100);
68
+ break;
69
+ }
70
+ }
71
+
72
+ // Collect Claude's explanation: scan backwards from tool_executing
73
+ // to find the nearest delta text block (skipping tool_start).
74
+ // If no delta found immediately before this tool, scan past
75
+ // intervening tool blocks to find the last delta text within
76
+ // the same assistant turn.
77
+ var assistantSnippet = "";
78
+ var deltaChunks = [];
79
+ for (var hd = hi - 1; hd >= 0; hd--) {
80
+ var hEntry = session.history[hd];
81
+ if (hEntry.type === "tool_start") continue;
82
+ if (hEntry.type === "delta" && hEntry.text) {
83
+ deltaChunks.unshift(hEntry.text);
84
+ } else {
85
+ break;
86
+ }
87
+ }
88
+ if (deltaChunks.length === 0) {
89
+ // No delta immediately before; scan past tool blocks
90
+ // to find the nearest preceding delta in the same turn
91
+ for (var hd2 = hi - 1; hd2 >= 0; hd2--) {
92
+ var hEntry2 = session.history[hd2];
93
+ if (hEntry2.type === "tool_start" || hEntry2.type === "tool_executing" || hEntry2.type === "tool_result") continue;
94
+ if (hEntry2.type === "delta" && hEntry2.text) {
95
+ // Found a delta before an earlier tool in the same turn.
96
+ // Collect this contiguous block of deltas.
97
+ for (var hd3 = hd2; hd3 >= 0; hd3--) {
98
+ var hEntry3 = session.history[hd3];
99
+ if (hEntry3.type === "tool_start") continue;
100
+ if (hEntry3.type === "delta" && hEntry3.text) {
101
+ deltaChunks.unshift(hEntry3.text);
102
+ } else {
103
+ break;
104
+ }
105
+ }
106
+ break;
107
+ } else {
108
+ // Hit message_uuid, user_message, etc. Stop.
109
+ break;
110
+ }
111
+ }
112
+ }
113
+ assistantSnippet = deltaChunks.join("").trim().substring(0, 150);
114
+
115
+ // Approximate timestamp: interpolate between session creation and last activity
116
+ var tStart = session.createdAt || 0;
117
+ var tEnd = session.lastActivity || tStart;
118
+ var ts = tStart + Math.floor((hi / histLen) * (tEnd - tStart));
119
+
120
+ var editRecord = {
121
+ source: "session",
122
+ timestamp: ts,
123
+ sessionLocalId: sessionLocalId,
124
+ sessionTitle: sessionTitle,
125
+ assistantUuid: assistantUuid,
126
+ toolId: entry.id,
127
+ messageSnippet: messageSnippet,
128
+ assistantSnippet: assistantSnippet,
129
+ toolName: entry.name,
130
+ };
131
+
132
+ if (entry.name === "Edit") {
133
+ editRecord.old_string = entry.input.old_string || "";
134
+ editRecord.new_string = entry.input.new_string || "";
135
+ } else {
136
+ editRecord.isFullWrite = true;
137
+ }
138
+
139
+ entries.push(editRecord);
140
+ }
141
+ });
142
+
143
+ // Collect git commits
144
+ try {
145
+ var gitLog = runGit(ws,
146
+ ["log", "--format=%H|%at|%an|%s", "--follow", "--", histPath],
147
+ { cwd: cwd, encoding: "utf8", timeout: 5000 }
148
+ );
149
+ var gitLines = gitLog.trim().split("\n");
150
+ for (var gi = 0; gi < gitLines.length; gi++) {
151
+ if (!gitLines[gi]) continue;
152
+ var parts = gitLines[gi].split("|");
153
+ if (parts.length < 4) continue;
154
+ entries.push({
155
+ source: "git",
156
+ hash: parts[0],
157
+ timestamp: parseInt(parts[1], 10) * 1000,
158
+ author: parts[2],
159
+ message: parts.slice(3).join("|"),
160
+ });
161
+ }
162
+ } catch (e) {
163
+ // Not a git repo or file not tracked, that is fine
164
+ }
165
+
166
+ // Sort by timestamp descending (newest first)
167
+ entries.sort(function (a, b) { return b.timestamp - a.timestamp; });
168
+
169
+ sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: entries });
170
+ return true;
171
+ }
172
+
173
+ // --- Git diff for file history ---
174
+ if (msg.type === "fs_git_diff") {
175
+ var diffPath = msg.path;
176
+ var hash = msg.hash;
177
+ var hash2 = msg.hash2 || null;
178
+ if (!diffPath || !hash) {
179
+ sendTo(ws, { type: "fs_git_diff_result", hash: hash, path: diffPath, diff: "", error: "Missing params" });
180
+ return true;
181
+ }
182
+ try {
183
+ var diff;
184
+ if (hash2) {
185
+ diff = runGit(ws, ["diff", hash, hash2, "--", diffPath],
186
+ { cwd: cwd, encoding: "utf8", timeout: 5000 });
187
+ } else {
188
+ diff = runGit(ws, ["show", hash, "--format=", "--", diffPath],
189
+ { cwd: cwd, encoding: "utf8", timeout: 5000 });
190
+ }
191
+ sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: diff || "" });
192
+ } catch (e) {
193
+ sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: "", error: e.message });
194
+ }
195
+ return true;
196
+ }
197
+
198
+ // --- File content at a git commit ---
199
+ if (msg.type === "fs_file_at") {
200
+ var atPath = msg.path;
201
+ var atHash = msg.hash;
202
+ if (!atPath || !atHash) {
203
+ sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: "Missing params" });
204
+ return true;
205
+ }
206
+ try {
207
+ // Convert to repo-relative path (git show requires hash:relative/path)
208
+ var atAbsPath = path.resolve(cwd, atPath);
209
+ var atRelPath = path.relative(cwd, atAbsPath);
210
+ var content = runGit(ws, ["show", atHash + ":" + atRelPath],
211
+ { cwd: cwd, encoding: "utf8", timeout: 5000 });
212
+ sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: content });
213
+ } catch (e) {
214
+ sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: e.message });
215
+ }
216
+ return true;
217
+ }
218
+
219
+ return false;
220
+ }
221
+ return { handleFileHistory: handleFileHistory };
222
+ }
223
+ module.exports = { attachFileHistory: attachFileHistory };
@@ -5,7 +5,7 @@ var path = require("path");
5
5
  * Attach file/directory watcher engine to a project context.
6
6
  *
7
7
  * ctx fields:
8
- * cwd, send, sendTo, safePath, BINARY_EXTS, FS_MAX_SIZE, IGNORED_DIRS
8
+ * cwd, send, sendTo, safePath, BINARY_EXTS, FS_MAX_SIZE, IGNORED_DIRS, requestAccess, fsAsUser
9
9
  */
10
10
  function attachFileWatch(ctx) {
11
11
  var cwd = ctx.cwd;
@@ -15,6 +15,19 @@ function attachFileWatch(ctx) {
15
15
  var BINARY_EXTS = ctx.BINARY_EXTS;
16
16
  var FS_MAX_SIZE = ctx.FS_MAX_SIZE;
17
17
  var IGNORED_DIRS = ctx.IGNORED_DIRS;
18
+ var access = ctx.requestAccess;
19
+
20
+ function identityFor(client) {
21
+ if (!access) return null;
22
+ if (!access.canUseFiles(client)) throw new Error("File browser access is not permitted");
23
+ return access.osIdentity(client);
24
+ }
25
+
26
+ function checkedPath(relPath) {
27
+ var resolved = safePath(cwd, relPath);
28
+ if (!resolved) throw new Error("Access denied");
29
+ return resolved;
30
+ }
18
31
 
19
32
  // --- File watcher ---
20
33
  // One open file per websocket client. A project-wide singleton watcher made
@@ -47,18 +60,21 @@ function attachFileWatch(ctx) {
47
60
  }
48
61
  }
49
62
 
50
- function readFileSnapshot(absPath) {
51
- var stat = fs.statSync(absPath);
63
+ function readFileSnapshot(client, relPath) {
64
+ var identity = identityFor(client);
65
+ var absPath = checkedPath(relPath);
66
+ var stat = identity ? ctx.fsAsUser("stat", { file: absPath }, identity) : fs.statSync(absPath);
52
67
  var ext = path.extname(absPath).toLowerCase();
53
68
  if (stat.size > FS_MAX_SIZE || BINARY_EXTS.has(ext)) return null;
69
+ if (identity) return ctx.fsAsUser("read", { file: absPath, readContent: true }, identity);
54
70
  return { content: fs.readFileSync(absPath, "utf8"), size: stat.size };
55
71
  }
56
72
 
57
- function publishFileChanged(key, client, relPath, absPath) {
73
+ function publishFileChanged(key, client, relPath) {
58
74
  var latest = fileWatchers.get(key);
59
75
  if (!latest || latest.relPath !== relPath) return;
60
76
  try {
61
- var snapshot = readFileSnapshot(absPath);
77
+ var snapshot = readFileSnapshot(client, relPath);
62
78
  if (!snapshot) return;
63
79
  if (latest.hasSnapshot && latest.content === snapshot.content && latest.size === snapshot.size) return;
64
80
  latest.hasSnapshot = true;
@@ -97,7 +113,9 @@ function attachFileWatch(ctx) {
97
113
  var parentPath = path.dirname(absPath);
98
114
  var baseName = path.basename(absPath);
99
115
  var initialSnapshot = null;
100
- try { initialSnapshot = readFileSnapshot(absPath); } catch (e) {}
116
+ try { initialSnapshot = readFileSnapshot(client, relPath); } catch (e) {
117
+ return Promise.resolve(false);
118
+ }
101
119
  try {
102
120
  var watcher = fs.watch(parentPath, function (eventType, filename) {
103
121
  if (filename && String(filename) !== baseName) return;
@@ -105,7 +123,7 @@ function attachFileWatch(ctx) {
105
123
  if (!active || active.relPath !== relPath) return;
106
124
  clearTimeout(active.debounce);
107
125
  active.debounce = setTimeout(function () {
108
- publishFileChanged(key, client, relPath, absPath);
126
+ publishFileChanged(key, client, relPath);
109
127
  }, 200);
110
128
  });
111
129
  var resolveReady = null;
@@ -127,7 +145,7 @@ function attachFileWatch(ctx) {
127
145
  // drop them under load. Periodic content reconciliation is the source of
128
146
  // truth and also survives atomic replacements with identical metadata.
129
147
  entry.pollTimer = setInterval(function () {
130
- publishFileChanged(key, client, relPath, absPath);
148
+ publishFileChanged(key, client, relPath);
131
149
  }, 1000);
132
150
  // fs.watch has no readiness event. Reconcile once on the next event-loop
133
151
  // turn so a change between the initial read and native watcher activation
@@ -135,7 +153,7 @@ function attachFileWatch(ctx) {
135
153
  entry.reconcile = setImmediate(function () {
136
154
  var active = fileWatchers.get(key);
137
155
  if (active) active.reconcile = null;
138
- publishFileChanged(key, client, relPath, absPath);
156
+ publishFileChanged(key, client, relPath);
139
157
  if (fileWatchers.get(key) === entry) settleFileWatchReady(entry, true);
140
158
  });
141
159
  watcher.on("error", function () { closeFileWatch(key); });
@@ -155,55 +173,65 @@ function attachFileWatch(ctx) {
155
173
  for (var i = 0; i < keys.length; i++) closeFileWatch(keys[i]);
156
174
  }
157
175
 
158
- // --- Directory watcher ---
159
- var dirWatchers = {}; // relPath -> { watcher, debounce }
176
+ // Directory subscriptions are private to the requesting socket.
177
+ var dirWatchers = new Map();
160
178
 
161
- function startDirWatch(relPath) {
162
- if (dirWatchers[relPath]) return;
163
- var absPath = safePath(cwd, relPath);
164
- if (!absPath) return;
179
+ function readDirectory(client, relPath) {
180
+ var identity = identityFor(client);
181
+ var absPath = checkedPath(relPath);
182
+ var items = identity ? ctx.fsAsUser("list", { dir: absPath }, identity) :
183
+ fs.readdirSync(absPath, { withFileTypes: true }).map(function (item) {
184
+ return { name: item.name, isDir: item.isDirectory() };
185
+ });
186
+ return items.filter(function (item) {
187
+ return !item.isDir || !IGNORED_DIRS.has(item.name);
188
+ }).map(function (item) {
189
+ return { name: item.name, type: item.isDir ? "dir" : "file",
190
+ path: path.relative(cwd, path.join(absPath, item.name)).split(path.sep).join("/") };
191
+ });
192
+ }
193
+
194
+ function startDirWatch(client, relPath) {
195
+ var subscriptions = dirWatchers.get(client);
196
+ if (!subscriptions) { subscriptions = new Map(); dirWatchers.set(client, subscriptions); }
197
+ if (subscriptions.has(relPath)) return;
165
198
  try {
166
- var debounce = null;
167
- var watcher = fs.watch(absPath, function () {
168
- clearTimeout(debounce);
169
- debounce = setTimeout(function () {
170
- // Re-read directory and broadcast to all clients
199
+ readDirectory(client, relPath);
200
+ var entry = { watcher: null, debounce: null };
201
+ entry.watcher = fs.watch(checkedPath(relPath), function () {
202
+ clearTimeout(entry.debounce);
203
+ entry.debounce = setTimeout(function () {
171
204
  try {
172
- var items = fs.readdirSync(absPath, { withFileTypes: true });
173
- var entries = [];
174
- for (var i = 0; i < items.length; i++) {
175
- if (items[i].isDirectory() && IGNORED_DIRS.has(items[i].name)) continue;
176
- entries.push({
177
- name: items[i].name,
178
- type: items[i].isDirectory() ? "dir" : "file",
179
- path: path.relative(cwd, path.join(absPath, items[i].name)).split(path.sep).join("/"),
180
- });
181
- }
182
- send({ type: "fs_dir_changed", path: relPath, entries: entries });
183
- } catch (e) {
184
- stopDirWatch(relPath);
185
- }
205
+ var entries = readDirectory(client, relPath);
206
+ sendTo(client, { type: "fs_dir_changed", path: relPath, entries: entries });
207
+ } catch (e) { stopDirWatch(client, relPath); }
186
208
  }, 300);
187
209
  });
188
- watcher.on("error", function () { stopDirWatch(relPath); });
189
- dirWatchers[relPath] = { watcher: watcher, debounce: debounce };
190
- } catch (e) {}
210
+ entry.watcher.on("error", function () { stopDirWatch(client, relPath); });
211
+ subscriptions.set(relPath, entry);
212
+ } catch (e) {
213
+ if (!subscriptions.size) dirWatchers.delete(client);
214
+ }
191
215
  }
192
216
 
193
- function stopDirWatch(relPath) {
194
- var entry = dirWatchers[relPath];
195
- if (entry) {
196
- clearTimeout(entry.debounce);
197
- try { entry.watcher.close(); } catch (e) {}
198
- delete dirWatchers[relPath];
199
- }
217
+ function stopDirWatch(client, relPath) {
218
+ var subscriptions = dirWatchers.get(client);
219
+ var entry = subscriptions && subscriptions.get(relPath);
220
+ if (!entry) return;
221
+ clearTimeout(entry.debounce);
222
+ try { entry.watcher.close(); } catch (e) {}
223
+ subscriptions.delete(relPath);
224
+ if (!subscriptions.size) dirWatchers.delete(client);
200
225
  }
201
226
 
202
- function stopAllDirWatches() {
203
- var paths = Object.keys(dirWatchers);
204
- for (var i = 0; i < paths.length; i++) {
205
- stopDirWatch(paths[i]);
206
- }
227
+ function stopAllDirWatches(client) {
228
+ var clients = arguments.length ? [client] : Array.from(dirWatchers.keys());
229
+ clients.forEach(function (key) {
230
+ var subscriptions = dirWatchers.get(key);
231
+ if (subscriptions) Array.from(subscriptions.keys()).forEach(function (relPath) {
232
+ stopDirWatch(key, relPath);
233
+ });
234
+ });
207
235
  }
208
236
 
209
237
  return {