claude-relay 2.2.4 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/project.js CHANGED
@@ -55,7 +55,7 @@ function safePath(base, requested) {
55
55
 
56
56
  /**
57
57
  * Create a project context — per-project state and handlers.
58
- * opts: { cwd, slug, title, pushModule, debug, currentVersion }
58
+ * opts: { cwd, slug, title, pushModule, debug, dangerouslySkipPermissions, currentVersion }
59
59
  */
60
60
  function createProjectContext(opts) {
61
61
  var cwd = opts.cwd;
@@ -64,7 +64,9 @@ function createProjectContext(opts) {
64
64
  var title = opts.title || null;
65
65
  var pushModule = opts.pushModule || null;
66
66
  var debug = opts.debug || false;
67
+ var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
67
68
  var currentVersion = opts.currentVersion;
69
+ var lanHost = opts.lanHost || null;
68
70
  var getProjectCount = opts.getProjectCount || function () { return 1; };
69
71
  var getProjectList = opts.getProjectList || function () { return []; };
70
72
  var latestVersion = null;
@@ -136,16 +138,69 @@ function createProjectContext(opts) {
136
138
  watchedPath = null;
137
139
  }
138
140
 
141
+ // --- Directory watcher ---
142
+ var dirWatchers = {}; // relPath -> { watcher, debounce }
143
+
144
+ function startDirWatch(relPath) {
145
+ if (dirWatchers[relPath]) return;
146
+ var absPath = safePath(cwd, relPath);
147
+ if (!absPath) return;
148
+ try {
149
+ var debounce = null;
150
+ var watcher = fs.watch(absPath, function () {
151
+ clearTimeout(debounce);
152
+ debounce = setTimeout(function () {
153
+ // Re-read directory and broadcast to all clients
154
+ try {
155
+ var items = fs.readdirSync(absPath, { withFileTypes: true });
156
+ var entries = [];
157
+ for (var i = 0; i < items.length; i++) {
158
+ if (items[i].isDirectory() && IGNORED_DIRS.has(items[i].name)) continue;
159
+ entries.push({
160
+ name: items[i].name,
161
+ type: items[i].isDirectory() ? "dir" : "file",
162
+ path: path.relative(cwd, path.join(absPath, items[i].name)).split(path.sep).join("/"),
163
+ });
164
+ }
165
+ send({ type: "fs_dir_changed", path: relPath, entries: entries });
166
+ } catch (e) {
167
+ stopDirWatch(relPath);
168
+ }
169
+ }, 300);
170
+ });
171
+ watcher.on("error", function () { stopDirWatch(relPath); });
172
+ dirWatchers[relPath] = { watcher: watcher, debounce: debounce };
173
+ } catch (e) {}
174
+ }
175
+
176
+ function stopDirWatch(relPath) {
177
+ var entry = dirWatchers[relPath];
178
+ if (entry) {
179
+ clearTimeout(entry.debounce);
180
+ try { entry.watcher.close(); } catch (e) {}
181
+ delete dirWatchers[relPath];
182
+ }
183
+ }
184
+
185
+ function stopAllDirWatches() {
186
+ var paths = Object.keys(dirWatchers);
187
+ for (var i = 0; i < paths.length; i++) {
188
+ stopDirWatch(paths[i]);
189
+ }
190
+ }
191
+
139
192
  // --- Session manager ---
140
193
  var sm = createSessionManager({ cwd: cwd, send: send });
141
194
 
142
195
  // --- SDK bridge ---
143
196
  var sdk = createSDKBridge({
144
197
  cwd: cwd,
198
+ slug: slug,
145
199
  sessionManager: sm,
146
200
  send: send,
147
201
  pushModule: pushModule,
148
202
  getSDK: getSDK,
203
+ dangerouslySkipPermissions: dangerouslySkipPermissions,
149
204
  });
150
205
 
151
206
  // --- Terminal manager ---
@@ -165,7 +220,7 @@ function createProjectContext(opts) {
165
220
  broadcastClientCount();
166
221
 
167
222
  // Send cached state
168
- sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, projectCount: getProjectCount(), projects: getProjectList() });
223
+ sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, dangerouslySkipPermissions: dangerouslySkipPermissions, lanHost: lanHost, projectCount: getProjectCount(), projects: getProjectList() });
169
224
  if (latestVersion) {
170
225
  sendTo(ws, { type: "update_available", version: latestVersion });
171
226
  }
@@ -206,6 +261,7 @@ function createProjectContext(opts) {
206
261
  for (var i = fromIndex; i < total; i++) {
207
262
  sendTo(ws, active.history[i]);
208
263
  }
264
+ sendTo(ws, { type: "history_done" });
209
265
 
210
266
  if (active.isProcessing) {
211
267
  sendTo(ws, { type: "status", status: "processing" });
@@ -238,7 +294,7 @@ function createProjectContext(opts) {
238
294
  // --- WS message handler ---
239
295
  function handleMessage(ws, msg) {
240
296
  if (msg.type === "push_subscribe") {
241
- if (pushModule && msg.subscription) pushModule.addSubscription(msg.subscription);
297
+ if (pushModule && msg.subscription) pushModule.addSubscription(msg.subscription, msg.replaceEndpoint);
242
298
  return;
243
299
  }
244
300
 
@@ -308,6 +364,31 @@ function createProjectContext(opts) {
308
364
  return;
309
365
  }
310
366
 
367
+ if (msg.type === "process_stats") {
368
+ var sessionCount = sm.sessions.size;
369
+ var processingCount = 0;
370
+ sm.sessions.forEach(function (s) {
371
+ if (s.isProcessing) processingCount++;
372
+ });
373
+ var mem = process.memoryUsage();
374
+ sendTo(ws, {
375
+ type: "process_stats",
376
+ pid: process.pid,
377
+ uptime: process.uptime(),
378
+ memory: {
379
+ rss: mem.rss,
380
+ heapUsed: mem.heapUsed,
381
+ heapTotal: mem.heapTotal,
382
+ external: mem.external,
383
+ },
384
+ sessions: sessionCount,
385
+ processing: processingCount,
386
+ clients: clients.size,
387
+ terminals: tm.list().length,
388
+ });
389
+ return;
390
+ }
391
+
311
392
  if (msg.type === "stop") {
312
393
  var session = sm.getActiveSession();
313
394
  if (session && session.abortController && session.isProcessing) {
@@ -357,34 +438,41 @@ function createProjectContext(opts) {
357
438
  if (msg.type === "rewind_execute") {
358
439
  var session = sm.getActiveSession();
359
440
  if (!session || !session.cliSessionId || !msg.uuid) return;
441
+ var mode = msg.mode || "both";
360
442
 
361
443
  (async function () {
362
444
  var result;
363
445
  try {
364
- result = await sdk.getOrCreateRewindQuery(session);
365
- await result.query.rewindFiles(msg.uuid, { dryRun: false });
366
-
367
- var targetIdx = -1;
368
- for (var i = 0; i < session.messageUUIDs.length; i++) {
369
- if (session.messageUUIDs[i].uuid === msg.uuid) {
370
- targetIdx = i;
371
- break;
372
- }
446
+ // File restoration (skip for chat-only mode)
447
+ if (mode !== "chat") {
448
+ result = await sdk.getOrCreateRewindQuery(session);
449
+ await result.query.rewindFiles(msg.uuid, { dryRun: false });
373
450
  }
374
451
 
375
- if (targetIdx >= 0) {
376
- var trimTo = session.messageUUIDs[targetIdx].historyIndex;
377
- for (var k = trimTo - 1; k >= 0; k--) {
378
- if (session.history[k].type === "user_message") {
379
- trimTo = k;
452
+ // Conversation rollback (skip for files-only mode)
453
+ if (mode !== "files") {
454
+ var targetIdx = -1;
455
+ for (var i = 0; i < session.messageUUIDs.length; i++) {
456
+ if (session.messageUUIDs[i].uuid === msg.uuid) {
457
+ targetIdx = i;
380
458
  break;
381
459
  }
382
460
  }
383
- session.history = session.history.slice(0, trimTo);
384
- session.messageUUIDs = session.messageUUIDs.slice(0, targetIdx);
385
- }
386
461
 
387
- session.lastRewindUuid = msg.uuid;
462
+ if (targetIdx >= 0) {
463
+ var trimTo = session.messageUUIDs[targetIdx].historyIndex;
464
+ for (var k = trimTo - 1; k >= 0; k--) {
465
+ if (session.history[k].type === "user_message") {
466
+ trimTo = k;
467
+ break;
468
+ }
469
+ }
470
+ session.history = session.history.slice(0, trimTo);
471
+ session.messageUUIDs = session.messageUUIDs.slice(0, targetIdx);
472
+ }
473
+
474
+ session.lastRewindUuid = msg.uuid;
475
+ }
388
476
 
389
477
  if (session.abortController) {
390
478
  try { session.abortController.abort(); } catch (e) {}
@@ -403,7 +491,7 @@ function createProjectContext(opts) {
403
491
 
404
492
  sm.saveSessionFile(session);
405
493
  sm.switchSession(session.localId);
406
- sm.sendAndRecord(session, { type: "rewind_complete" });
494
+ sm.sendAndRecord(session, { type: "rewind_complete", mode: mode });
407
495
  sm.broadcastSessionList();
408
496
  } catch (err) {
409
497
  send({ type: "rewind_error", text: "Rewind failed: " + err.message });
@@ -482,6 +570,8 @@ function createProjectContext(opts) {
482
570
  });
483
571
  }
484
572
  sendTo(ws, { type: "fs_list_result", path: msg.path || ".", entries: entries });
573
+ // Auto-watch the directory for changes
574
+ startDirWatch(msg.path || ".");
485
575
  } catch (e) {
486
576
  sendTo(ws, { type: "fs_list_result", path: msg.path, entries: [], error: e.message });
487
577
  }
@@ -526,6 +616,197 @@ function createProjectContext(opts) {
526
616
  return;
527
617
  }
528
618
 
619
+ // --- File edit history ---
620
+ if (msg.type === "fs_file_history") {
621
+ var histPath = msg.path;
622
+ if (!histPath) {
623
+ sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: [] });
624
+ return;
625
+ }
626
+ var absHistPath = path.resolve(cwd, histPath);
627
+ var entries = [];
628
+
629
+ // Collect session edits
630
+ sm.sessions.forEach(function (session) {
631
+ var sessionLocalId = session.localId;
632
+ var sessionTitle = session.title || "Untitled";
633
+ var histLen = session.history.length || 1;
634
+
635
+ for (var hi = 0; hi < session.history.length; hi++) {
636
+ var entry = session.history[hi];
637
+ if (entry.type !== "tool_executing") continue;
638
+ if (entry.name !== "Edit" && entry.name !== "Write") continue;
639
+ if (!entry.input || !entry.input.file_path) continue;
640
+ if (entry.input.file_path !== absHistPath) continue;
641
+
642
+ // Find parent assistant UUID + message snippet by scanning backwards
643
+ var assistantUuid = null;
644
+ var uuidIndex = -1;
645
+ for (var hj = hi - 1; hj >= 0; hj--) {
646
+ if (session.history[hj].type === "message_uuid" && session.history[hj].messageType === "assistant") {
647
+ assistantUuid = session.history[hj].uuid;
648
+ uuidIndex = hj;
649
+ break;
650
+ }
651
+ }
652
+
653
+ // Find user prompt by scanning backwards from the assistant uuid
654
+ var messageSnippet = "";
655
+ var searchFrom = uuidIndex >= 0 ? uuidIndex : hi;
656
+ for (var hk = searchFrom - 1; hk >= 0; hk--) {
657
+ if (session.history[hk].type === "user_message" && session.history[hk].text) {
658
+ messageSnippet = session.history[hk].text.trim().substring(0, 100);
659
+ break;
660
+ }
661
+ }
662
+
663
+ // Collect Claude's explanation: scan backwards from tool_executing
664
+ // to find the nearest delta text block (skipping tool_start).
665
+ // If no delta found immediately before this tool, scan past
666
+ // intervening tool blocks to find the last delta text within
667
+ // the same assistant turn.
668
+ var assistantSnippet = "";
669
+ var deltaChunks = [];
670
+ for (var hd = hi - 1; hd >= 0; hd--) {
671
+ var hEntry = session.history[hd];
672
+ if (hEntry.type === "tool_start") continue;
673
+ if (hEntry.type === "delta" && hEntry.text) {
674
+ deltaChunks.unshift(hEntry.text);
675
+ } else {
676
+ break;
677
+ }
678
+ }
679
+ if (deltaChunks.length === 0) {
680
+ // No delta immediately before; scan past tool blocks
681
+ // to find the nearest preceding delta in the same turn
682
+ for (var hd2 = hi - 1; hd2 >= 0; hd2--) {
683
+ var hEntry2 = session.history[hd2];
684
+ if (hEntry2.type === "tool_start" || hEntry2.type === "tool_executing" || hEntry2.type === "tool_result") continue;
685
+ if (hEntry2.type === "delta" && hEntry2.text) {
686
+ // Found a delta before an earlier tool in the same turn.
687
+ // Collect this contiguous block of deltas.
688
+ for (var hd3 = hd2; hd3 >= 0; hd3--) {
689
+ var hEntry3 = session.history[hd3];
690
+ if (hEntry3.type === "tool_start") continue;
691
+ if (hEntry3.type === "delta" && hEntry3.text) {
692
+ deltaChunks.unshift(hEntry3.text);
693
+ } else {
694
+ break;
695
+ }
696
+ }
697
+ break;
698
+ } else {
699
+ // Hit message_uuid, user_message, etc. Stop.
700
+ break;
701
+ }
702
+ }
703
+ }
704
+ assistantSnippet = deltaChunks.join("").trim().substring(0, 150);
705
+
706
+ // Approximate timestamp: interpolate between session creation and last activity
707
+ var tStart = session.createdAt || 0;
708
+ var tEnd = session.lastActivity || tStart;
709
+ var ts = tStart + Math.floor((hi / histLen) * (tEnd - tStart));
710
+
711
+ var editRecord = {
712
+ source: "session",
713
+ timestamp: ts,
714
+ sessionLocalId: sessionLocalId,
715
+ sessionTitle: sessionTitle,
716
+ assistantUuid: assistantUuid,
717
+ toolId: entry.id,
718
+ messageSnippet: messageSnippet,
719
+ assistantSnippet: assistantSnippet,
720
+ toolName: entry.name,
721
+ };
722
+
723
+ if (entry.name === "Edit") {
724
+ editRecord.old_string = entry.input.old_string || "";
725
+ editRecord.new_string = entry.input.new_string || "";
726
+ } else {
727
+ editRecord.isFullWrite = true;
728
+ }
729
+
730
+ entries.push(editRecord);
731
+ }
732
+ });
733
+
734
+ // Collect git commits
735
+ try {
736
+ var gitLog = execFileSync(
737
+ "git", ["log", "--format=%H|%at|%an|%s", "--follow", "--", histPath],
738
+ { cwd: cwd, encoding: "utf8", timeout: 5000 }
739
+ );
740
+ var gitLines = gitLog.trim().split("\n");
741
+ for (var gi = 0; gi < gitLines.length; gi++) {
742
+ if (!gitLines[gi]) continue;
743
+ var parts = gitLines[gi].split("|");
744
+ if (parts.length < 4) continue;
745
+ entries.push({
746
+ source: "git",
747
+ hash: parts[0],
748
+ timestamp: parseInt(parts[1], 10) * 1000,
749
+ author: parts[2],
750
+ message: parts.slice(3).join("|"),
751
+ });
752
+ }
753
+ } catch (e) {
754
+ // Not a git repo or file not tracked, that's fine
755
+ }
756
+
757
+ // Sort by timestamp descending (newest first)
758
+ entries.sort(function (a, b) { return b.timestamp - a.timestamp; });
759
+
760
+ sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: entries });
761
+ return;
762
+ }
763
+
764
+ // --- Git diff for file history ---
765
+ if (msg.type === "fs_git_diff") {
766
+ var diffPath = msg.path;
767
+ var hash = msg.hash;
768
+ var hash2 = msg.hash2 || null;
769
+ if (!diffPath || !hash) {
770
+ sendTo(ws, { type: "fs_git_diff_result", hash: hash, path: diffPath, diff: "", error: "Missing params" });
771
+ return;
772
+ }
773
+ try {
774
+ var diff;
775
+ if (hash2) {
776
+ diff = execFileSync("git", ["diff", hash, hash2, "--", diffPath],
777
+ { cwd: cwd, encoding: "utf8", timeout: 5000 });
778
+ } else {
779
+ diff = execFileSync("git", ["show", hash, "--format=", "--", diffPath],
780
+ { cwd: cwd, encoding: "utf8", timeout: 5000 });
781
+ }
782
+ sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: diff || "" });
783
+ } catch (e) {
784
+ sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: "", error: e.message });
785
+ }
786
+ return;
787
+ }
788
+
789
+ // --- File content at a git commit ---
790
+ if (msg.type === "fs_file_at") {
791
+ var atPath = msg.path;
792
+ var atHash = msg.hash;
793
+ if (!atPath || !atHash) {
794
+ sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: "Missing params" });
795
+ return;
796
+ }
797
+ try {
798
+ // Convert to repo-relative path (git show requires hash:relative/path)
799
+ var atAbsPath = path.resolve(cwd, atPath);
800
+ var atRelPath = path.relative(cwd, atAbsPath);
801
+ var content = execFileSync("git", ["show", atHash + ":" + atRelPath],
802
+ { cwd: cwd, encoding: "utf8", timeout: 5000 });
803
+ sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: content });
804
+ } catch (e) {
805
+ sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: e.message });
806
+ }
807
+ return;
808
+ }
809
+
529
810
  // --- Web terminal ---
530
811
  if (msg.type === "term_create") {
531
812
  var t = tm.create(msg.cols || 80, msg.rows || 24);
@@ -627,7 +908,21 @@ function createProjectContext(opts) {
627
908
  function handleDisconnection(ws) {
628
909
  tm.detachAll(ws);
629
910
  clients.delete(ws);
630
- if (clients.size === 0) stopFileWatch();
911
+ if (clients.size === 0) {
912
+ stopFileWatch();
913
+ stopAllDirWatches();
914
+ // Abort all running queries when no clients are connected
915
+ var aborted = 0;
916
+ sm.sessions.forEach(function (session) {
917
+ if (session.isProcessing && session.abortController) {
918
+ try { session.abortController.abort(); } catch (e) {}
919
+ aborted++;
920
+ }
921
+ });
922
+ if (aborted > 0) {
923
+ console.log("[project:" + slug + "] No clients connected, aborted " + aborted + " active queries");
924
+ }
925
+ }
631
926
  broadcastClientCount();
632
927
  }
633
928
 
@@ -635,8 +930,9 @@ function createProjectContext(opts) {
635
930
  function handleHTTP(req, res, urlPath) {
636
931
  // Push subscribe
637
932
  if (req.method === "POST" && urlPath === "/api/push-subscribe") {
638
- parseJsonBody(req).then(function (sub) {
639
- if (pushModule) pushModule.addSubscription(sub);
933
+ parseJsonBody(req).then(function (body) {
934
+ var sub = body.subscription || body;
935
+ if (pushModule) pushModule.addSubscription(sub, body.replaceEndpoint);
640
936
  res.writeHead(200, { "Content-Type": "application/json" });
641
937
  res.end('{"ok":true}');
642
938
  }).catch(function () {
@@ -736,6 +1032,7 @@ function createProjectContext(opts) {
736
1032
  // --- Destroy ---
737
1033
  function destroy() {
738
1034
  stopFileWatch();
1035
+ stopAllDirWatches();
739
1036
  // Abort all active sessions
740
1037
  sm.sessions.forEach(function (session) {
741
1038
  if (session.abortController) {
@@ -773,7 +1070,7 @@ function createProjectContext(opts) {
773
1070
 
774
1071
  function setTitle(newTitle) {
775
1072
  title = newTitle || null;
776
- send({ type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, projectCount: getProjectCount(), projects: getProjectList() });
1073
+ send({ type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, lanHost: lanHost, projectCount: getProjectCount(), projects: getProjectList() });
777
1074
  }
778
1075
 
779
1076
  return {