clay-server 2.26.1-beta.1 → 2.27.0-beta.2

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
@@ -8,7 +8,6 @@ var { createTerminalManager } = require("./terminal-manager");
8
8
  var { createNotesManager } = require("./notes");
9
9
  var { fetchLatestVersion, fetchVersion, isNewer } = require("./updater");
10
10
  var { execFileSync, spawn } = require("child_process");
11
- var { createLoopRegistry } = require("./scheduler");
12
11
  var usersModule = require("./users");
13
12
  var { resolveOsUserInfo, fsAsUser } = require("./os-users");
14
13
  var crisisSafety = require("./crisis-safety");
@@ -18,7 +17,15 @@ var userPresence = require("./user-presence");
18
17
  var { attachDebate } = require("./project-debate");
19
18
  var { attachMemory } = require("./project-memory");
20
19
  var { attachMateInteraction } = require("./project-mate-interaction");
21
- var MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
20
+ var { attachLoop } = require("./project-loop");
21
+ var { attachFileWatch } = require("./project-file-watch");
22
+ var { attachHTTP } = require("./project-http");
23
+ var { attachImage } = require("./project-image");
24
+ var { attachKnowledge } = require("./project-knowledge");
25
+ var { attachFilesystem } = require("./project-filesystem");
26
+ var { attachSessions } = require("./project-sessions");
27
+ var { attachUserMessage } = require("./project-user-message");
28
+ var { attachConnection } = require("./project-connection");
22
29
 
23
30
  // --- Context Sources persistence ---
24
31
  var _ctxSrcConfig = require("./config");
@@ -91,21 +98,6 @@ var BINARY_EXTS = new Set([
91
98
  ]);
92
99
  var IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"]);
93
100
  var FS_MAX_SIZE = 512 * 1024;
94
- var MIME_TYPES = {
95
- ".html": "text/html",
96
- ".css": "text/css",
97
- ".js": "application/javascript",
98
- ".json": "application/json",
99
- ".png": "image/png",
100
- ".jpg": "image/jpeg",
101
- ".jpeg": "image/jpeg",
102
- ".gif": "image/gif",
103
- ".webp": "image/webp",
104
- ".bmp": "image/bmp",
105
- ".svg": "image/svg+xml",
106
- ".ico": "image/x-icon",
107
- };
108
-
109
101
  function safePath(base, requested) {
110
102
  var resolved = path.resolve(base, requested);
111
103
  if (resolved !== base && !resolved.startsWith(base + path.sep)) return null;
@@ -168,77 +160,11 @@ function createProjectContext(opts) {
168
160
  // Browser MCP server runs in-process via createSdkMcpServer (no child process spawn).
169
161
  // Do NOT write to .claude-local/settings.json -- the SDK reads that too, causing duplicate spawns.
170
162
 
171
- // --- Chat image storage ---
172
- var _imgConfig = require("./config");
173
- var _imgUtils = require("./utils");
174
- var _imagesBaseDir = path.join(_imgConfig.CONFIG_DIR, "images");
175
- var _imagesEncodedCwd = _imgUtils.encodeCwd(cwd);
176
- var imagesDir = path.join(_imagesBaseDir, _imagesEncodedCwd);
177
-
178
- // Convert imageRefs in history entries to images with URLs for the client
179
- function hydrateImageRefs(entry) {
180
- if (!entry) return entry;
181
- // Hydrate context_preview: convert screenshotFile to screenshotUrl
182
- if (entry.type === "context_preview" && entry.tab && entry.tab.screenshotFile) {
183
- var hydrated = {};
184
- for (var k in entry) hydrated[k] = entry[k];
185
- hydrated.tab = {};
186
- for (var tk in entry.tab) hydrated.tab[tk] = entry.tab[tk];
187
- hydrated.tab.screenshotUrl = "/p/" + slug + "/images/" + entry.tab.screenshotFile;
188
- delete hydrated.tab.screenshotFile;
189
- return hydrated;
190
- }
191
- if (!entry.imageRefs) return entry;
192
- if (entry.type !== "user_message" && entry.type !== "mention_user") return entry;
193
- var images = [];
194
- for (var ri = 0; ri < entry.imageRefs.length; ri++) {
195
- var ref = entry.imageRefs[ri];
196
- images.push({ mediaType: ref.mediaType, url: "/p/" + slug + "/images/" + ref.file });
197
- }
198
- var hydrated = {};
199
- for (var k2 in entry) {
200
- if (k2 !== "imageRefs") hydrated[k2] = entry[k2];
201
- }
202
- hydrated.images = images;
203
- return hydrated;
204
- }
205
-
206
- function saveImageFile(mediaType, base64data, ownerLinuxUser) {
207
- try { fs.mkdirSync(imagesDir, { recursive: true }); } catch (e) {}
208
- var ext = mediaType === "image/png" ? ".png" : mediaType === "image/gif" ? ".gif" : mediaType === "image/webp" ? ".webp" : ".jpg";
209
- var hash = crypto.createHash("sha256").update(base64data).digest("hex").substring(0, 16);
210
- var fileName = Date.now() + "-" + hash + ext;
211
- var filePath = path.join(imagesDir, fileName);
212
- try {
213
- fs.writeFileSync(filePath, Buffer.from(base64data, "base64"));
214
- if (process.platform !== "win32") {
215
- // 644 so all local users can read (needed for git, copy, etc.)
216
- try { fs.chmodSync(filePath, 0o644); } catch (e) {}
217
- // In OS-user mode the daemon runs as root, so chown the file
218
- // (and parent dirs) to the session owner to avoid permission issues.
219
- if (ownerLinuxUser) {
220
- try {
221
- var osUsersMod = require("./os-users");
222
- var uid = osUsersMod.getLinuxUserUid(ownerLinuxUser);
223
- if (uid != null) {
224
- require("child_process").execSync("chown " + uid + " " + JSON.stringify(filePath));
225
- // Also fix parent dirs if root-owned
226
- try {
227
- var dirStat = fs.statSync(imagesDir);
228
- if (dirStat.uid !== uid) {
229
- require("child_process").execSync("chown " + uid + " " + JSON.stringify(imagesDir));
230
- }
231
- } catch (e2) {}
232
- }
233
- } catch (e) {}
234
- }
235
- }
236
- return fileName;
237
- } catch (e) {
238
- console.error("[images] Failed to save image:", e.message);
239
- return null;
240
- }
241
- }
163
+ // --- Image engine (delegated to project-image.js) ---
164
+ var _image = attachImage({ cwd: cwd, slug: slug });
165
+ var imagesDir = _image.imagesDir;
166
+ var hydrateImageRefs = _image.hydrateImageRefs;
167
+ var saveImageFile = _image.saveImageFile;
242
168
 
243
169
  // --- OS-level user isolation helper ---
244
170
  // Returns the Linux username for the session owner.
@@ -415,98 +341,29 @@ function createProjectContext(opts) {
415
341
  }
416
342
  }
417
343
 
418
- // --- File watcher ---
419
- var fileWatcher = null;
420
- var watchedPath = null;
421
- var watchDebounce = null;
422
-
423
- function startFileWatch(relPath) {
424
- var absPath = safePath(cwd, relPath);
425
- if (!absPath) return;
426
- if (watchedPath === relPath) return;
427
- stopFileWatch();
428
- watchedPath = relPath;
429
- try {
430
- fileWatcher = fs.watch(absPath, function () {
431
- clearTimeout(watchDebounce);
432
- watchDebounce = setTimeout(function () {
433
- try {
434
- var stat = fs.statSync(absPath);
435
- var ext = path.extname(absPath).toLowerCase();
436
- if (stat.size > FS_MAX_SIZE || BINARY_EXTS.has(ext)) return;
437
- var content = fs.readFileSync(absPath, "utf8");
438
- send({ type: "fs_file_changed", path: relPath, content: content, size: stat.size });
439
- } catch (e) {
440
- stopFileWatch();
441
- }
442
- }, 200);
443
- });
444
- fileWatcher.on("error", function () { stopFileWatch(); });
445
- } catch (e) {
446
- watchedPath = null;
447
- }
448
- }
449
-
450
- function stopFileWatch() {
451
- if (fileWatcher) {
452
- try { fileWatcher.close(); } catch (e) {}
453
- fileWatcher = null;
454
- }
455
- clearTimeout(watchDebounce);
456
- watchDebounce = null;
457
- watchedPath = null;
458
- }
459
-
460
- // --- Directory watcher ---
461
- var dirWatchers = {}; // relPath -> { watcher, debounce }
462
-
463
- function startDirWatch(relPath) {
464
- if (dirWatchers[relPath]) return;
465
- var absPath = safePath(cwd, relPath);
466
- if (!absPath) return;
467
- try {
468
- var debounce = null;
469
- var watcher = fs.watch(absPath, function () {
470
- clearTimeout(debounce);
471
- debounce = setTimeout(function () {
472
- // Re-read directory and broadcast to all clients
473
- try {
474
- var items = fs.readdirSync(absPath, { withFileTypes: true });
475
- var entries = [];
476
- for (var i = 0; i < items.length; i++) {
477
- if (items[i].isDirectory() && IGNORED_DIRS.has(items[i].name)) continue;
478
- entries.push({
479
- name: items[i].name,
480
- type: items[i].isDirectory() ? "dir" : "file",
481
- path: path.relative(cwd, path.join(absPath, items[i].name)).split(path.sep).join("/"),
482
- });
483
- }
484
- send({ type: "fs_dir_changed", path: relPath, entries: entries });
485
- } catch (e) {
486
- stopDirWatch(relPath);
487
- }
488
- }, 300);
489
- });
490
- watcher.on("error", function () { stopDirWatch(relPath); });
491
- dirWatchers[relPath] = { watcher: watcher, debounce: debounce };
492
- } catch (e) {}
493
- }
494
-
495
- function stopDirWatch(relPath) {
496
- var entry = dirWatchers[relPath];
497
- if (entry) {
498
- clearTimeout(entry.debounce);
499
- try { entry.watcher.close(); } catch (e) {}
500
- delete dirWatchers[relPath];
501
- }
502
- }
344
+ // --- Knowledge engine (delegated to project-knowledge.js) ---
345
+ var _knowledge = attachKnowledge({
346
+ cwd: cwd,
347
+ isMate: isMate,
348
+ sendTo: sendTo,
349
+ matesModule: matesModule,
350
+ getProjectOwnerId: function () { return projectOwnerId; },
351
+ });
503
352
 
504
- function stopAllDirWatches() {
505
- var paths = Object.keys(dirWatchers);
506
- for (var i = 0; i < paths.length; i++) {
507
- stopDirWatch(paths[i]);
508
- }
509
- }
353
+ // --- File/directory watcher engine (delegated to project-file-watch.js) ---
354
+ var _fileWatch = attachFileWatch({
355
+ cwd: cwd,
356
+ send: send,
357
+ safePath: safePath,
358
+ BINARY_EXTS: BINARY_EXTS,
359
+ FS_MAX_SIZE: FS_MAX_SIZE,
360
+ IGNORED_DIRS: IGNORED_DIRS,
361
+ });
362
+ var startFileWatch = _fileWatch.startFileWatch;
363
+ var stopFileWatch = _fileWatch.stopFileWatch;
364
+ var startDirWatch = _fileWatch.startDirWatch;
365
+ var stopDirWatch = _fileWatch.stopDirWatch;
366
+ var stopAllDirWatches = _fileWatch.stopAllDirWatches;
510
367
 
511
368
  // --- Session manager ---
512
369
  var sm = createSessionManager({
@@ -641,3665 +498,206 @@ function createProjectContext(opts) {
641
498
  },
642
499
  });
643
500
 
644
- // --- Ralph Loop state ---
645
- var loopState = {
646
- active: false,
647
- phase: "idle", // idle | crafting | approval | executing | done
648
- promptText: "",
649
- judgeText: "",
650
- iteration: 0,
651
- maxIterations: 20,
652
- baseCommit: null,
653
- currentSessionId: null,
654
- judgeSessionId: null,
655
- results: [],
656
- stopping: false,
657
- wizardData: null,
658
- craftingSessionId: null,
659
- startedAt: null,
660
- loopId: null,
661
- loopFilesId: null,
662
- };
501
+ // --- Loop engine (delegated to project-loop.js) ---
502
+ var _loop = attachLoop({
503
+ cwd: cwd,
504
+ slug: slug,
505
+ sm: sm,
506
+ sdk: sdk,
507
+ send: send,
508
+ sendTo: sendTo,
509
+ sendToSession: sendToSession,
510
+ pushModule: pushModule,
511
+ getHubSchedules: getHubSchedules,
512
+ getLinuxUserForSession: getLinuxUserForSession,
513
+ onProcessingChanged: onProcessingChanged,
514
+ hydrateImageRefs: hydrateImageRefs,
515
+ });
516
+ var loopState = _loop.loopState;
517
+ var loopRegistry = _loop.loopRegistry;
518
+ var loopDir = _loop.loopDir;
519
+ var startLoop = _loop.startLoop;
520
+ var stopLoop = _loop.stopLoop;
521
+ var resumeLoop = _loop.resumeLoop;
663
522
 
664
- function loopDir() {
665
- var id = loopState.loopFilesId || loopState.loopId;
666
- if (!id) return null;
667
- return path.join(cwd, ".claude", "loops", id);
668
- }
523
+ // Mate CLAUDE.md crisis safety watcher
524
+ var crisisWatcher = null;
525
+ var crisisDebounce = null;
669
526
 
670
- function generateLoopId() {
671
- return "loop_" + Date.now() + "_" + crypto.randomBytes(3).toString("hex");
672
- }
673
527
 
674
- // Loop state persistence
675
- var _loopConfig = require("./config");
676
- var _loopUtils = require("./utils");
677
- var _loopDir = path.join(_loopConfig.CONFIG_DIR, "loops");
678
- var _loopEncodedCwd = _loopUtils.resolveEncodedFile(_loopDir, cwd, ".json");
679
- var _loopStatePath = path.join(_loopDir, _loopEncodedCwd + ".json");
680
528
 
681
- function saveLoopState() {
682
- try {
683
- fs.mkdirSync(_loopDir, { recursive: true });
684
- var data = {
685
- phase: loopState.phase,
686
- active: loopState.active,
687
- iteration: loopState.iteration,
688
- maxIterations: loopState.maxIterations,
689
- baseCommit: loopState.baseCommit,
690
- results: loopState.results,
691
- wizardData: loopState.wizardData,
692
- startedAt: loopState.startedAt,
693
- loopId: loopState.loopId,
694
- loopFilesId: loopState.loopFilesId || null,
695
- };
696
- var tmpPath = _loopStatePath + ".tmp";
697
- fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
698
- fs.renameSync(tmpPath, _loopStatePath);
699
- } catch (e) {
700
- console.error("[ralph-loop] Failed to save state:", e.message);
701
- }
702
- }
529
+ // --- Terminal manager ---
530
+ var tm = createTerminalManager({ cwd: cwd, send: send, sendTo: sendTo });
531
+ var nm = createNotesManager({ cwd: cwd, send: send, sendTo: sendTo });
703
532
 
704
- function loadLoopState() {
705
- try {
706
- var raw = fs.readFileSync(_loopStatePath, "utf8");
707
- var data = JSON.parse(raw);
708
- loopState.phase = data.phase || "idle";
709
- loopState.active = data.active || false;
710
- loopState.iteration = data.iteration || 0;
711
- loopState.maxIterations = data.maxIterations || 20;
712
- loopState.baseCommit = data.baseCommit || null;
713
- loopState.results = data.results || [];
714
- loopState.wizardData = data.wizardData || null;
715
- loopState.startedAt = data.startedAt || null;
716
- loopState.loopId = data.loopId || null;
717
- loopState.loopFilesId = data.loopFilesId || null;
718
- // SDK sessions cannot survive daemon restart
719
- loopState.currentSessionId = null;
720
- loopState.judgeSessionId = null;
721
- loopState.craftingSessionId = null;
722
- loopState.stopping = false;
723
- // If was executing, schedule resume after SDK is ready
724
- if (loopState.phase === "executing" && loopState.active) {
725
- loopState._needsResume = true;
726
- }
727
- // If was crafting, check if files exist and move to approval
728
- if (loopState.phase === "crafting") {
729
- var hasFiles = checkLoopFilesExist();
730
- if (hasFiles) {
731
- loopState.phase = "approval";
732
- saveLoopState();
733
- } else {
734
- loopState.phase = "idle";
735
- saveLoopState();
736
- }
737
- }
738
- } catch (e) {
739
- // No saved state, use defaults
740
- }
741
- // Recover orphaned loops: if idle but completed loop files exist in .claude/loops/
742
- if (loopState.phase === "idle") {
743
- var _loopsBase = path.join(cwd, ".claude", "loops");
744
- try {
745
- var _loopDirs = fs.readdirSync(_loopsBase).filter(function (d) {
746
- return d.indexOf("loop_") === 0;
747
- });
748
- for (var _li = 0; _li < _loopDirs.length; _li++) {
749
- var _ld = path.join(_loopsBase, _loopDirs[_li]);
750
- try {
751
- fs.accessSync(path.join(_ld, "PROMPT.md"));
752
- fs.accessSync(path.join(_ld, "JUDGE.md"));
753
- fs.accessSync(path.join(_ld, "LOOP.json"));
754
- // Found a completed loop — recover to approval phase
755
- loopState.loopId = _loopDirs[_li];
756
- loopState.phase = "approval";
757
- var _loopCfg = JSON.parse(fs.readFileSync(path.join(_ld, "LOOP.json"), "utf8"));
758
- loopState.maxIterations = _loopCfg.maxIterations || 20;
759
- saveLoopState();
760
- console.log("[ralph-loop] Recovered orphaned loop: " + _loopDirs[_li]);
761
- break;
762
- } catch (e) {}
763
- }
764
- } catch (e) {}
533
+ // Check for updates in background (admin only)
534
+ fetchVersion(updateChannel).then(function (v) {
535
+ if (v && isNewer(v, currentVersion)) {
536
+ latestVersion = v;
537
+ sendToAdmins({ type: "update_available", version: v });
765
538
  }
766
- }
539
+ }).catch(function (e) {
540
+ console.error("[project] Background version check failed:", e.message || e);
541
+ });
767
542
 
768
- function clearLoopState() {
769
- loopState.active = false;
770
- loopState.phase = "idle";
771
- loopState.promptText = "";
772
- loopState.judgeText = "";
773
- loopState.iteration = 0;
774
- loopState.maxIterations = 20;
775
- loopState.baseCommit = null;
776
- loopState.currentSessionId = null;
777
- loopState.judgeSessionId = null;
778
- loopState.results = [];
779
- loopState.stopping = false;
780
- loopState.wizardData = null;
781
- loopState.craftingSessionId = null;
782
- loopState.startedAt = null;
783
- loopState.loopId = null;
784
- loopState.loopFilesId = null;
785
- saveLoopState();
543
+ // --- WS connection handler (delegated to project-connection.js) ---
544
+ function handleConnection(ws, wsUser) {
545
+ _connection.handleConnection(ws, wsUser, handleMessage, handleDisconnection);
786
546
  }
787
547
 
788
- function checkLoopFilesExist() {
789
- var dir = loopDir();
790
- if (!dir) return false;
791
- var hasPrompt = false;
792
- var hasJudge = false;
793
- try { fs.accessSync(path.join(dir, "PROMPT.md")); hasPrompt = true; } catch (e) {}
794
- try { fs.accessSync(path.join(dir, "JUDGE.md")); hasJudge = true; } catch (e) {}
795
- return hasPrompt && hasJudge;
548
+ // --- WS message handler ---
549
+ function getSessionForWs(ws) {
550
+ return sm.sessions.get(ws._clayActiveSession) || null;
796
551
  }
797
552
 
798
- // .claude/ directory watcher for PROMPT.md / JUDGE.md
799
- var claudeDirWatcher = null;
800
- var claudeDirDebounce = null;
801
-
802
- // Mate CLAUDE.md crisis safety watcher
803
- var crisisWatcher = null;
804
- var crisisDebounce = null;
805
-
806
- function startClaudeDirWatch() {
807
- if (claudeDirWatcher) return;
808
- var watchDir = loopDir();
809
- if (!watchDir) return;
810
- try { fs.mkdirSync(watchDir, { recursive: true }); } catch (e) {}
811
- try {
812
- claudeDirWatcher = fs.watch(watchDir, function () {
813
- if (claudeDirDebounce) clearTimeout(claudeDirDebounce);
814
- claudeDirDebounce = setTimeout(function () {
815
- broadcastLoopFilesStatus();
816
- }, 300);
817
- });
818
- claudeDirWatcher.on("error", function () {});
819
- } catch (e) {
820
- console.error("[ralph-loop] Failed to watch .claude/:", e.message);
553
+ // --- Schedule / cancel a message (used by WS handler and auto-continue) ---
554
+ function scheduleMessage(session, text, resetsAt) {
555
+ if (!session || !text || !resetsAt) return;
556
+ // Cancel any existing scheduled message
557
+ if (session.scheduledMessage && session.scheduledMessage.timer) {
558
+ clearTimeout(session.scheduledMessage.timer);
821
559
  }
560
+ var isPastReset = resetsAt <= Date.now();
561
+ var schedDelay = isPastReset ? 5000 : Math.max(0, resetsAt - Date.now()) + 60000; // +1min buffer after reset, or 5s for immediate
562
+ var sendsAt = Date.now() + schedDelay;
563
+ var schedEntry = {
564
+ type: "scheduled_message_queued",
565
+ text: text,
566
+ resetsAt: sendsAt,
567
+ scheduledAt: Date.now(),
568
+ };
569
+ sm.sendAndRecord(session, schedEntry);
570
+ session.scheduledMessage = {
571
+ text: text,
572
+ resetsAt: resetsAt,
573
+ timer: setTimeout(function () {
574
+ session.scheduledMessage = null;
575
+ if (session.destroying) return;
576
+ console.log("[project] Scheduled message firing for session " + session.localId);
577
+ sm.sendAndRecord(session, { type: "scheduled_message_sent" });
578
+ var schedUserMsg = { type: "user_message", text: text };
579
+ session.history.push(schedUserMsg);
580
+ sm.appendToSessionFile(session, schedUserMsg);
581
+ sendToSession(session.localId, schedUserMsg);
582
+ session.isProcessing = true;
583
+ onProcessingChanged();
584
+ sendToSession(session.localId, { type: "status", status: "processing" });
585
+ sdk.startQuery(session, text, null, getLinuxUserForSession(session));
586
+ sm.broadcastSessionList();
587
+ }, schedDelay),
588
+ };
822
589
  }
823
590
 
824
- function stopClaudeDirWatch() {
825
- if (claudeDirWatcher) {
826
- claudeDirWatcher.close();
827
- claudeDirWatcher = null;
828
- }
829
- if (claudeDirDebounce) {
830
- clearTimeout(claudeDirDebounce);
831
- claudeDirDebounce = null;
591
+ function cancelScheduledMessage(session) {
592
+ if (!session) return;
593
+ if (session.scheduledMessage && session.scheduledMessage.timer) {
594
+ clearTimeout(session.scheduledMessage.timer);
595
+ session.scheduledMessage = null;
596
+ session.rateLimitAutoContinuePending = false;
597
+ sm.sendAndRecord(session, { type: "scheduled_message_cancelled" });
832
598
  }
833
599
  }
834
600
 
835
- function broadcastLoopFilesStatus() {
836
- var dir = loopDir();
837
- var hasPrompt = false;
838
- var hasJudge = false;
839
- var hasLoopJson = false;
840
- if (dir) {
841
- try { fs.accessSync(path.join(dir, "PROMPT.md")); hasPrompt = true; } catch (e) {}
842
- try { fs.accessSync(path.join(dir, "JUDGE.md")); hasJudge = true; } catch (e) {}
843
- try { fs.accessSync(path.join(dir, "LOOP.json")); hasLoopJson = true; } catch (e) {}
844
- }
845
- send({
846
- type: "ralph_files_status",
847
- promptReady: hasPrompt,
848
- judgeReady: hasJudge,
849
- loopJsonReady: hasLoopJson,
850
- bothReady: hasPrompt && hasJudge,
851
- taskId: loopState.loopId,
852
- });
853
- // Auto-transition to approval phase when both files appear
854
- if (hasPrompt && hasJudge && loopState.phase === "crafting") {
855
- loopState.phase = "approval";
856
- saveLoopState();
857
-
858
- // Parse recommended title from crafting session conversation
859
- if (loopState.craftingSessionId && loopState.loopId) {
860
- var craftSess = sm.sessions.get(loopState.craftingSessionId);
861
- if (craftSess && craftSess.history) {
862
- for (var hi = craftSess.history.length - 1; hi >= 0; hi--) {
863
- var entry = craftSess.history[hi];
864
- var entryText = entry.text || "";
865
- var titleMatch = entryText.match(/\[\[LOOP_TITLE:\s*(.+?)\]\]/);
866
- if (titleMatch) {
867
- var suggestedTitle = titleMatch[1].trim();
868
- if (suggestedTitle) {
869
- loopRegistry.updateRecord(loopState.loopId, { name: suggestedTitle });
870
- }
871
- break;
872
- }
873
- }
874
- }
601
+ function handleMessage(ws, msg) {
602
+ // --- DM messages (delegated to server-level handler) ---
603
+ if (msg.type === "dm_open" || msg.type === "dm_send" || msg.type === "dm_list" || msg.type === "dm_typing" || msg.type === "dm_add_favorite" || msg.type === "dm_remove_favorite" || msg.type === "mate_create" || msg.type === "mate_list" || msg.type === "mate_delete" || msg.type === "mate_update" || msg.type === "mate_readd_builtin" || msg.type === "mate_list_available_builtins") {
604
+ if (typeof opts.onDmMessage === "function") {
605
+ opts.onDmMessage(ws, msg);
875
606
  }
607
+ return;
876
608
  }
877
- }
878
-
879
- // Load persisted state on startup
880
- loadLoopState();
881
-
882
- // --- Loop Registry (unified one-off + scheduled) ---
883
- var activeRegistryId = null; // track which registry record triggered current loop
884
609
 
885
- var loopRegistry = createLoopRegistry({
886
- cwd: cwd,
887
- onTrigger: function (record) {
888
- // Skip trigger if a loop is already active and skipIfRunning is enabled
889
- if (loopState.active || loopState.phase === "executing") {
890
- if (record.skipIfRunning !== false) {
891
- console.log("[loop-registry] Skipping trigger for " + record.name + " — loop already active (skipIfRunning)");
892
- return;
893
- }
894
- console.log("[loop-registry] Loop active but skipIfRunning disabled for " + record.name + "; deferring");
895
- return;
896
- }
610
+ // --- @Mention: invoke another Mate inline ---
611
+ if (msg.type === "mention") {
612
+ handleMention(ws, msg);
613
+ return;
614
+ }
897
615
 
898
- // For schedule records, resolve the linked task to get loop files
899
- var loopFilesId = record.id;
900
- if (record.source === "schedule") {
901
- if (!record.linkedTaskId) {
902
- console.error("[loop-registry] Schedule has no linked task: " + record.name);
903
- return;
616
+ if (msg.type === "mention_stop") {
617
+ var session = getSessionForWs(ws);
618
+ if (session && session._mentionInProgress) {
619
+ // Abort the active mention session for this mate
620
+ var mateId = msg.mateId;
621
+ if (mateId && session._mentionSessions && session._mentionSessions[mateId]) {
622
+ session._mentionSessions[mateId].abort();
623
+ session._mentionSessions[mateId].close();
624
+ delete session._mentionSessions[mateId];
904
625
  }
905
- loopFilesId = record.linkedTaskId;
906
- console.log("[loop-registry] Schedule triggered: " + record.name + " linked task " + loopFilesId);
907
- }
908
-
909
- // Verify the loop directory and PROMPT.md exist
910
- var recDir = path.join(cwd, ".claude", "loops", loopFilesId);
911
- try {
912
- fs.accessSync(path.join(recDir, "PROMPT.md"));
913
- } catch (e) {
914
- console.error("[loop-registry] PROMPT.md missing for " + loopFilesId);
915
- return;
626
+ session._mentionInProgress = false;
627
+ sendToSession(session.localId, { type: "mention_done", mateId: mateId, stopped: true });
628
+ send({ type: "mention_processing", mateId: mateId, active: false });
916
629
  }
917
- // Set the loopId to the schedule's own id (not the linked task) so sidebar groups correctly
918
- loopState.loopId = record.id;
919
- loopState.loopFilesId = loopFilesId;
920
- loopState.wizardData = null;
921
- activeRegistryId = record.id;
922
- console.log("[loop-registry] Auto-starting loop: " + record.name + " (" + loopState.loopId + ")");
923
- send({ type: "schedule_run_started", recordId: record.id });
924
- startLoop({ maxIterations: record.maxIterations, name: record.name });
925
- },
926
- onChange: function () {
927
- send({ type: "loop_registry_updated", records: getHubSchedules() });
928
- },
929
- });
930
- loopRegistry.load();
931
- loopRegistry.startTimer();
932
-
933
- // Wire loop info resolution for session list broadcasts
934
- sm.setResolveLoopInfo(function (loopId) {
935
- var rec = loopRegistry.getById(loopId);
936
- if (!rec) return null;
937
- return { name: rec.name || null, source: rec.source || null };
938
- });
630
+ return;
631
+ }
939
632
 
940
- function startLoop(opts) {
941
- var loopOpts = opts || {};
942
- var dir = loopDir();
943
- if (!dir) {
944
- send({ type: "loop_error", text: "No loop directory. Run the wizard first." });
633
+ // --- Debate ---
634
+ if (msg.type === "debate_start") {
635
+ handleDebateStart(ws, msg);
945
636
  return;
946
637
  }
947
- var promptPath = path.join(dir, "PROMPT.md");
948
- var judgePath = path.join(dir, "JUDGE.md");
949
- var promptText, judgeText;
950
- try {
951
- promptText = fs.readFileSync(promptPath, "utf8");
952
- } catch (e) {
953
- send({ type: "loop_error", text: "Missing PROMPT.md in " + dir });
638
+ if (msg.type === "debate_hand_raise") {
639
+ handleDebateHandRaise(ws);
954
640
  return;
955
641
  }
956
- try {
957
- judgeText = fs.readFileSync(judgePath, "utf8");
958
- } catch (e) {
959
- judgeText = null;
642
+ if (msg.type === "debate_comment") {
643
+ handleDebateComment(ws, msg);
644
+ return;
960
645
  }
961
-
962
- var baseCommit;
963
- try {
964
- baseCommit = execFileSync("git", ["rev-parse", "HEAD"], {
965
- cwd: cwd, encoding: "utf8", timeout: 5000,
966
- }).trim();
967
- } catch (e) {
968
- send({ type: "loop_error", text: "Failed to get git HEAD: " + e.message });
646
+ if (msg.type === "debate_stop") {
647
+ handleDebateStop(ws);
969
648
  return;
970
649
  }
971
-
972
- // Read loop config from LOOP.json in loop directory
973
- var loopConfig = {};
974
- try {
975
- loopConfig = JSON.parse(fs.readFileSync(path.join(dir, "LOOP.json"), "utf8"));
976
- } catch (e) {}
977
-
978
- loopState.active = true;
979
- loopState.phase = "executing";
980
- loopState.promptText = promptText;
981
- loopState.judgeText = judgeText;
982
- loopState.iteration = 0;
983
- loopState.maxIterations = judgeText ? ((loopOpts.maxIterations >= 1 ? loopOpts.maxIterations : null) || loopConfig.maxIterations || 20) : 1;
984
- loopState.baseCommit = baseCommit;
985
- loopState.currentSessionId = null;
986
- loopState.judgeSessionId = null;
987
- loopState.results = [];
988
- loopState.stopping = false;
989
- loopState.name = loopOpts.name || null;
990
- loopState.startedAt = Date.now();
991
- saveLoopState();
992
-
993
- stopClaudeDirWatch();
994
-
995
- send({ type: "loop_started", maxIterations: loopState.maxIterations, name: loopState.name });
996
- runNextIteration();
997
- }
998
-
999
- function runNextIteration() {
1000
- console.log("[ralph-loop] runNextIteration called, iteration: " + loopState.iteration + ", active: " + loopState.active + ", stopping: " + loopState.stopping);
1001
- if (!loopState.active || loopState.stopping) {
1002
- finishLoop("stopped");
650
+ if (msg.type === "debate_conclude_response") {
651
+ handleDebateConcludeResponse(ws, msg);
1003
652
  return;
1004
653
  }
1005
-
1006
- loopState.iteration++;
1007
- if (loopState.iteration > loopState.maxIterations) {
1008
- finishLoop("max_iterations");
654
+ if (msg.type === "debate_confirm_brief") {
655
+ handleDebateConfirmBrief(ws);
1009
656
  return;
1010
657
  }
1011
-
1012
- var session = sm.createSession();
1013
- var loopSource = loopRegistry.getById(loopState.loopId);
1014
- var loopName = (loopState.wizardData && loopState.wizardData.name) || (loopSource && loopSource.name) || "";
1015
- var loopSourceTag = (loopSource && loopSource.source) || null;
1016
- var isRalphLoop = loopSourceTag === "ralph";
1017
- session.loop = { active: true, iteration: loopState.iteration, role: "coder", loopId: loopState.loopId, name: loopName, source: loopSourceTag, startedAt: loopState.startedAt };
1018
- session.title = (isRalphLoop ? "Ralph" : "Task") + (loopName ? " " + loopName : "") + " #" + loopState.iteration;
1019
- sm.saveSessionFile(session);
1020
- sm.broadcastSessionList();
1021
-
1022
- loopState.currentSessionId = session.localId;
1023
-
1024
- send({
1025
- type: "loop_iteration",
1026
- iteration: loopState.iteration,
1027
- maxIterations: loopState.maxIterations,
1028
- sessionId: session.localId,
1029
- });
1030
-
1031
- var coderCompleted = false;
1032
- session.onQueryComplete = function(completedSession) {
1033
- if (coderCompleted) return;
1034
- coderCompleted = true;
1035
- if (coderWatchdog) { clearTimeout(coderWatchdog); coderWatchdog = null; }
1036
- console.log("[ralph-loop] Coder #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
1037
- if (!loopState.active) { console.log("[ralph-loop] Coder: loopState.active is false, skipping"); return; }
1038
- // Check if session ended with error
1039
- var lastItems = completedSession.history.slice(-3);
1040
- var hadError = false;
1041
- for (var i = 0; i < lastItems.length; i++) {
1042
- if (lastItems[i].type === "error" || (lastItems[i].type === "done" && lastItems[i].code === 1)) {
1043
- hadError = true;
1044
- break;
658
+ if (msg.type === "debate_proposal_response") {
659
+ // Match the most recent pending proposal (proposalId may not be
660
+ // available on the client since it's not part of the tool input)
661
+ var _dpKeys = Object.keys(_pendingDebateProposals);
662
+ if (_dpKeys.length === 0) return;
663
+ var _dpKey = msg.proposalId || _dpKeys[_dpKeys.length - 1];
664
+ var pending = _pendingDebateProposals[_dpKey];
665
+ if (!pending) return;
666
+ delete _pendingDebateProposals[_dpKey];
667
+ if (msg.action === "start") {
668
+ // Set up debate state on the session, then transition to live
669
+ var _dpSession = getSessionForWs(ws);
670
+ if (_dpSession) {
671
+ var _dpMateId = isMate ? path.basename(cwd) : null;
672
+ handleMcpDebateApproval(_dpSession, pending.briefData, _dpMateId, ws);
1045
673
  }
1046
- }
1047
- if (hadError) {
1048
- loopState.results.push({
1049
- iteration: loopState.iteration,
1050
- verdict: "error",
1051
- summary: "Iteration ended with error",
1052
- });
1053
- send({
1054
- type: "loop_verdict",
1055
- iteration: loopState.iteration,
1056
- verdict: "error",
1057
- summary: "Iteration ended with error, retrying...",
1058
- });
1059
- setTimeout(function() { runNextIteration(); }, 2000);
1060
- return;
1061
- }
1062
- if (loopState.judgeText && loopState.maxIterations > 1) {
1063
- runJudge();
674
+ pending.resolve({ action: "start" });
1064
675
  } else {
1065
- finishLoop("pass");
1066
- }
1067
- };
1068
-
1069
- // Watchdog: if onQueryComplete hasn't fired after 10 minutes, force error and retry
1070
- var coderWatchdog = setTimeout(function() {
1071
- if (!coderCompleted && loopState.active && !loopState.stopping) {
1072
- console.error("[ralph-loop] Coder #" + loopState.iteration + " watchdog triggered — onQueryComplete never fired");
1073
- coderCompleted = true;
1074
- loopState.results.push({
1075
- iteration: loopState.iteration,
1076
- verdict: "error",
1077
- summary: "Coder session timed out (no completion signal)",
1078
- });
1079
- send({
1080
- type: "loop_verdict",
1081
- iteration: loopState.iteration,
1082
- verdict: "error",
1083
- summary: "Coder session timed out, retrying...",
1084
- });
1085
- setTimeout(function() { runNextIteration(); }, 2000);
676
+ pending.resolve({ action: "cancel" });
1086
677
  }
1087
- }, 10 * 60 * 1000);
1088
-
1089
- var userMsg = { type: "user_message", text: loopState.promptText };
1090
- session.history.push(userMsg);
1091
- sm.appendToSessionFile(session, userMsg);
1092
-
1093
- session.isProcessing = true;
1094
- onProcessingChanged();
1095
- session.sentToolResults = {};
1096
- sendToSession(session.localId, { type: "status", status: "processing" });
1097
- session.acceptEditsAfterStart = true;
1098
- session.singleTurn = true;
1099
- sdk.startQuery(session, loopState.promptText, undefined, getLinuxUserForSession(session));
1100
- }
1101
-
1102
- function runJudge() {
1103
- if (!loopState.active || loopState.stopping) {
1104
- finishLoop("stopped");
678
+ return;
679
+ }
680
+ if (msg.type === "debate_user_floor_response") {
681
+ handleDebateUserFloorResponse(ws, msg);
1105
682
  return;
1106
683
  }
1107
684
 
1108
- var diff;
1109
- try {
1110
- diff = execFileSync("git", ["diff", loopState.baseCommit], {
1111
- cwd: cwd, encoding: "utf8", timeout: 30000,
1112
- maxBuffer: 10 * 1024 * 1024,
1113
- });
1114
- } catch (e) {
1115
- send({ type: "loop_error", text: "Failed to generate git diff: " + e.message });
1116
- finishLoop("error");
1117
- return;
1118
- }
1119
-
1120
- var gitLog = "";
1121
- try {
1122
- gitLog = execFileSync("git", ["log", "--oneline", loopState.baseCommit + "..HEAD"], {
1123
- cwd: cwd, encoding: "utf8", timeout: 10000,
1124
- }).trim();
1125
- } catch (e) {}
1126
-
1127
- var judgePrompt = "You are a judge evaluating whether a coding task has been completed.\n\n" +
1128
- "## Original Task (PROMPT.md)\n\n" + loopState.promptText + "\n\n" +
1129
- "## Evaluation Criteria (JUDGE.md)\n\n" + loopState.judgeText + "\n\n" +
1130
- "## Commit History\n\n```\n" + (gitLog || "(no commits yet)") + "\n```\n\n" +
1131
- "## Changes Made (git diff)\n\n```diff\n" + diff + "\n```\n\n" +
1132
- "Based on the evaluation criteria, has the task been completed successfully?\n\n" +
1133
- "IMPORTANT: The git diff above may not show everything. If criteria involve checking whether " +
1134
- "specific files, classes, or features exist, use tools (Read, Glob, Grep, Bash) to verify " +
1135
- "directly in the codebase. Do NOT assume something is missing just because it is not in the diff.\n\n" +
1136
- "After your evaluation, respond with exactly one of:\n" +
1137
- "- PASS: [brief explanation]\n" +
1138
- "- FAIL: [brief explanation of what is still missing]";
1139
-
1140
- var judgeSession = sm.createSession();
1141
- var judgeSource = loopRegistry.getById(loopState.loopId);
1142
- var judgeName = (loopState.wizardData && loopState.wizardData.name) || (judgeSource && judgeSource.name) || "";
1143
- var judgeSourceTag = (judgeSource && judgeSource.source) || null;
1144
- var isRalphJudge = judgeSourceTag === "ralph";
1145
- judgeSession.loop = { active: true, iteration: loopState.iteration, role: "judge", loopId: loopState.loopId, name: judgeName, source: judgeSourceTag, startedAt: loopState.startedAt };
1146
- judgeSession.title = (isRalphJudge ? "Ralph" : "Task") + (judgeName ? " " + judgeName : "") + " Judge #" + loopState.iteration;
1147
- sm.saveSessionFile(judgeSession);
1148
- sm.broadcastSessionList();
1149
- loopState.judgeSessionId = judgeSession.localId;
1150
-
1151
- send({
1152
- type: "loop_judging",
1153
- iteration: loopState.iteration,
1154
- sessionId: judgeSession.localId,
1155
- });
1156
-
1157
- var judgeCompleted = false;
1158
- judgeSession.onQueryComplete = function(completedSession) {
1159
- if (judgeCompleted) return;
1160
- judgeCompleted = true;
1161
- if (judgeWatchdog) { clearTimeout(judgeWatchdog); judgeWatchdog = null; }
1162
- console.log("[ralph-loop] Judge #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
1163
- var verdict = parseJudgeVerdict(completedSession);
1164
- console.log("[ralph-loop] Judge verdict: " + (verdict.pass ? "PASS" : "FAIL") + " - " + verdict.explanation);
1165
-
1166
- loopState.results.push({
1167
- iteration: loopState.iteration,
1168
- verdict: verdict.pass ? "pass" : "fail",
1169
- summary: verdict.explanation,
1170
- });
1171
-
1172
- send({
1173
- type: "loop_verdict",
1174
- iteration: loopState.iteration,
1175
- verdict: verdict.pass ? "pass" : "fail",
1176
- summary: verdict.explanation,
1177
- });
1178
-
1179
- if (verdict.pass) {
1180
- finishLoop("pass");
1181
- } else {
1182
- setTimeout(function() { runNextIteration(); }, 1000);
1183
- }
1184
- };
1185
-
1186
- // Watchdog: judge may use tools to verify, so allow more time
1187
- var judgeWatchdog = setTimeout(function() {
1188
- if (!judgeCompleted && loopState.active && !loopState.stopping) {
1189
- console.error("[ralph-loop] Judge #" + loopState.iteration + " watchdog triggered — onQueryComplete never fired");
1190
- judgeCompleted = true;
1191
- loopState.results.push({
1192
- iteration: loopState.iteration,
1193
- verdict: "error",
1194
- summary: "Judge session timed out (no completion signal)",
1195
- });
1196
- send({
1197
- type: "loop_verdict",
1198
- iteration: loopState.iteration,
1199
- verdict: "error",
1200
- summary: "Judge session timed out, retrying...",
1201
- });
1202
- setTimeout(function() { runNextIteration(); }, 2000);
1203
- }
1204
- }, 10 * 60 * 1000);
1205
-
1206
- var userMsg = { type: "user_message", text: judgePrompt };
1207
- judgeSession.history.push(userMsg);
1208
- sm.appendToSessionFile(judgeSession, userMsg);
1209
-
1210
- judgeSession.isProcessing = true;
1211
- onProcessingChanged();
1212
- judgeSession.sentToolResults = {};
1213
- judgeSession.acceptEditsAfterStart = true;
1214
- judgeSession.singleTurn = true;
1215
- sdk.startQuery(judgeSession, judgePrompt, undefined, getLinuxUserForSession(judgeSession));
1216
- }
1217
-
1218
- function parseJudgeVerdict(session) {
1219
- var text = "";
1220
- for (var i = 0; i < session.history.length; i++) {
1221
- var h = session.history[i];
1222
- if (h.type === "delta" && h.text) text += h.text;
1223
- if (h.type === "text" && h.text) text += h.text;
1224
- }
1225
- console.log("[ralph-loop] Judge raw text (last 500 chars): " + text.slice(-500));
1226
- var upper = text.toUpperCase();
1227
- var passIdx = upper.indexOf("PASS");
1228
- var failIdx = upper.indexOf("FAIL");
1229
- if (passIdx !== -1 && (failIdx === -1 || passIdx < failIdx)) {
1230
- var explanation = text.substring(passIdx + 4).replace(/^[\s:]+/, "").split("\n")[0].trim();
1231
- return { pass: true, explanation: explanation || "Task completed" };
1232
- }
1233
- if (failIdx !== -1) {
1234
- var explanation = text.substring(failIdx + 4).replace(/^[\s:]+/, "").split("\n")[0].trim();
1235
- return { pass: false, explanation: explanation || "Task not yet complete" };
1236
- }
1237
- return { pass: false, explanation: "Could not parse judge verdict" };
1238
- }
1239
-
1240
- function finishLoop(reason) {
1241
- console.log("[ralph-loop] finishLoop called, reason: " + reason + ", iteration: " + loopState.iteration);
1242
- loopState.active = false;
1243
- loopState.phase = "done";
1244
- loopState.stopping = false;
1245
- loopState.currentSessionId = null;
1246
- loopState.judgeSessionId = null;
1247
- saveLoopState();
1248
-
1249
- send({
1250
- type: "loop_finished",
1251
- reason: reason,
1252
- iterations: loopState.iteration,
1253
- results: loopState.results,
1254
- });
1255
-
1256
- // Record result in loop registry
1257
- if (loopState.loopId) {
1258
- loopRegistry.recordRun(loopState.loopId, {
1259
- reason: reason,
1260
- startedAt: loopState.startedAt,
1261
- iterations: loopState.iteration,
1262
- });
1263
- }
1264
- if (activeRegistryId) {
1265
- send({ type: "schedule_run_finished", recordId: activeRegistryId, reason: reason, iterations: loopState.iteration });
1266
- activeRegistryId = null;
1267
- }
1268
-
1269
- if (pushModule) {
1270
- var body = reason === "pass"
1271
- ? "Task completed after " + loopState.iteration + " iteration(s)"
1272
- : reason === "max_iterations"
1273
- ? "Reached max iterations (" + loopState.maxIterations + ")"
1274
- : reason === "stopped"
1275
- ? "Loop stopped by user"
1276
- : "Loop ended due to error";
1277
- pushModule.sendPush({
1278
- type: "done",
1279
- slug: slug,
1280
- title: "Ralph Loop Complete",
1281
- body: body,
1282
- tag: "ralph-loop-done",
1283
- });
1284
- }
1285
- }
1286
-
1287
- function resumeLoop() {
1288
- var dir = loopDir();
1289
- if (!dir) {
1290
- console.error("[ralph-loop] Cannot resume: no loop directory");
1291
- loopState.active = false;
1292
- loopState.phase = "idle";
1293
- saveLoopState();
1294
- return;
1295
- }
1296
- try {
1297
- loopState.promptText = fs.readFileSync(path.join(dir, "PROMPT.md"), "utf8");
1298
- } catch (e) {
1299
- console.error("[ralph-loop] Cannot resume: missing PROMPT.md");
1300
- loopState.active = false;
1301
- loopState.phase = "idle";
1302
- saveLoopState();
1303
- return;
1304
- }
1305
- try {
1306
- loopState.judgeText = fs.readFileSync(path.join(dir, "JUDGE.md"), "utf8");
1307
- } catch (e) {
1308
- console.error("[ralph-loop] Cannot resume: missing JUDGE.md");
1309
- loopState.active = false;
1310
- loopState.phase = "idle";
1311
- saveLoopState();
1312
- return;
1313
- }
1314
- // Retry the interrupted iteration (runNextIteration will increment)
1315
- if (loopState.iteration > 0) {
1316
- loopState.iteration--;
1317
- }
1318
- console.log("[ralph-loop] Resuming loop, next iteration will be " + (loopState.iteration + 1) + "/" + loopState.maxIterations);
1319
- send({ type: "loop_started", maxIterations: loopState.maxIterations });
1320
- runNextIteration();
1321
- }
1322
-
1323
- function stopLoop() {
1324
- if (!loopState.active) return;
1325
- console.log("[ralph-loop] stopLoop called");
1326
- loopState.stopping = true;
1327
-
1328
- // Abort all loop-related sessions (coder + judge)
1329
- var sessionIds = [loopState.currentSessionId, loopState.judgeSessionId];
1330
- for (var i = 0; i < sessionIds.length; i++) {
1331
- if (sessionIds[i] == null) continue;
1332
- var s = sm.sessions.get(sessionIds[i]);
1333
- if (!s) continue;
1334
- // End message queue so SDK exits prompt wait
1335
- if (s.messageQueue) { try { s.messageQueue.end(); } catch (e) {} }
1336
- // Abort active API call
1337
- if (s.abortController) { try { s.abortController.abort(); } catch (e) {} }
1338
- }
1339
-
1340
- send({ type: "loop_stopping" });
1341
-
1342
- // Fallback: force finish if onQueryComplete hasn't fired after 5s
1343
- setTimeout(function() {
1344
- if (loopState.active && loopState.stopping) {
1345
- console.log("[ralph-loop] Stop fallback triggered — forcing finishLoop");
1346
- finishLoop("stopped");
1347
- }
1348
- }, 5000);
1349
- }
1350
-
1351
- // --- Terminal manager ---
1352
- var tm = createTerminalManager({ cwd: cwd, send: send, sendTo: sendTo });
1353
- var nm = createNotesManager({ cwd: cwd, send: send, sendTo: sendTo });
1354
-
1355
- // Check for updates in background (admin only)
1356
- fetchVersion(updateChannel).then(function (v) {
1357
- if (v && isNewer(v, currentVersion)) {
1358
- latestVersion = v;
1359
- sendToAdmins({ type: "update_available", version: v });
1360
- }
1361
- }).catch(function (e) {
1362
- console.error("[project] Background version check failed:", e.message || e);
1363
- });
1364
-
1365
- // --- WS connection handler ---
1366
- function handleConnection(ws, wsUser) {
1367
- ws._clayUser = wsUser || null;
1368
- clients.add(ws);
1369
- broadcastClientCount();
1370
-
1371
- // Resume loop if server restarted mid-execution (deferred so client gets initial state first)
1372
- if (loopState._needsResume) {
1373
- delete loopState._needsResume;
1374
- setTimeout(function() { resumeLoop(); }, 500);
1375
- }
1376
-
1377
- // Auto-assign owner if project has none and a user connects (e.g. IPC-added projects)
1378
- if (!projectOwnerId && ws._clayUser && ws._clayUser.id && !isMate) {
1379
- projectOwnerId = ws._clayUser.id;
1380
- if (opts.onProjectOwnerChanged) {
1381
- opts.onProjectOwnerChanged(slug, projectOwnerId);
1382
- }
1383
- console.log("[project] Auto-assigned owner for " + slug + ": " + projectOwnerId);
1384
- }
1385
-
1386
- // Send cached state
1387
- var _userId = ws._clayUser ? ws._clayUser.id : null;
1388
- var _filteredProjects = getProjectList(_userId);
1389
- sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, dangerouslySkipPermissions: dangerouslySkipPermissions, osUsers: osUsers, lanHost: lanHost, projectCount: _filteredProjects.length, projects: _filteredProjects, projectOwnerId: projectOwnerId });
1390
- if (latestVersion && ws._clayUser && ws._clayUser.role === "admin") {
1391
- sendTo(ws, { type: "update_available", version: latestVersion });
1392
- }
1393
- if (sm.slashCommands) {
1394
- sendTo(ws, { type: "slash_commands", commands: sm.slashCommands });
1395
- }
1396
- if (sm.currentModel) {
1397
- sendTo(ws, { type: "model_info", model: sm.currentModel, models: sm.availableModels || [] });
1398
- }
1399
- sendTo(ws, { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
1400
- sendTo(ws, { type: "term_list", terminals: tm.list() });
1401
- // Restore context sources (keep tab: sources — validated against _browserTabList at query time)
1402
- var restoredSources = loadContextSources(slug);
1403
- sendTo(ws, { type: "context_sources_state", active: restoredSources });
1404
- sendTo(ws, { type: "notes_list", notes: nm.list() });
1405
- sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules() });
1406
-
1407
- // Ralph Loop availability
1408
- var hasLoopFiles = false;
1409
- try {
1410
- fs.accessSync(path.join(cwd, ".claude", "PROMPT.md"));
1411
- fs.accessSync(path.join(cwd, ".claude", "JUDGE.md"));
1412
- hasLoopFiles = true;
1413
- } catch (e) {}
1414
- // Also check loop directory files
1415
- if (!hasLoopFiles && loopState.loopId) {
1416
- var _avDir = loopDir();
1417
- if (_avDir) {
1418
- try {
1419
- fs.accessSync(path.join(_avDir, "PROMPT.md"));
1420
- fs.accessSync(path.join(_avDir, "JUDGE.md"));
1421
- hasLoopFiles = true;
1422
- } catch (e) {}
1423
- }
1424
- }
1425
- sendTo(ws, {
1426
- type: "loop_available",
1427
- available: hasLoopFiles,
1428
- active: loopState.active,
1429
- iteration: loopState.iteration,
1430
- maxIterations: loopState.maxIterations,
1431
- name: loopState.name || null,
1432
- });
1433
-
1434
- // Ralph phase state
1435
- sendTo(ws, {
1436
- type: "ralph_phase",
1437
- phase: loopState.phase,
1438
- wizardData: loopState.wizardData,
1439
- craftingSessionId: loopState.craftingSessionId || null,
1440
- });
1441
- if (loopState.phase === "crafting" || loopState.phase === "approval") {
1442
- var _hasPrompt = false;
1443
- var _hasJudge = false;
1444
- var _lDir = loopDir();
1445
- if (_lDir) {
1446
- try { fs.accessSync(path.join(_lDir, "PROMPT.md")); _hasPrompt = true; } catch (e) {}
1447
- try { fs.accessSync(path.join(_lDir, "JUDGE.md")); _hasJudge = true; } catch (e) {}
1448
- }
1449
- sendTo(ws, {
1450
- type: "ralph_files_status",
1451
- promptReady: _hasPrompt,
1452
- judgeReady: _hasJudge,
1453
- bothReady: _hasPrompt && _hasJudge,
1454
- taskId: loopState.loopId,
1455
- });
1456
- }
1457
-
1458
- // Session list (filtered for access control)
1459
- var allSessions = [].concat(Array.from(sm.sessions.values())).filter(function (s) { return !s.hidden; });
1460
- if (usersModule.isMultiUser() && wsUser) {
1461
- allSessions = allSessions.filter(function (s) {
1462
- return usersModule.canAccessSession(wsUser.id, s, { visibility: "public" });
1463
- });
1464
- } else if (!usersModule.isMultiUser()) {
1465
- allSessions = allSessions.filter(function (s) { return !s.ownerId; });
1466
- }
1467
- sendTo(ws, {
1468
- type: "session_list",
1469
- sessions: allSessions.map(function (s) {
1470
- var loop = s.loop ? Object.assign({}, s.loop) : null;
1471
- if (loop && loop.loopId && loopRegistry) {
1472
- var rec = loopRegistry.getById(loop.loopId);
1473
- if (rec) {
1474
- if (rec.name) loop.name = rec.name;
1475
- if (rec.source) loop.source = rec.source;
1476
- }
1477
- }
1478
- return {
1479
- id: s.localId,
1480
- cliSessionId: s.cliSessionId || null,
1481
- title: s.title || "New Session",
1482
- active: s.localId === sm.activeSessionId,
1483
- isProcessing: s.isProcessing,
1484
- lastActivity: s.lastActivity || s.createdAt || 0,
1485
- loop: loop,
1486
- ownerId: s.ownerId || null,
1487
- sessionVisibility: s.sessionVisibility || "shared",
1488
- };
1489
- }),
1490
- });
1491
-
1492
- // Restore active session for this client from server-side presence
1493
- var active = null;
1494
- var presenceKey = wsUser ? wsUser.id : "_default";
1495
- var storedPresence = userPresence.getPresence(slug, presenceKey);
1496
- if (storedPresence && storedPresence.sessionId) {
1497
- // Look up stored session by localId
1498
- if (sm.sessions.has(storedPresence.sessionId)) {
1499
- active = sm.sessions.get(storedPresence.sessionId);
1500
- } else {
1501
- // Try matching by cliSessionId (survives server restarts where localIds change)
1502
- sm.sessions.forEach(function (s) {
1503
- if (s.cliSessionId && s.cliSessionId === storedPresence.sessionId) active = s;
1504
- });
1505
- }
1506
- // Validate access
1507
- if (active && usersModule.isMultiUser() && wsUser) {
1508
- if (!usersModule.canAccessSession(wsUser.id, active, { visibility: "public" })) active = null;
1509
- } else if (active && !usersModule.isMultiUser() && active.ownerId) {
1510
- active = null;
1511
- }
1512
- }
1513
- // Fallback: pick the most recent accessible session
1514
- if (!active && allSessions.length > 0) {
1515
- active = allSessions[0];
1516
- for (var fi = 1; fi < allSessions.length; fi++) {
1517
- if ((allSessions[fi].lastActivity || 0) > (active.lastActivity || 0)) {
1518
- active = allSessions[fi];
1519
- }
1520
- }
1521
- }
1522
- // Auto-create a session if none exist for this client
1523
- var autoCreated = false;
1524
- if (!active) {
1525
- var autoOpts = {};
1526
- if (wsUser && usersModule.isMultiUser()) autoOpts.ownerId = wsUser.id;
1527
- active = sm.createSession(autoOpts, ws);
1528
- autoCreated = true;
1529
- }
1530
- if (active && !autoCreated) {
1531
- // Backfill ownerId for legacy sessions restored without one (multi-user only)
1532
- if (!active.ownerId && wsUser && usersModule.isMultiUser()) {
1533
- active.ownerId = wsUser.id;
1534
- sm.saveSessionFile(active);
1535
- }
1536
- ws._clayActiveSession = active.localId;
1537
- sendTo(ws, { type: "session_switched", id: active.localId, cliSessionId: active.cliSessionId || null, loop: active.loop || null });
1538
-
1539
- var total = active.history.length;
1540
- var fromIndex = 0;
1541
- if (total > sm.HISTORY_PAGE_SIZE) {
1542
- fromIndex = sm.findTurnBoundary(active.history, Math.max(0, total - sm.HISTORY_PAGE_SIZE));
1543
- }
1544
- sendTo(ws, { type: "history_meta", total: total, from: fromIndex });
1545
- for (var i = fromIndex; i < total; i++) {
1546
- var _hitem = active.history[i];
1547
- if (_hitem && (_hitem.type === "mention_user" || _hitem.type === "mention_response")) {
1548
- console.log("[DEBUG handleConnection] sending mention at index=" + i + " from=" + fromIndex + " total=" + total + " type=" + _hitem.type + " mate=" + (_hitem.mateName || "") + " slug=" + slug);
1549
- }
1550
- sendTo(ws, hydrateImageRefs(_hitem));
1551
- }
1552
- // Include last result data + cached context usage for accurate restore
1553
- var _lastUsage = null, _lastModelUsage = null, _lastCost = null, _lastStreamInputTokens = null;
1554
- for (var _ri = total - 1; _ri >= 0; _ri--) {
1555
- if (active.history[_ri].type === "result") {
1556
- var _r = active.history[_ri];
1557
- _lastUsage = _r.usage || null;
1558
- _lastModelUsage = _r.modelUsage || null;
1559
- _lastCost = _r.cost != null ? _r.cost : null;
1560
- _lastStreamInputTokens = _r.lastStreamInputTokens || null;
1561
- break;
1562
- }
1563
- }
1564
- sendTo(ws, { type: "history_done", lastUsage: _lastUsage, lastModelUsage: _lastModelUsage, lastCost: _lastCost, lastStreamInputTokens: _lastStreamInputTokens, contextUsage: active.lastContextUsage || null });
1565
-
1566
- if (active.isProcessing) {
1567
- sendTo(ws, { type: "status", status: "processing" });
1568
- }
1569
- var pendingIds = Object.keys(active.pendingPermissions);
1570
- for (var pi = 0; pi < pendingIds.length; pi++) {
1571
- var p = active.pendingPermissions[pendingIds[pi]];
1572
- sendTo(ws, {
1573
- type: "permission_request_pending",
1574
- requestId: p.requestId,
1575
- toolName: p.toolName,
1576
- toolInput: p.toolInput,
1577
- toolUseId: p.toolUseId,
1578
- decisionReason: p.decisionReason,
1579
- mateId: p.mateId || undefined,
1580
- });
1581
- }
1582
- }
1583
-
1584
- // Record presence for this user + send mate DM restore hint if applicable
1585
- if (active) {
1586
- userPresence.setPresence(slug, presenceKey, active.localId, storedPresence ? storedPresence.mateDm : null);
1587
- }
1588
- if (storedPresence && storedPresence.mateDm && !isMate) {
1589
- sendTo(ws, { type: "restore_mate_dm", mateId: storedPresence.mateDm });
1590
- }
1591
-
1592
- broadcastPresence();
1593
-
1594
- // Restore debate state and brief watcher if a debate was in progress
1595
- restoreDebateState(ws);
1596
-
1597
- ws.on("message", function (raw) {
1598
- var msg;
1599
- try { msg = JSON.parse(raw.toString()); } catch (e) { return; }
1600
- handleMessage(ws, msg);
1601
- });
1602
-
1603
- ws.on("close", function () {
1604
- handleDisconnection(ws);
1605
- });
1606
- }
1607
-
1608
- // --- WS message handler ---
1609
- function getSessionForWs(ws) {
1610
- return sm.sessions.get(ws._clayActiveSession) || null;
1611
- }
1612
-
1613
- // --- Schedule / cancel a message (used by WS handler and auto-continue) ---
1614
- function scheduleMessage(session, text, resetsAt) {
1615
- if (!session || !text || !resetsAt) return;
1616
- // Cancel any existing scheduled message
1617
- if (session.scheduledMessage && session.scheduledMessage.timer) {
1618
- clearTimeout(session.scheduledMessage.timer);
1619
- }
1620
- var isPastReset = resetsAt <= Date.now();
1621
- var schedDelay = isPastReset ? 5000 : Math.max(0, resetsAt - Date.now()) + 60000; // +1min buffer after reset, or 5s for immediate
1622
- var sendsAt = Date.now() + schedDelay;
1623
- var schedEntry = {
1624
- type: "scheduled_message_queued",
1625
- text: text,
1626
- resetsAt: sendsAt,
1627
- scheduledAt: Date.now(),
1628
- };
1629
- sm.sendAndRecord(session, schedEntry);
1630
- session.scheduledMessage = {
1631
- text: text,
1632
- resetsAt: resetsAt,
1633
- timer: setTimeout(function () {
1634
- session.scheduledMessage = null;
1635
- if (session.destroying) return;
1636
- console.log("[project] Scheduled message firing for session " + session.localId);
1637
- sm.sendAndRecord(session, { type: "scheduled_message_sent" });
1638
- var schedUserMsg = { type: "user_message", text: text };
1639
- session.history.push(schedUserMsg);
1640
- sm.appendToSessionFile(session, schedUserMsg);
1641
- sendToSession(session.localId, schedUserMsg);
1642
- session.isProcessing = true;
1643
- onProcessingChanged();
1644
- sendToSession(session.localId, { type: "status", status: "processing" });
1645
- sdk.startQuery(session, text, null, getLinuxUserForSession(session));
1646
- sm.broadcastSessionList();
1647
- }, schedDelay),
1648
- };
1649
- }
1650
-
1651
- function cancelScheduledMessage(session) {
1652
- if (!session) return;
1653
- if (session.scheduledMessage && session.scheduledMessage.timer) {
1654
- clearTimeout(session.scheduledMessage.timer);
1655
- session.scheduledMessage = null;
1656
- session.rateLimitAutoContinuePending = false;
1657
- sm.sendAndRecord(session, { type: "scheduled_message_cancelled" });
1658
- }
1659
- }
1660
-
1661
- function handleMessage(ws, msg) {
1662
- // --- DM messages (delegated to server-level handler) ---
1663
- if (msg.type === "dm_open" || msg.type === "dm_send" || msg.type === "dm_list" || msg.type === "dm_typing" || msg.type === "dm_add_favorite" || msg.type === "dm_remove_favorite" || msg.type === "mate_create" || msg.type === "mate_list" || msg.type === "mate_delete" || msg.type === "mate_update" || msg.type === "mate_readd_builtin" || msg.type === "mate_list_available_builtins") {
1664
- if (typeof opts.onDmMessage === "function") {
1665
- opts.onDmMessage(ws, msg);
1666
- }
1667
- return;
1668
- }
1669
-
1670
- // --- @Mention: invoke another Mate inline ---
1671
- if (msg.type === "mention") {
1672
- handleMention(ws, msg);
1673
- return;
1674
- }
1675
-
1676
- if (msg.type === "mention_stop") {
1677
- var session = getSessionForWs(ws);
1678
- if (session && session._mentionInProgress) {
1679
- // Abort the active mention session for this mate
1680
- var mateId = msg.mateId;
1681
- if (mateId && session._mentionSessions && session._mentionSessions[mateId]) {
1682
- session._mentionSessions[mateId].abort();
1683
- session._mentionSessions[mateId].close();
1684
- delete session._mentionSessions[mateId];
1685
- }
1686
- session._mentionInProgress = false;
1687
- sendToSession(session.localId, { type: "mention_done", mateId: mateId, stopped: true });
1688
- send({ type: "mention_processing", mateId: mateId, active: false });
1689
- }
1690
- return;
1691
- }
1692
-
1693
- // --- Debate ---
1694
- if (msg.type === "debate_start") {
1695
- handleDebateStart(ws, msg);
1696
- return;
1697
- }
1698
- if (msg.type === "debate_hand_raise") {
1699
- handleDebateHandRaise(ws);
1700
- return;
1701
- }
1702
- if (msg.type === "debate_comment") {
1703
- handleDebateComment(ws, msg);
1704
- return;
1705
- }
1706
- if (msg.type === "debate_stop") {
1707
- handleDebateStop(ws);
1708
- return;
1709
- }
1710
- if (msg.type === "debate_conclude_response") {
1711
- handleDebateConcludeResponse(ws, msg);
1712
- return;
1713
- }
1714
- if (msg.type === "debate_confirm_brief") {
1715
- handleDebateConfirmBrief(ws);
1716
- return;
1717
- }
1718
- if (msg.type === "debate_proposal_response") {
1719
- // Match the most recent pending proposal (proposalId may not be
1720
- // available on the client since it's not part of the tool input)
1721
- var _dpKeys = Object.keys(_pendingDebateProposals);
1722
- if (_dpKeys.length === 0) return;
1723
- var _dpKey = msg.proposalId || _dpKeys[_dpKeys.length - 1];
1724
- var pending = _pendingDebateProposals[_dpKey];
1725
- if (!pending) return;
1726
- delete _pendingDebateProposals[_dpKey];
1727
- if (msg.action === "start") {
1728
- // Set up debate state on the session, then transition to live
1729
- var _dpSession = getSessionForWs(ws);
1730
- if (_dpSession) {
1731
- var _dpMateId = isMate ? path.basename(cwd) : null;
1732
- handleMcpDebateApproval(_dpSession, pending.briefData, _dpMateId, ws);
1733
- }
1734
- pending.resolve({ action: "start" });
1735
- } else {
1736
- pending.resolve({ action: "cancel" });
1737
- }
1738
- return;
1739
- }
1740
- if (msg.type === "debate_user_floor_response") {
1741
- handleDebateUserFloorResponse(ws, msg);
1742
- return;
1743
- }
1744
-
1745
- // --- Knowledge file management ---
1746
- if (msg.type === "knowledge_list") {
1747
- var knowledgeDir = path.join(cwd, "knowledge");
1748
- var files = [];
1749
- try {
1750
- var entries = fs.readdirSync(knowledgeDir);
1751
- for (var ki = 0; ki < entries.length; ki++) {
1752
- if (entries[ki] === "session-digests.jsonl") continue;
1753
- if (entries[ki] === "sticky-notes.md") continue;
1754
- if (entries[ki] === "memory-summary.md") continue;
1755
- if (entries[ki].endsWith(".md") || entries[ki].endsWith(".jsonl")) {
1756
- var stat = fs.statSync(path.join(knowledgeDir, entries[ki]));
1757
- files.push({ name: entries[ki], size: stat.size, mtime: stat.mtimeMs, common: false });
1758
- }
1759
- }
1760
- } catch (e) { /* dir may not exist */ }
1761
- files.sort(function (a, b) { return b.mtime - a.mtime; });
1762
-
1763
- // For mate projects, check which files are promoted and include common files from other mates
1764
- if (isMate) {
1765
- var mateCtx = matesModule.buildMateCtx(projectOwnerId);
1766
- var thisMateId = path.basename(cwd);
1767
- // Tag promoted files
1768
- for (var pi = 0; pi < files.length; pi++) {
1769
- files[pi].promoted = matesModule.isPromoted(mateCtx, thisMateId, files[pi].name);
1770
- }
1771
- // Get common files from other mates
1772
- var commonFiles = matesModule.getCommonKnowledgeForMate(mateCtx, thisMateId);
1773
- // Filter out entries that belong to THIS mate (those are already in the list as promoted)
1774
- for (var ci = 0; ci < commonFiles.length; ci++) {
1775
- if (commonFiles[ci].ownMateId !== thisMateId) {
1776
- files.push(commonFiles[ci]);
1777
- }
1778
- }
1779
- }
1780
-
1781
- sendTo(ws, { type: "knowledge_list", files: files });
1782
- return;
1783
- }
1784
-
1785
- if (msg.type === "knowledge_read") {
1786
- if (!msg.name) return;
1787
- var safeName = path.basename(msg.name);
1788
- var filePath;
1789
- if (msg.common && msg.ownMateId && isMate) {
1790
- // Reading a common file from another mate
1791
- var mateCtx = matesModule.buildMateCtx(projectOwnerId);
1792
- try {
1793
- var content = matesModule.readCommonKnowledgeFile(mateCtx, msg.ownMateId, safeName);
1794
- sendTo(ws, { type: "knowledge_content", name: safeName, content: content, common: true, ownMateId: msg.ownMateId });
1795
- } catch (e) {
1796
- sendTo(ws, { type: "knowledge_content", name: safeName, content: "", error: "File not found", common: true });
1797
- }
1798
- } else {
1799
- filePath = path.join(cwd, "knowledge", safeName);
1800
- try {
1801
- var content = fs.readFileSync(filePath, "utf8");
1802
- sendTo(ws, { type: "knowledge_content", name: safeName, content: content });
1803
- } catch (e) {
1804
- sendTo(ws, { type: "knowledge_content", name: safeName, content: "", error: "File not found" });
1805
- }
1806
- }
1807
- return;
1808
- }
1809
-
1810
- if (msg.type === "knowledge_save") {
1811
- if (!msg.name || typeof msg.content !== "string") return;
1812
- var safeName = path.basename(msg.name);
1813
- if (!safeName.endsWith(".md") && !safeName.endsWith(".jsonl")) safeName += ".md";
1814
- var knowledgeDir = path.join(cwd, "knowledge");
1815
- fs.mkdirSync(knowledgeDir, { recursive: true });
1816
- fs.writeFileSync(path.join(knowledgeDir, safeName), msg.content);
1817
- // Return updated list
1818
- var files = [];
1819
- try {
1820
- var entries = fs.readdirSync(knowledgeDir);
1821
- for (var ki = 0; ki < entries.length; ki++) {
1822
- if (entries[ki].endsWith(".md") || entries[ki].endsWith(".jsonl")) {
1823
- var stat = fs.statSync(path.join(knowledgeDir, entries[ki]));
1824
- files.push({ name: entries[ki], size: stat.size, mtime: stat.mtimeMs });
1825
- }
1826
- }
1827
- } catch (e) {}
1828
- files.sort(function (a, b) { return b.mtime - a.mtime; });
1829
- // Tag files for mate projects
1830
- if (isMate) {
1831
- var mateCtx = matesModule.buildMateCtx(projectOwnerId);
1832
- var thisMateId = path.basename(cwd);
1833
- for (var pi = 0; pi < files.length; pi++) {
1834
- files[pi].common = false;
1835
- files[pi].promoted = matesModule.isPromoted(mateCtx, thisMateId, files[pi].name);
1836
- }
1837
- var commonFiles = matesModule.getCommonKnowledgeForMate(mateCtx, thisMateId);
1838
- for (var ci = 0; ci < commonFiles.length; ci++) {
1839
- if (commonFiles[ci].ownMateId !== thisMateId) files.push(commonFiles[ci]);
1840
- }
1841
- }
1842
- sendTo(ws, { type: "knowledge_saved", name: safeName });
1843
- sendTo(ws, { type: "knowledge_list", files: files });
1844
- return;
1845
- }
1846
-
1847
- if (msg.type === "knowledge_delete") {
1848
- if (!msg.name) return;
1849
- var safeName = path.basename(msg.name);
1850
- var filePath = path.join(cwd, "knowledge", safeName);
1851
- try { fs.unlinkSync(filePath); } catch (e) {}
1852
- // Return updated list
1853
- var knowledgeDir = path.join(cwd, "knowledge");
1854
- var files = [];
1855
- try {
1856
- var entries = fs.readdirSync(knowledgeDir);
1857
- for (var ki = 0; ki < entries.length; ki++) {
1858
- if (entries[ki].endsWith(".md") || entries[ki].endsWith(".jsonl")) {
1859
- var stat = fs.statSync(path.join(knowledgeDir, entries[ki]));
1860
- files.push({ name: entries[ki], size: stat.size, mtime: stat.mtimeMs });
1861
- }
1862
- }
1863
- } catch (e) {}
1864
- files.sort(function (a, b) { return b.mtime - a.mtime; });
1865
- // Tag files for mate projects
1866
- if (isMate) {
1867
- var mateCtx = matesModule.buildMateCtx(projectOwnerId);
1868
- var thisMateId = path.basename(cwd);
1869
- for (var pi = 0; pi < files.length; pi++) {
1870
- files[pi].common = false;
1871
- files[pi].promoted = matesModule.isPromoted(mateCtx, thisMateId, files[pi].name);
1872
- }
1873
- var commonFiles = matesModule.getCommonKnowledgeForMate(mateCtx, thisMateId);
1874
- for (var ci = 0; ci < commonFiles.length; ci++) {
1875
- if (commonFiles[ci].ownMateId !== thisMateId) files.push(commonFiles[ci]);
1876
- }
1877
- }
1878
- sendTo(ws, { type: "knowledge_deleted", name: safeName });
1879
- sendTo(ws, { type: "knowledge_list", files: files });
1880
- return;
1881
- }
1882
-
1883
- if (msg.type === "knowledge_promote") {
1884
- if (!isMate || !msg.name) return;
1885
- var safeName = path.basename(msg.name);
1886
- var mateCtx = matesModule.buildMateCtx(projectOwnerId);
1887
- var thisMateId = path.basename(cwd);
1888
- var mate = matesModule.getMate(mateCtx, thisMateId);
1889
- var mateName = (mate && mate.name) || null;
1890
- matesModule.promoteKnowledge(mateCtx, thisMateId, mateName, safeName);
1891
- sendTo(ws, { type: "knowledge_promoted", name: safeName });
1892
- // Re-send updated list (reuse knowledge_list logic)
1893
- handleMessage(ws, { type: "knowledge_list" });
1894
- return;
1895
- }
1896
-
1897
- if (msg.type === "knowledge_depromote") {
1898
- if (!isMate || !msg.name) return;
1899
- var safeName = path.basename(msg.name);
1900
- var mateCtx = matesModule.buildMateCtx(projectOwnerId);
1901
- var thisMateId = path.basename(cwd);
1902
- matesModule.depromoteKnowledge(mateCtx, thisMateId, safeName);
1903
- sendTo(ws, { type: "knowledge_depromoted", name: safeName });
1904
- handleMessage(ws, { type: "knowledge_list" });
1905
- return;
1906
- }
1907
-
1908
- // --- Memory (session digests) management (delegated to project-memory.js) ---
1909
- if (msg.type === "memory_list") { _memory.handleMemoryList(ws); return; }
1910
- if (msg.type === "memory_search") { _memory.handleMemorySearch(ws, msg); return; }
1911
- if (msg.type === "memory_delete") { _memory.handleMemoryDelete(ws, msg); return; }
1912
-
1913
- if (msg.type === "push_subscribe") {
1914
- var _pushUserId = ws._clayUser ? ws._clayUser.id : null;
1915
- if (pushModule && msg.subscription) pushModule.addSubscription(msg.subscription, msg.replaceEndpoint, _pushUserId);
1916
- return;
1917
- }
1918
-
1919
- if (msg.type === "load_more_history") {
1920
- var session = getSessionForWs(ws);
1921
- if (!session || typeof msg.before !== "number") return;
1922
- var before = msg.before;
1923
- var targetFrom = typeof msg.target === "number" ? msg.target : before - sm.HISTORY_PAGE_SIZE;
1924
- var from = sm.findTurnBoundary(session.history, Math.max(0, targetFrom));
1925
- var to = before;
1926
- var items = session.history.slice(from, to).map(hydrateImageRefs);
1927
- sendTo(ws, {
1928
- type: "history_prepend",
1929
- items: items,
1930
- meta: { from: from, to: to, hasMore: from > 0 },
1931
- });
1932
- return;
1933
- }
1934
-
1935
- if (msg.type === "new_session") {
1936
- var sessionOpts = {};
1937
- if (ws._clayUser && usersModule.isMultiUser()) sessionOpts.ownerId = ws._clayUser.id;
1938
- if (msg.sessionVisibility) sessionOpts.sessionVisibility = msg.sessionVisibility;
1939
- var newSess = sm.createSession(sessionOpts, ws);
1940
- ws._clayActiveSession = newSess.localId;
1941
- var nsPresKey = ws._clayUser ? ws._clayUser.id : "_default";
1942
- userPresence.setPresence(slug, nsPresKey, newSess.localId, null);
1943
- if (usersModule.isMultiUser()) {
1944
- broadcastPresence();
1945
- }
1946
- return;
1947
- }
1948
-
1949
- if (msg.type === "set_session_visibility") {
1950
- if (typeof msg.sessionId === "number" && (msg.visibility === "shared" || msg.visibility === "private")) {
1951
- sm.setSessionVisibility(msg.sessionId, msg.visibility);
1952
- }
1953
- return;
1954
- }
1955
-
1956
- if (msg.type === "transfer_project_owner") {
1957
- var isAdmin = ws._clayUser && ws._clayUser.role === "admin";
1958
- var isProjectOwner = ws._clayUser && projectOwnerId && ws._clayUser.id === projectOwnerId;
1959
- if (!ws._clayUser || (!isAdmin && !isProjectOwner)) {
1960
- sendTo(ws, { type: "error", text: "Only project owners or admins can transfer ownership." });
1961
- return;
1962
- }
1963
- var targetUser = msg.userId ? usersModule.findUserById(msg.userId) : null;
1964
- if (!targetUser) {
1965
- sendTo(ws, { type: "error", text: "User not found." });
1966
- return;
1967
- }
1968
- projectOwnerId = targetUser.id;
1969
- // Persist via daemon callback
1970
- if (opts.onProjectOwnerChanged) {
1971
- opts.onProjectOwnerChanged(slug, projectOwnerId);
1972
- }
1973
- send({ type: "project_owner_changed", ownerId: projectOwnerId, ownerName: targetUser.displayName || targetUser.username });
1974
- return;
1975
- }
1976
-
1977
- if (msg.type === "resume_session") {
1978
- if (!msg.cliSessionId) return;
1979
- var cliSess = require("./cli-sessions");
1980
- // Try SDK for title first, then fall back to manual parsing
1981
- var titlePromise = getSDK().then(function(sdkMod) {
1982
- return sdkMod.getSessionInfo(msg.cliSessionId, { dir: cwd });
1983
- }).then(function(info) {
1984
- return (info && info.summary) ? info.summary.substring(0, 100) : null;
1985
- }).catch(function() { return null; });
1986
-
1987
- Promise.all([
1988
- cliSess.readCliSessionHistory(cwd, msg.cliSessionId),
1989
- titlePromise
1990
- ]).then(function(results) {
1991
- var history = results[0];
1992
- var sdkTitle = results[1];
1993
- var title = sdkTitle || "Resumed session";
1994
- if (!sdkTitle) {
1995
- for (var i = 0; i < history.length; i++) {
1996
- if (history[i].type === "user_message" && history[i].text) {
1997
- title = history[i].text.substring(0, 50);
1998
- break;
1999
- }
2000
- }
2001
- }
2002
- var resumed = sm.resumeSession(msg.cliSessionId, { history: history, title: title }, ws);
2003
- if (resumed) ws._clayActiveSession = resumed.localId;
2004
- }).catch(function() {
2005
- var resumed = sm.resumeSession(msg.cliSessionId, undefined, ws);
2006
- if (resumed) ws._clayActiveSession = resumed.localId;
2007
- });
2008
- return;
2009
- }
2010
-
2011
- if (msg.type === "list_cli_sessions") {
2012
- var _fs = require("fs");
2013
- // Collect session IDs already in relay (in-memory + persisted on disk)
2014
- var relayIds = {};
2015
- sm.sessions.forEach(function (s) {
2016
- if (s.cliSessionId) relayIds[s.cliSessionId] = true;
2017
- });
2018
- try {
2019
- var sessDir = sm.sessionsDir;
2020
- var diskFiles = _fs.readdirSync(sessDir);
2021
- for (var fi = 0; fi < diskFiles.length; fi++) {
2022
- if (diskFiles[fi].endsWith(".jsonl")) {
2023
- relayIds[diskFiles[fi].replace(".jsonl", "")] = true;
2024
- }
2025
- }
2026
- } catch (e) {}
2027
-
2028
- getSDK().then(function(sdkMod) {
2029
- return sdkMod.listSessions({ dir: cwd });
2030
- }).then(function(sdkSessions) {
2031
- var filtered = sdkSessions.filter(function(s) {
2032
- return !relayIds[s.sessionId];
2033
- }).map(function(s) {
2034
- return {
2035
- sessionId: s.sessionId,
2036
- firstPrompt: s.summary || s.firstPrompt || "",
2037
- model: null,
2038
- gitBranch: s.gitBranch || null,
2039
- startTime: s.createdAt ? new Date(s.createdAt).toISOString() : null,
2040
- lastActivity: s.lastModified ? new Date(s.lastModified).toISOString() : null,
2041
- };
2042
- });
2043
- sendTo(ws, { type: "cli_session_list", sessions: filtered });
2044
- }).catch(function() {
2045
- // Fallback to manual parsing if SDK fails
2046
- var cliSessions = require("./cli-sessions");
2047
- cliSessions.listCliSessions(cwd).then(function(sessions) {
2048
- var filtered = sessions.filter(function(s) {
2049
- return !relayIds[s.sessionId];
2050
- });
2051
- sendTo(ws, { type: "cli_session_list", sessions: filtered });
2052
- }).catch(function() {
2053
- sendTo(ws, { type: "cli_session_list", sessions: [] });
2054
- });
2055
- });
2056
- return;
2057
- }
2058
-
2059
-
2060
- if (msg.type === "switch_session") {
2061
- if (msg.id && sm.sessions.has(msg.id)) {
2062
- // Check access in multi-user mode
2063
- if (usersModule.isMultiUser() && ws._clayUser) {
2064
- var switchTarget = sm.sessions.get(msg.id);
2065
- if (!usersModule.canAccessSession(ws._clayUser.id, switchTarget, { visibility: "public" })) return;
2066
- ws._clayActiveSession = msg.id;
2067
- sm.switchSession(msg.id, ws, hydrateImageRefs);
2068
- broadcastPresence();
2069
- } else {
2070
- ws._clayActiveSession = msg.id;
2071
- sm.switchSession(msg.id, ws, hydrateImageRefs);
2072
- }
2073
- var swPresKey = ws._clayUser ? ws._clayUser.id : "_default";
2074
- userPresence.setPresence(slug, swPresKey, msg.id, null);
2075
- }
2076
- return;
2077
- }
2078
-
2079
- if (msg.type === "set_mate_dm") {
2080
- // Only store mateDm on non-mate projects (main project presence).
2081
- // Mate projects should never hold mateDm to avoid circular restore loops.
2082
- if (!isMate) {
2083
- var dmPresKey = ws._clayUser ? ws._clayUser.id : "_default";
2084
- userPresence.setMateDm(slug, dmPresKey, msg.mateId || null);
2085
- }
2086
- return;
2087
- }
2088
-
2089
- if (msg.type === "delete_session") {
2090
- if (ws._clayUser) {
2091
- var sdPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2092
- if (!sdPerms.sessionDelete) {
2093
- sendTo(ws, { type: "error", text: "You do not have permission to delete sessions" });
2094
- return;
2095
- }
2096
- }
2097
- if (msg.id && sm.sessions.has(msg.id)) {
2098
- sm.deleteSession(msg.id, ws);
2099
- }
2100
- return;
2101
- }
2102
-
2103
- if (msg.type === "rename_session") {
2104
- if (msg.id && sm.sessions.has(msg.id) && msg.title) {
2105
- var s = sm.sessions.get(msg.id);
2106
- s.title = String(msg.title).substring(0, 100);
2107
- sm.saveSessionFile(s);
2108
- sm.broadcastSessionList();
2109
- // Sync title to SDK session
2110
- if (s.cliSessionId) {
2111
- getSDK().then(function(sdk) {
2112
- sdk.renameSession(s.cliSessionId, s.title, { dir: cwd }).catch(function(e) {
2113
- console.error("[project] SDK renameSession failed:", e.message);
2114
- });
2115
- }).catch(function() {});
2116
- }
2117
- }
2118
- return;
2119
- }
2120
-
2121
- if (msg.type === "search_sessions") {
2122
- var results = sm.searchSessions(msg.query || "");
2123
- sendTo(ws, { type: "search_results", query: msg.query || "", results: results });
2124
- return;
2125
- }
2126
-
2127
- if (msg.type === "search_session_content") {
2128
- var targetSession = msg.id ? sm.sessions.get(msg.id) : getSessionForWs(ws);
2129
- if (!targetSession) return;
2130
- var contentResults = sm.searchSessionContent(targetSession.localId, msg.query || "");
2131
- var searchResp = { type: "search_content_results", query: msg.query || "", sessionId: targetSession.localId, hits: contentResults.hits, total: contentResults.total };
2132
- if (msg.source) searchResp.source = msg.source;
2133
- sendTo(ws, searchResp);
2134
- return;
2135
- }
2136
-
2137
- if (msg.type === "set_update_channel") {
2138
- if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return;
2139
- var newChannel = msg.channel === "beta" ? "beta" : "stable";
2140
- updateChannel = newChannel;
2141
- latestVersion = null;
2142
- if (typeof opts.onSetUpdateChannel === "function") {
2143
- opts.onSetUpdateChannel(newChannel);
2144
- }
2145
- // Re-fetch with new channel and broadcast to admin clients
2146
- fetchVersion(updateChannel).then(function (v) {
2147
- if (v && isNewer(v, currentVersion)) {
2148
- latestVersion = v;
2149
- sendToAdmins({ type: "update_available", version: v });
2150
- }
2151
- }).catch(function () {});
2152
- return;
2153
- }
2154
-
2155
- if (msg.type === "check_update") {
2156
- if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return;
2157
- fetchVersion(updateChannel).then(function (v) {
2158
- if (v && isNewer(v, currentVersion)) {
2159
- latestVersion = v;
2160
- sendTo(ws, { type: "update_available", version: v });
2161
- } else {
2162
- sendTo(ws, { type: "up_to_date", version: currentVersion });
2163
- }
2164
- }).catch(function () {});
2165
- return;
2166
- }
2167
-
2168
- if (msg.type === "update_now") {
2169
- if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return;
2170
- send({ type: "update_started", version: latestVersion || "" });
2171
- var _ipc = require("./ipc");
2172
- var _config = require("./config");
2173
- _ipc.sendIPCCommand(_config.socketPath(), { cmd: "update" });
2174
- return;
2175
- }
2176
-
2177
- if (msg.type === "process_stats") {
2178
- var sessionCount = sm.sessions.size;
2179
- var processingCount = 0;
2180
- sm.sessions.forEach(function (s) {
2181
- if (s.isProcessing) processingCount++;
2182
- });
2183
- var mem = process.memoryUsage();
2184
- sendTo(ws, {
2185
- type: "process_stats",
2186
- pid: process.pid,
2187
- uptime: process.uptime(),
2188
- memory: {
2189
- rss: mem.rss,
2190
- heapUsed: mem.heapUsed,
2191
- heapTotal: mem.heapTotal,
2192
- external: mem.external,
2193
- },
2194
- sessions: sessionCount,
2195
- processing: processingCount,
2196
- clients: clients.size,
2197
- terminals: tm.list().length,
2198
- });
2199
- return;
2200
- }
2201
-
2202
- if (msg.type === "stop") {
2203
- var session = getSessionForWs(ws);
2204
- if (session && session.abortController && session.isProcessing) {
2205
- session.abortController.abort();
2206
- }
2207
- return;
2208
- }
2209
-
2210
-
2211
- if (msg.type === "stop_task") {
2212
- if (msg.taskId) {
2213
- sdk.stopTask(msg.taskId);
2214
- }
2215
- return;
2216
- }
2217
-
2218
- if (msg.type === "kill_process") {
2219
- var pid = msg.pid;
2220
- if (!pid || typeof pid !== "number") return;
2221
- // Verify target is actually a claude process before killing
2222
- if (!sdk.isClaudeProcess(pid)) {
2223
- console.error("[project] Refused to kill PID " + pid + ": not a claude process");
2224
- sendTo(ws, { type: "error", text: "Process " + pid + " is not a Claude process." });
2225
- return;
2226
- }
2227
- try {
2228
- process.kill(pid, "SIGTERM");
2229
- console.log("[project] Sent SIGTERM to conflicting Claude process PID " + pid);
2230
- sendTo(ws, { type: "process_killed", pid: pid });
2231
- } catch (e) {
2232
- console.error("[project] Failed to kill PID " + pid + ":", e.message);
2233
- sendTo(ws, { type: "error", text: "Failed to kill process " + pid + ": " + (e.message || e) });
2234
- }
2235
- return;
2236
- }
2237
-
2238
- if (msg.type === "set_model" && msg.model) {
2239
- var session = getSessionForWs(ws);
2240
- if (session) {
2241
- sdk.setModel(session, msg.model);
2242
- }
2243
- return;
2244
- }
2245
-
2246
- if (msg.type === "set_server_default_model" && msg.model) {
2247
- if (typeof opts.onSetServerDefaultModel === "function") {
2248
- opts.onSetServerDefaultModel(msg.model);
2249
- }
2250
- var session = getSessionForWs(ws);
2251
- if (session) {
2252
- sdk.setModel(session, msg.model);
2253
- }
2254
- return;
2255
- }
2256
-
2257
- if (msg.type === "set_project_default_model" && msg.model) {
2258
- if (typeof opts.onSetProjectDefaultModel === "function") {
2259
- opts.onSetProjectDefaultModel(slug, msg.model);
2260
- }
2261
- var session = getSessionForWs(ws);
2262
- if (session) {
2263
- sdk.setModel(session, msg.model);
2264
- }
2265
- return;
2266
- }
2267
-
2268
- if (msg.type === "set_permission_mode" && msg.mode) {
2269
- sm.currentPermissionMode = msg.mode;
2270
- var session = getSessionForWs(ws);
2271
- if (session) {
2272
- sdk.setPermissionMode(session, msg.mode);
2273
- }
2274
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
2275
- return;
2276
- }
2277
-
2278
- if (msg.type === "set_server_default_mode" && msg.mode) {
2279
- if (typeof opts.onSetServerDefaultMode === "function") {
2280
- opts.onSetServerDefaultMode(msg.mode);
2281
- }
2282
- sm.currentPermissionMode = msg.mode;
2283
- var session = getSessionForWs(ws);
2284
- if (session) {
2285
- sdk.setPermissionMode(session, msg.mode);
2286
- }
2287
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
2288
- return;
2289
- }
2290
-
2291
- if (msg.type === "set_project_default_mode" && msg.mode) {
2292
- if (typeof opts.onSetProjectDefaultMode === "function") {
2293
- opts.onSetProjectDefaultMode(slug, msg.mode);
2294
- }
2295
- sm.currentPermissionMode = msg.mode;
2296
- var session = getSessionForWs(ws);
2297
- if (session) {
2298
- sdk.setPermissionMode(session, msg.mode);
2299
- }
2300
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
2301
- return;
2302
- }
2303
-
2304
- if (msg.type === "set_effort" && msg.effort) {
2305
- sm.currentEffort = msg.effort;
2306
- var session = getSessionForWs(ws);
2307
- if (session) {
2308
- sdk.setEffort(session, msg.effort);
2309
- }
2310
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
2311
- return;
2312
- }
2313
-
2314
- if (msg.type === "set_server_default_effort" && msg.effort) {
2315
- if (typeof opts.onSetServerDefaultEffort === "function") {
2316
- opts.onSetServerDefaultEffort(msg.effort);
2317
- }
2318
- sm.currentEffort = msg.effort;
2319
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
2320
- return;
2321
- }
2322
-
2323
- if (msg.type === "set_project_default_effort" && msg.effort) {
2324
- if (typeof opts.onSetProjectDefaultEffort === "function") {
2325
- opts.onSetProjectDefaultEffort(slug, msg.effort);
2326
- }
2327
- sm.currentEffort = msg.effort;
2328
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
2329
- return;
2330
- }
2331
-
2332
- if (msg.type === "set_betas") {
2333
- sm.currentBetas = msg.betas || [];
2334
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas, thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
2335
- return;
2336
- }
2337
-
2338
- if (msg.type === "set_thinking") {
2339
- sm.currentThinking = msg.thinking || "adaptive";
2340
- if (msg.budgetTokens) sm.currentThinkingBudget = msg.budgetTokens;
2341
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
2342
- return;
2343
- }
2344
-
2345
- if (msg.type === "rewind_preview") {
2346
- var session = getSessionForWs(ws);
2347
- if (!session || !session.cliSessionId || !msg.uuid) return;
2348
- // Reject preview requests while a rewind is executing
2349
- if (session._rewindInProgress) return;
2350
-
2351
- (async function () {
2352
- var result;
2353
- try {
2354
- result = await sdk.getOrCreateRewindQuery(session);
2355
- var preview = await result.query.rewindFiles(msg.uuid, { dryRun: true });
2356
- var diffs = {};
2357
- var changedFiles = preview.filesChanged || [];
2358
- for (var f = 0; f < changedFiles.length; f++) {
2359
- try {
2360
- diffs[changedFiles[f]] = execFileSync(
2361
- "git", ["diff", "HEAD", "--", changedFiles[f]],
2362
- { cwd: cwd, encoding: "utf8", timeout: 5000 }
2363
- ) || "";
2364
- } catch (e) { diffs[changedFiles[f]] = ""; }
2365
- }
2366
- sendTo(ws, { type: "rewind_preview_result", preview: preview, diffs: diffs, uuid: msg.uuid });
2367
- } catch (err) {
2368
- sendTo(ws, { type: "rewind_error", text: "Failed to preview rewind: " + err.message });
2369
- } finally {
2370
- if (result && result.isTemp) result.cleanup();
2371
- }
2372
- })();
2373
- return;
2374
- }
2375
-
2376
- if (msg.type === "rewind_execute") {
2377
- var session = getSessionForWs(ws);
2378
- if (!session || !session.cliSessionId || !msg.uuid) return;
2379
- // Guard against concurrent rewind executions
2380
- if (session._rewindInProgress) {
2381
- sendTo(ws, { type: "rewind_error", text: "Rewind already in progress." });
2382
- return;
2383
- }
2384
- session._rewindInProgress = true;
2385
- var mode = msg.mode || "both";
2386
-
2387
- (async function () {
2388
- var result;
2389
- try {
2390
- // File restoration (skip for chat-only mode)
2391
- if (mode !== "chat") {
2392
- result = await sdk.getOrCreateRewindQuery(session);
2393
- await result.query.rewindFiles(msg.uuid, { dryRun: false });
2394
- }
2395
-
2396
- // Conversation rollback (skip for files-only mode)
2397
- if (mode !== "files") {
2398
- var targetIdx = -1;
2399
- for (var i = 0; i < session.messageUUIDs.length; i++) {
2400
- if (session.messageUUIDs[i].uuid === msg.uuid) {
2401
- targetIdx = i;
2402
- break;
2403
- }
2404
- }
2405
-
2406
- if (targetIdx >= 0) {
2407
- var trimTo = session.messageUUIDs[targetIdx].historyIndex;
2408
- for (var k = trimTo - 1; k >= 0; k--) {
2409
- if (session.history[k].type === "user_message") {
2410
- trimTo = k;
2411
- break;
2412
- }
2413
- }
2414
- session.history = session.history.slice(0, trimTo);
2415
- session.messageUUIDs = session.messageUUIDs.slice(0, targetIdx);
2416
- }
2417
-
2418
- var kept = session.messageUUIDs;
2419
- session.lastRewindUuid = kept.length > 0 ? kept[kept.length - 1].uuid : null;
2420
- }
2421
-
2422
- if (session.abortController) {
2423
- try { session.abortController.abort(); } catch (e) {}
2424
- }
2425
- if (session.messageQueue) {
2426
- try { session.messageQueue.end(); } catch (e) {}
2427
- }
2428
- session.queryInstance = null;
2429
- session.messageQueue = null;
2430
- session.abortController = null;
2431
- session.blocks = {};
2432
- session.sentToolResults = {};
2433
- session.pendingPermissions = {};
2434
- session.pendingAskUser = {};
2435
- session.isProcessing = false;
2436
- onProcessingChanged();
2437
-
2438
- sm.saveSessionFile(session);
2439
- sm.switchSession(session.localId, ws, hydrateImageRefs);
2440
- sm.sendAndRecord(session, { type: "rewind_complete", mode: mode });
2441
- sm.broadcastSessionList();
2442
- } catch (err) {
2443
- sendTo(ws, { type: "rewind_error", text: "Rewind failed: " + err.message });
2444
- } finally {
2445
- session._rewindInProgress = false;
2446
- if (result && result.isTemp) result.cleanup();
2447
- }
2448
- })();
2449
- return;
2450
- }
2451
-
2452
- if (msg.type === "fork_session" && msg.uuid) {
2453
- var session = getSessionForWs(ws);
2454
- if (!session || !session.cliSessionId) {
2455
- sendTo(ws, { type: "error", text: "Cannot fork: no CLI session" });
2456
- return;
2457
- }
2458
- var forkCliId = session.cliSessionId;
2459
- var forkTitle = (session.title || "New Session") + " (fork)";
2460
- getSDK().then(function(sdkMod) {
2461
- return sdkMod.forkSession(forkCliId, {
2462
- upToMessageId: msg.uuid,
2463
- dir: cwd,
2464
- });
2465
- }).then(function(result) {
2466
- var cliSess = require("./cli-sessions");
2467
- return cliSess.readCliSessionHistory(cwd, result.sessionId).then(function(history) {
2468
- var forked = sm.resumeSession(result.sessionId, { history: history, title: forkTitle }, ws);
2469
- if (forked) {
2470
- ws._clayActiveSession = forked.localId;
2471
- sendTo(ws, { type: "fork_complete", sessionId: forked.localId });
2472
- }
2473
- });
2474
- }).catch(function(e) {
2475
- sendTo(ws, { type: "error", text: "Fork failed: " + (e.message || e) });
2476
- });
2477
- return;
2478
- }
2479
-
2480
- if (msg.type === "ask_user_response") {
2481
- var session = getSessionForWs(ws);
2482
- if (!session) return;
2483
- var toolId = msg.toolId;
2484
- var answers = msg.answers || {};
2485
- var pending = session.pendingAskUser[toolId];
2486
- if (!pending) return;
2487
- delete session.pendingAskUser[toolId];
2488
- sm.sendAndRecord(session, { type: "ask_user_answered", toolId: toolId, answers: answers });
2489
- pending.resolve({
2490
- behavior: "allow",
2491
- updatedInput: Object.assign({}, pending.input, { answers: answers }),
2492
- });
2493
- return;
2494
- }
2495
-
2496
- if (msg.type === "input_sync") {
2497
- sendToSessionOthers(ws, ws._clayActiveSession, msg);
2498
- return;
2499
- }
2500
-
2501
- if (msg.type === "cursor_move" || msg.type === "cursor_leave" || msg.type === "text_select") {
2502
- if (!usersModule.isMultiUser() || !ws._clayUser) return;
2503
- var u = ws._clayUser;
2504
- var p = u.profile || {};
2505
- var cursorMsg = {
2506
- type: msg.type,
2507
- userId: u.id,
2508
- displayName: p.name || u.displayName || u.username,
2509
- avatarStyle: p.avatarStyle || "thumbs",
2510
- avatarSeed: p.avatarSeed || u.username,
2511
- avatarCustom: p.avatarCustom || "",
2512
- };
2513
- if (msg.type === "cursor_move") {
2514
- cursorMsg.turn = msg.turn;
2515
- if (msg.rx != null) cursorMsg.rx = msg.rx;
2516
- if (msg.ry != null) cursorMsg.ry = msg.ry;
2517
- }
2518
- if (msg.type === "text_select") {
2519
- cursorMsg.ranges = msg.ranges || [];
2520
- }
2521
- sendToSessionOthers(ws, ws._clayActiveSession, cursorMsg);
2522
- return;
2523
- }
2524
-
2525
- if (msg.type === "permission_response") {
2526
- var session = getSessionForWs(ws);
2527
- if (!session) return;
2528
- var requestId = msg.requestId;
2529
- var decision = msg.decision;
2530
- var pending = session.pendingPermissions[requestId];
2531
- if (!pending) return;
2532
- delete session.pendingPermissions[requestId];
2533
- onProcessingChanged(); // update cross-project permission badge
2534
-
2535
- // --- Plan approval: "allow_accept_edits" — approve + switch to acceptEdits mode ---
2536
- if (decision === "allow_accept_edits") {
2537
- sdk.setPermissionMode(session, "acceptEdits");
2538
- sm.currentPermissionMode = "acceptEdits";
2539
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
2540
- pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
2541
- sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
2542
- return;
2543
- }
2544
-
2545
- // --- Plan approval: "allow_clear_context" — new session + plan as first message + acceptEdits ---
2546
- if (decision === "allow_clear_context") {
2547
- // Deny current plan to end the turn
2548
- pending.resolve({ behavior: "deny", message: "User chose to clear context and restart" });
2549
- sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
2550
-
2551
- // Abort the old session's query — but defer to next tick so the SDK's
2552
- // deny write (scheduled as microtask by pending.resolve) completes first.
2553
- // Aborting synchronously would kill the subprocess before the write,
2554
- // causing an "Operation aborted" crash in the SDK.
2555
- session.isProcessing = false;
2556
- onProcessingChanged();
2557
- session.pendingPermissions = {};
2558
- session.pendingAskUser = {};
2559
- sm.broadcastSessionList();
2560
- setImmediate(function () {
2561
- if (session.abortController) {
2562
- session.abortController.abort();
2563
- }
2564
- });
2565
-
2566
- // Update permission mode for the new session
2567
- sm.currentPermissionMode = "acceptEdits";
2568
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
2569
-
2570
- // Build prompt from plan content (sent from client) or plan file path
2571
- var clientPlanContent = msg.planContent || "";
2572
- var planPrompt;
2573
- if (clientPlanContent) {
2574
- planPrompt = "Execute the following plan. Do NOT re-enter plan mode — just implement it step by step.\n\n" + clientPlanContent;
2575
- } else {
2576
- var planFilePath = (pending.toolInput && pending.toolInput.planFilePath) || "";
2577
- planPrompt = "Execute the plan in " + planFilePath + ". Do NOT re-enter plan mode — read the plan file and implement it step by step.";
2578
- }
2579
-
2580
- // Wait for old query stream to fully terminate, then create new session + send plan
2581
- var oldStreamPromise = session.streamPromise || Promise.resolve();
2582
- Promise.race([
2583
- oldStreamPromise,
2584
- new Promise(function (resolve) { setTimeout(resolve, 3000); }),
2585
- ]).then(function () {
2586
- try {
2587
- var newSession = sm.createSession(null, ws);
2588
- // Send the plan as the first user message (with planContent for UI rendering)
2589
- var userMsg = { type: "user_message", text: planPrompt, planContent: clientPlanContent || null };
2590
- newSession.history.push(userMsg);
2591
- sm.appendToSessionFile(newSession, userMsg);
2592
- newSession.title = "Plan execution (cleared context)";
2593
- sm.saveSessionFile(newSession);
2594
- sm.broadcastSessionList();
2595
- sendToSession(newSession.localId, userMsg);
2596
-
2597
- newSession.isProcessing = true;
2598
- onProcessingChanged();
2599
- newSession.sentToolResults = {};
2600
- sendToSession(newSession.localId, { type: "status", status: "processing" });
2601
- newSession.acceptEditsAfterStart = true;
2602
- sdk.startQuery(newSession, planPrompt, undefined, getLinuxUserForSession(newSession));
2603
- } catch (e) {
2604
- console.error("[project] Error starting plan execution:", e);
2605
- sendTo(ws, { type: "error", text: "Failed to start plan execution: " + (e.message || e) });
2606
- }
2607
- }).catch(function (e) {
2608
- console.error("[project] Plan execution stream wait failed:", e.message || e);
2609
- });
2610
- return;
2611
- }
2612
-
2613
- // --- Plan approval: "deny_with_feedback" — deny + send feedback as follow-up message ---
2614
- if (decision === "deny_with_feedback") {
2615
- var feedback = msg.feedback || "";
2616
- pending.resolve({ behavior: "deny", message: feedback || "User provided feedback" });
2617
- sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
2618
-
2619
- // Send feedback as next user message if there's text
2620
- if (feedback) {
2621
- setTimeout(function () {
2622
- var userMsg = { type: "user_message", text: feedback };
2623
- session.history.push(userMsg);
2624
- sm.appendToSessionFile(session, userMsg);
2625
- sendToSession(session.localId, userMsg);
2626
-
2627
- if (!session.isProcessing) {
2628
- session.isProcessing = true;
2629
- onProcessingChanged();
2630
- session.sentToolResults = {};
2631
- sendToSession(session.localId, { type: "status", status: "processing" });
2632
- if (!session.queryInstance && !session.worker) {
2633
- sdk.startQuery(session, feedback, undefined, getLinuxUserForSession(session));
2634
- } else {
2635
- sdk.pushMessage(session, feedback);
2636
- }
2637
- } else {
2638
- sdk.pushMessage(session, feedback);
2639
- }
2640
- }, 200);
2641
- }
2642
- return;
2643
- }
2644
-
2645
- if (decision === "allow" || decision === "allow_always") {
2646
- if (decision === "allow_always") {
2647
- if (!session.allowedTools) session.allowedTools = {};
2648
- session.allowedTools[pending.toolName] = true;
2649
- }
2650
- pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
2651
- } else {
2652
- pending.resolve({ behavior: "deny", message: "User denied permission" });
2653
- }
2654
-
2655
- sm.sendAndRecord(session, {
2656
- type: "permission_resolved",
2657
- requestId: requestId,
2658
- decision: decision,
2659
- });
2660
- return;
2661
- }
2662
-
2663
- // --- MCP elicitation response ---
2664
- if (msg.type === "elicitation_response") {
2665
- var session = getSessionForWs(ws);
2666
- if (!session) return;
2667
- var pending = session.pendingElicitations && session.pendingElicitations[msg.requestId];
2668
- if (!pending) return;
2669
- delete session.pendingElicitations[msg.requestId];
2670
- if (msg.action === "accept") {
2671
- pending.resolve({ action: "accept", content: msg.content || {} });
2672
- } else {
2673
- pending.resolve({ action: "reject" });
2674
- }
2675
- sm.sendAndRecord(session, {
2676
- type: "elicitation_resolved",
2677
- requestId: msg.requestId,
2678
- action: msg.action,
2679
- });
2680
- return;
2681
- }
2682
-
2683
- // --- Browse directories (for add-project autocomplete) ---
2684
- if (msg.type === "browse_dir") {
2685
- var rawPath = (msg.path || "").replace(/^~/, require("./config").REAL_HOME);
2686
- var absTarget = path.resolve(rawPath);
2687
- var parentDir, prefix;
2688
- try {
2689
- var stat = fs.statSync(absTarget);
2690
- if (stat.isDirectory()) {
2691
- // Input is an existing directory — list its children
2692
- parentDir = absTarget;
2693
- prefix = "";
2694
- } else {
2695
- parentDir = path.dirname(absTarget);
2696
- prefix = path.basename(absTarget).toLowerCase();
2697
- }
2698
- } catch (e) {
2699
- // Path doesn't exist — list parent and filter by typed prefix
2700
- parentDir = path.dirname(absTarget);
2701
- prefix = path.basename(absTarget).toLowerCase();
2702
- }
2703
- try {
2704
- var dirItems = fs.readdirSync(parentDir, { withFileTypes: true });
2705
- var dirEntries = [];
2706
- for (var di = 0; di < dirItems.length; di++) {
2707
- var d = dirItems[di];
2708
- if (!d.isDirectory()) continue;
2709
- if (d.name.charAt(0) === ".") continue;
2710
- if (IGNORED_DIRS.has(d.name)) continue;
2711
- if (prefix && !d.name.toLowerCase().startsWith(prefix)) continue;
2712
- dirEntries.push({ name: d.name, path: path.join(parentDir, d.name) });
2713
- }
2714
- dirEntries.sort(function (a, b) { return a.name.localeCompare(b.name); });
2715
- sendTo(ws, { type: "browse_dir_result", path: msg.path, entries: dirEntries });
2716
- } catch (e) {
2717
- sendTo(ws, { type: "browse_dir_result", path: msg.path, entries: [], error: e.message });
2718
- }
2719
- return;
2720
- }
2721
-
2722
- // --- Add project from web UI ---
2723
- if (msg.type === "add_project") {
2724
- var addPath = (msg.path || "").replace(/^~/, require("./config").REAL_HOME);
2725
- var addAbs = path.resolve(addPath);
2726
- try {
2727
- var addStat = fs.statSync(addAbs);
2728
- if (!addStat.isDirectory()) {
2729
- sendTo(ws, { type: "add_project_result", ok: false, error: "Not a directory" });
2730
- return;
2731
- }
2732
- } catch (e) {
2733
- sendTo(ws, { type: "add_project_result", ok: false, error: "Directory not found" });
2734
- return;
2735
- }
2736
- if (typeof opts.onAddProject === "function") {
2737
- var result = opts.onAddProject(addAbs, ws._clayUser);
2738
- sendTo(ws, { type: "add_project_result", ok: result.ok, slug: result.slug, error: result.error, existing: result.existing });
2739
- } else {
2740
- sendTo(ws, { type: "add_project_result", ok: false, error: "Not supported" });
2741
- }
2742
- return;
2743
- }
2744
-
2745
- // --- Create new empty project ---
2746
- if (msg.type === "create_project" || msg.type === "clone_project") {
2747
- if (ws._clayUser) {
2748
- var cpPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2749
- if (!cpPerms.createProject) {
2750
- sendTo(ws, { type: "add_project_result", ok: false, error: "You do not have permission to create projects" });
2751
- return;
2752
- }
2753
- }
2754
- }
2755
- if (msg.type === "create_project") {
2756
- var createName = (msg.name || "").trim();
2757
- if (!createName || !/^[a-zA-Z0-9_-]+$/.test(createName)) {
2758
- sendTo(ws, { type: "add_project_result", ok: false, error: "Invalid name. Use only letters, numbers, dashes, and underscores." });
2759
- return;
2760
- }
2761
- if (typeof opts.onCreateProject === "function") {
2762
- var createResult = opts.onCreateProject(createName, ws._clayUser);
2763
- sendTo(ws, { type: "add_project_result", ok: createResult.ok, slug: createResult.slug, error: createResult.error });
2764
- } else {
2765
- sendTo(ws, { type: "add_project_result", ok: false, error: "Not supported" });
2766
- }
2767
- return;
2768
- }
2769
-
2770
- // --- Clone project from GitHub ---
2771
- if (msg.type === "clone_project") {
2772
- var cloneUrl = (msg.url || "").trim();
2773
- if (!cloneUrl || (!/^https?:\/\//.test(cloneUrl) && !/^git@/.test(cloneUrl))) {
2774
- sendTo(ws, { type: "add_project_result", ok: false, error: "Invalid URL. Use https:// or git@ format." });
2775
- return;
2776
- }
2777
- sendTo(ws, { type: "clone_project_progress", status: "cloning" });
2778
- if (typeof opts.onCloneProject === "function") {
2779
- opts.onCloneProject(cloneUrl, ws._clayUser, function (cloneResult) {
2780
- sendTo(ws, { type: "add_project_result", ok: cloneResult.ok, slug: cloneResult.slug, error: cloneResult.error });
2781
- });
2782
- } else {
2783
- sendTo(ws, { type: "add_project_result", ok: false, error: "Not supported" });
2784
- }
2785
- return;
2786
- }
2787
-
2788
- // --- Create worktree from web UI ---
2789
- if (msg.type === "create_worktree") {
2790
- var wtBranch = (msg.branch || "").trim();
2791
- var wtDirName = (msg.dirName || "").trim() || wtBranch.replace(/\//g, "-");
2792
- var wtBase = (msg.baseBranch || "").trim() || null;
2793
- if (!wtBranch || !/^[a-zA-Z0-9_\/.@-]+$/.test(wtBranch)) {
2794
- sendTo(ws, { type: "create_worktree_result", ok: false, error: "Invalid branch name" });
2795
- return;
2796
- }
2797
- if (typeof onCreateWorktree === "function") {
2798
- var wtResult = onCreateWorktree(slug, wtBranch, wtDirName, wtBase);
2799
- sendTo(ws, { type: "create_worktree_result", ok: wtResult.ok, slug: wtResult.slug, error: wtResult.error });
2800
- } else {
2801
- sendTo(ws, { type: "create_worktree_result", ok: false, error: "Not supported" });
2802
- }
2803
- return;
2804
- }
2805
-
2806
- // --- Pre-check: does the project have tasks/schedules? ---
2807
- if (msg.type === "remove_project_check") {
2808
- var checkSlug = msg.slug;
2809
- if (!checkSlug) {
2810
- sendTo(ws, { type: "remove_project_check_result", slug: checkSlug, name: msg.name || checkSlug, count: 0 });
2811
- return;
2812
- }
2813
- var schedCount = getScheduleCount(checkSlug);
2814
- sendTo(ws, { type: "remove_project_check_result", slug: checkSlug, name: msg.name || checkSlug, count: schedCount });
2815
- return;
2816
- }
2817
-
2818
- // --- Remove project from web UI ---
2819
- if (msg.type === "remove_project") {
2820
- if (ws._clayUser) {
2821
- var dpPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2822
- if (!dpPerms.deleteProject) {
2823
- sendTo(ws, { type: "remove_project_result", ok: false, error: "You do not have permission to delete projects" });
2824
- return;
2825
- }
2826
- }
2827
- var removeSlug = msg.slug;
2828
- if (!removeSlug) {
2829
- sendTo(ws, { type: "remove_project_result", ok: false, error: "Missing slug" });
2830
- return;
2831
- }
2832
- // If client chose to move tasks to another project before removing
2833
- if (msg.moveTasksTo) {
2834
- moveAllSchedulesToProject(removeSlug, msg.moveTasksTo);
2835
- }
2836
- if (typeof opts.onRemoveProject === "function") {
2837
- // Send result before removing so the WS is still open
2838
- sendTo(ws, { type: "remove_project_result", ok: true, slug: removeSlug });
2839
- var removeUserId = ws._clayUser ? ws._clayUser.id : null;
2840
- opts.onRemoveProject(removeSlug, removeUserId);
2841
- } else {
2842
- sendTo(ws, { type: "remove_project_result", ok: false, error: "Not supported" });
2843
- }
2844
- return;
2845
- }
2846
-
2847
- // --- Move a single schedule to another project ---
2848
- if (msg.type === "schedule_move") {
2849
- var moveResult = moveScheduleToProject(msg.recordId, msg.fromSlug, msg.toSlug);
2850
- if (moveResult.ok) {
2851
- // Re-broadcast updated records to this project's clients
2852
- send({ type: "loop_registry_updated", records: getHubSchedules() });
2853
- }
2854
- sendTo(ws, { type: "schedule_move_result", ok: moveResult.ok, error: moveResult.error });
2855
- return;
2856
- }
2857
-
2858
- // --- Reorder projects ---
2859
- if (msg.type === "reorder_projects") {
2860
- var slugs = msg.slugs;
2861
- if (!Array.isArray(slugs) || slugs.length === 0) {
2862
- sendTo(ws, { type: "reorder_projects_result", ok: false, error: "Missing slugs" });
2863
- return;
2864
- }
2865
- if (typeof opts.onReorderProjects === "function") {
2866
- var reorderResult = opts.onReorderProjects(slugs);
2867
- sendTo(ws, { type: "reorder_projects_result", ok: reorderResult.ok, error: reorderResult.error });
2868
- } else {
2869
- sendTo(ws, { type: "reorder_projects_result", ok: false, error: "Not supported" });
2870
- }
2871
- return;
2872
- }
2873
-
2874
- // --- Set project title (rename) ---
2875
- if (msg.type === "set_project_title") {
2876
- if (!msg.slug) {
2877
- sendTo(ws, { type: "set_project_title_result", ok: false, error: "Missing slug" });
2878
- return;
2879
- }
2880
- if (typeof opts.onSetProjectTitle === "function") {
2881
- var titleResult = opts.onSetProjectTitle(msg.slug, msg.title || null);
2882
- sendTo(ws, { type: "set_project_title_result", ok: titleResult.ok, slug: msg.slug, error: titleResult.error });
2883
- } else {
2884
- sendTo(ws, { type: "set_project_title_result", ok: false, error: "Not supported" });
2885
- }
2886
- return;
2887
- }
2888
-
2889
- // --- Set project icon (emoji) ---
2890
- if (msg.type === "set_project_icon") {
2891
- if (!msg.slug) {
2892
- sendTo(ws, { type: "set_project_icon_result", ok: false, error: "Missing slug" });
2893
- return;
2894
- }
2895
- if (typeof opts.onSetProjectIcon === "function") {
2896
- var iconResult = opts.onSetProjectIcon(msg.slug, msg.icon || null);
2897
- sendTo(ws, { type: "set_project_icon_result", ok: iconResult.ok, slug: msg.slug, error: iconResult.error });
2898
- } else {
2899
- sendTo(ws, { type: "set_project_icon_result", ok: false, error: "Not supported" });
2900
- }
2901
- return;
2902
- }
2903
-
2904
- // --- Daemon config / server management (admin-only in multi-user mode) ---
2905
- if (msg.type === "get_daemon_config" || msg.type === "set_pin" || msg.type === "set_keep_awake" ||
2906
- msg.type === "set_auto_continue" || msg.type === "set_image_retention" || msg.type === "shutdown_server" || msg.type === "restart_server") {
2907
- if (usersModule.isMultiUser()) {
2908
- var _wsUser = ws._clayUser;
2909
- if (!_wsUser || _wsUser.role !== "admin") {
2910
- sendTo(ws, { type: "error", message: "Admin access required" });
2911
- return;
2912
- }
2913
- }
2914
- }
2915
-
2916
- if (msg.type === "get_daemon_config") {
2917
- if (typeof opts.onGetDaemonConfig === "function") {
2918
- var daemonConfig = opts.onGetDaemonConfig();
2919
- sendTo(ws, { type: "daemon_config", config: daemonConfig });
2920
- }
2921
- return;
2922
- }
2923
-
2924
- if (msg.type === "set_pin") {
2925
- if (typeof opts.onSetPin === "function") {
2926
- var pinResult = opts.onSetPin(msg.pin || null);
2927
- sendTo(ws, { type: "set_pin_result", ok: pinResult.ok, pinEnabled: pinResult.pinEnabled });
2928
- }
2929
- return;
2930
- }
2931
-
2932
- if (msg.type === "set_keep_awake") {
2933
- if (typeof opts.onSetKeepAwake === "function") {
2934
- var kaResult = opts.onSetKeepAwake(msg.value);
2935
- sendTo(ws, { type: "set_keep_awake_result", ok: kaResult.ok, keepAwake: kaResult.keepAwake });
2936
- send({ type: "keep_awake_changed", keepAwake: kaResult.keepAwake });
2937
- }
2938
- return;
2939
- }
2940
-
2941
- if (msg.type === "set_auto_continue") {
2942
- if (typeof opts.onSetAutoContinue === "function") {
2943
- var acResult = opts.onSetAutoContinue(msg.value);
2944
- sendTo(ws, { type: "set_auto_continue_result", ok: acResult.ok, autoContinueOnRateLimit: acResult.autoContinueOnRateLimit });
2945
- send({ type: "auto_continue_changed", autoContinueOnRateLimit: acResult.autoContinueOnRateLimit });
2946
- }
2947
- return;
2948
- }
2949
-
2950
- if (msg.type === "set_image_retention") {
2951
- if (typeof opts.onSetImageRetention === "function") {
2952
- var irResult = opts.onSetImageRetention(msg.days);
2953
- sendTo(ws, { type: "set_image_retention_result", ok: irResult.ok, days: irResult.days });
2954
- }
2955
- return;
2956
- }
2957
-
2958
- if (msg.type === "shutdown_server") {
2959
- if (typeof opts.onShutdown === "function") {
2960
- sendTo(ws, { type: "shutdown_server_result", ok: true });
2961
- send({ type: "toast", level: "warn", message: "Server is shutting down..." });
2962
- // Small delay so the response has time to reach clients
2963
- setTimeout(function () {
2964
- opts.onShutdown();
2965
- }, 500);
2966
- } else {
2967
- sendTo(ws, { type: "shutdown_server_result", ok: false, error: "Shutdown not supported" });
2968
- }
2969
- return;
2970
- }
2971
-
2972
- if (msg.type === "restart_server") {
2973
- if (typeof opts.onRestart === "function") {
2974
- sendTo(ws, { type: "restart_server_result", ok: true });
2975
- send({ type: "toast", level: "info", message: "Server is restarting..." });
2976
- // Small delay so the response has time to reach clients
2977
- setTimeout(function () {
2978
- opts.onRestart();
2979
- }, 500);
2980
- } else {
2981
- sendTo(ws, { type: "restart_server_result", ok: false, error: "Restart not supported" });
2982
- }
2983
- return;
2984
- }
2985
-
2986
- // --- File browser ---
2987
- if (msg.type === "fs_list" || msg.type === "fs_read" || msg.type === "fs_write" || msg.type === "fs_delete" || msg.type === "fs_rename" || msg.type === "fs_mkdir" || msg.type === "fs_upload") {
2988
- if (ws._clayUser) {
2989
- var fbPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2990
- if (!fbPerms.fileBrowser) {
2991
- sendTo(ws, { type: msg.type + "_result", error: "File browser access is not permitted" });
2992
- return;
2993
- }
2994
- }
2995
- }
2996
- if (msg.type === "fs_list") {
2997
- var fsDir = safePath(cwd, msg.path || ".");
2998
- // In OS user mode, fall back to absolute path resolution (ACL enforces access)
2999
- if (!fsDir && getOsUserInfoForWs(ws)) {
3000
- fsDir = safeAbsPath(msg.path);
3001
- }
3002
- if (!fsDir) {
3003
- sendTo(ws, { type: "fs_list_result", path: msg.path, entries: [], error: "Access denied" });
3004
- return;
3005
- }
3006
- try {
3007
- var fsListUserInfo = getOsUserInfoForWs(ws);
3008
- var entries = [];
3009
- if (fsListUserInfo) {
3010
- // Run as target OS user to respect Linux file permissions
3011
- var rawEntries = fsAsUser("list", { dir: fsDir }, fsListUserInfo);
3012
- for (var fi = 0; fi < rawEntries.length; fi++) {
3013
- var re = rawEntries[fi];
3014
- if (re.isDir && IGNORED_DIRS.has(re.name)) continue;
3015
- entries.push({
3016
- name: re.name,
3017
- type: re.isDir ? "dir" : "file",
3018
- path: path.relative(cwd, path.join(fsDir, re.name)).split(path.sep).join("/"),
3019
- });
3020
- }
3021
- } else {
3022
- var items = fs.readdirSync(fsDir, { withFileTypes: true });
3023
- for (var fi = 0; fi < items.length; fi++) {
3024
- var item = items[fi];
3025
- if (item.isDirectory() && IGNORED_DIRS.has(item.name)) continue;
3026
- entries.push({
3027
- name: item.name,
3028
- type: item.isDirectory() ? "dir" : "file",
3029
- path: path.relative(cwd, path.join(fsDir, item.name)).split(path.sep).join("/"),
3030
- });
3031
- }
3032
- }
3033
- sendTo(ws, { type: "fs_list_result", path: msg.path || ".", entries: entries });
3034
- // Auto-watch the directory for changes
3035
- startDirWatch(msg.path || ".");
3036
- } catch (e) {
3037
- sendTo(ws, { type: "fs_list_result", path: msg.path, entries: [], error: e.message });
3038
- }
3039
- return;
3040
- }
3041
-
3042
- if (msg.type === "fs_read") {
3043
- var fsFile = safePath(cwd, msg.path);
3044
- if (!fsFile && getOsUserInfoForWs(ws)) {
3045
- fsFile = safeAbsPath(msg.path);
3046
- }
3047
- if (!fsFile) {
3048
- sendTo(ws, { type: "fs_read_result", path: msg.path, error: "Access denied" });
3049
- return;
3050
- }
3051
- try {
3052
- var fsReadUserInfo = getOsUserInfoForWs(ws);
3053
- var ext = path.extname(fsFile).toLowerCase();
3054
- if (fsReadUserInfo) {
3055
- // Run stat and read as target OS user
3056
- var statResult = fsAsUser("stat", { file: fsFile }, fsReadUserInfo);
3057
- if (statResult.size > FS_MAX_SIZE) {
3058
- sendTo(ws, { type: "fs_read_result", path: msg.path, binary: true, size: statResult.size, error: "File too large (" + (statResult.size / 1024 / 1024).toFixed(1) + " MB)" });
3059
- return;
3060
- }
3061
- if (BINARY_EXTS.has(ext)) {
3062
- var result = { type: "fs_read_result", path: msg.path, binary: true, size: statResult.size };
3063
- if (IMAGE_EXTS.has(ext)) result.imageUrl = "api/file?path=" + encodeURIComponent(msg.path);
3064
- sendTo(ws, result);
3065
- return;
3066
- }
3067
- var readResult = fsAsUser("read", { file: fsFile, readContent: true }, fsReadUserInfo);
3068
- sendTo(ws, { type: "fs_read_result", path: msg.path, content: readResult.content, size: statResult.size });
3069
- } else {
3070
- var stat = fs.statSync(fsFile);
3071
- if (stat.size > FS_MAX_SIZE) {
3072
- sendTo(ws, { type: "fs_read_result", path: msg.path, binary: true, size: stat.size, error: "File too large (" + (stat.size / 1024 / 1024).toFixed(1) + " MB)" });
3073
- return;
3074
- }
3075
- if (BINARY_EXTS.has(ext)) {
3076
- var result = { type: "fs_read_result", path: msg.path, binary: true, size: stat.size };
3077
- if (IMAGE_EXTS.has(ext)) result.imageUrl = "api/file?path=" + encodeURIComponent(msg.path);
3078
- sendTo(ws, result);
3079
- return;
3080
- }
3081
- var content = fs.readFileSync(fsFile, "utf8");
3082
- sendTo(ws, { type: "fs_read_result", path: msg.path, content: content, size: stat.size });
3083
- }
3084
- } catch (e) {
3085
- sendTo(ws, { type: "fs_read_result", path: msg.path, error: e.message });
3086
- }
3087
- return;
3088
- }
3089
-
3090
- // --- File write ---
3091
- if (msg.type === "fs_write") {
3092
- var fsWriteFile = safePath(cwd, msg.path);
3093
- if (!fsWriteFile && getOsUserInfoForWs(ws)) {
3094
- fsWriteFile = safeAbsPath(msg.path);
3095
- }
3096
- if (!fsWriteFile) {
3097
- sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: "Access denied" });
3098
- return;
3099
- }
3100
- try {
3101
- var fsWriteUserInfo = getOsUserInfoForWs(ws);
3102
- if (fsWriteUserInfo) {
3103
- fsAsUser("write", { file: fsWriteFile, content: msg.content || "" }, fsWriteUserInfo);
3104
- } else {
3105
- fs.writeFileSync(fsWriteFile, msg.content || "", "utf8");
3106
- }
3107
- sendTo(ws, { type: "fs_write_result", path: msg.path, ok: true });
3108
- } catch (e) {
3109
- sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: e.message });
3110
- }
3111
- return;
3112
- }
3113
-
3114
- // --- Project settings permission gate ---
3115
- if (msg.type === "get_project_env" || msg.type === "set_project_env" ||
3116
- msg.type === "read_global_claude_md" || msg.type === "write_global_claude_md" ||
3117
- msg.type === "get_shared_env" || msg.type === "set_shared_env" ||
3118
- msg.type === "transfer_project_owner") {
3119
- if (ws._clayUser) {
3120
- var psPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
3121
- if (!psPerms.projectSettings) {
3122
- sendTo(ws, { type: "error", text: "Project settings access is not permitted" });
3123
- return;
3124
- }
3125
- }
3126
- }
3127
-
3128
- // --- Project environment variables ---
3129
- if (msg.type === "get_project_env") {
3130
- var envrc = "";
3131
- var hasEnvrc = false;
3132
- if (typeof opts.onGetProjectEnv === "function") {
3133
- var envResult = opts.onGetProjectEnv(msg.slug);
3134
- envrc = envResult.envrc || "";
3135
- }
3136
- try {
3137
- var envrcPath = path.join(cwd, ".envrc");
3138
- hasEnvrc = fs.existsSync(envrcPath);
3139
- } catch (e) {}
3140
- sendTo(ws, { type: "project_env_result", slug: msg.slug, envrc: envrc, hasEnvrc: hasEnvrc });
3141
- return;
3142
- }
3143
-
3144
- if (msg.type === "set_project_env") {
3145
- if (typeof opts.onSetProjectEnv === "function") {
3146
- var envError = validateEnvString(msg.envrc || "");
3147
- if (envError) {
3148
- sendTo(ws, { type: "set_project_env_result", ok: false, slug: msg.slug, error: envError });
3149
- return;
3150
- }
3151
- var setResult = opts.onSetProjectEnv(msg.slug, msg.envrc || "");
3152
- sendTo(ws, { type: "set_project_env_result", ok: setResult.ok, slug: msg.slug, error: setResult.error });
3153
- } else {
3154
- sendTo(ws, { type: "set_project_env_result", ok: false, error: "Not supported" });
3155
- }
3156
- return;
3157
- }
3158
-
3159
- // --- Global CLAUDE.md ---
3160
- if (msg.type === "read_global_claude_md") {
3161
- var globalMdPath = path.join(require("./config").REAL_HOME, ".claude", "CLAUDE.md");
3162
- try {
3163
- var globalMdContent = fs.readFileSync(globalMdPath, "utf8");
3164
- sendTo(ws, { type: "global_claude_md_result", content: globalMdContent });
3165
- } catch (e) {
3166
- sendTo(ws, { type: "global_claude_md_result", error: e.message });
3167
- }
3168
- return;
3169
- }
3170
-
3171
- if (msg.type === "write_global_claude_md") {
3172
- var globalMdDir = path.join(require("./config").REAL_HOME, ".claude");
3173
- var globalMdWritePath = path.join(globalMdDir, "CLAUDE.md");
3174
- try {
3175
- if (!fs.existsSync(globalMdDir)) {
3176
- fs.mkdirSync(globalMdDir, { recursive: true });
3177
- }
3178
- fs.writeFileSync(globalMdWritePath, msg.content || "", "utf8");
3179
- sendTo(ws, { type: "write_global_claude_md_result", ok: true });
3180
- } catch (e) {
3181
- sendTo(ws, { type: "write_global_claude_md_result", ok: false, error: e.message });
3182
- }
3183
- return;
3184
- }
3185
-
3186
- // --- Shared environment variables ---
3187
- if (msg.type === "get_shared_env") {
3188
- var sharedEnvrc = "";
3189
- if (typeof opts.onGetSharedEnv === "function") {
3190
- var sharedResult = opts.onGetSharedEnv();
3191
- sharedEnvrc = sharedResult.envrc || "";
3192
- }
3193
- sendTo(ws, { type: "shared_env_result", envrc: sharedEnvrc });
3194
- return;
3195
- }
3196
-
3197
- if (msg.type === "set_shared_env") {
3198
- if (typeof opts.onSetSharedEnv === "function") {
3199
- var sharedEnvError = validateEnvString(msg.envrc || "");
3200
- if (sharedEnvError) {
3201
- sendTo(ws, { type: "set_shared_env_result", ok: false, error: sharedEnvError });
3202
- return;
3203
- }
3204
- var sharedSetResult = opts.onSetSharedEnv(msg.envrc || "");
3205
- sendTo(ws, { type: "set_shared_env_result", ok: sharedSetResult.ok, error: sharedSetResult.error });
3206
- } else {
3207
- sendTo(ws, { type: "set_shared_env_result", ok: false, error: "Not supported" });
3208
- }
3209
- return;
3210
- }
3211
-
3212
- // --- File watcher ---
3213
- if (msg.type === "fs_watch") {
3214
- if (msg.path) startFileWatch(msg.path);
3215
- return;
3216
- }
3217
-
3218
- if (msg.type === "fs_unwatch") {
3219
- stopFileWatch();
3220
- return;
3221
- }
3222
-
3223
- // --- File edit history ---
3224
- if (msg.type === "fs_file_history") {
3225
- var histPath = msg.path;
3226
- if (!histPath) {
3227
- sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: [] });
3228
- return;
3229
- }
3230
- var absHistPath = path.resolve(cwd, histPath);
3231
- var entries = [];
3232
-
3233
- // Collect session edits
3234
- sm.sessions.forEach(function (session) {
3235
- var sessionLocalId = session.localId;
3236
- var sessionTitle = session.title || "Untitled";
3237
- var histLen = session.history.length || 1;
3238
-
3239
- for (var hi = 0; hi < session.history.length; hi++) {
3240
- var entry = session.history[hi];
3241
- if (entry.type !== "tool_executing") continue;
3242
- if (entry.name !== "Edit" && entry.name !== "Write") continue;
3243
- if (!entry.input || !entry.input.file_path) continue;
3244
- if (entry.input.file_path !== absHistPath) continue;
3245
-
3246
- // Find parent assistant UUID + message snippet by scanning backwards
3247
- var assistantUuid = null;
3248
- var uuidIndex = -1;
3249
- for (var hj = hi - 1; hj >= 0; hj--) {
3250
- if (session.history[hj].type === "message_uuid" && session.history[hj].messageType === "assistant") {
3251
- assistantUuid = session.history[hj].uuid;
3252
- uuidIndex = hj;
3253
- break;
3254
- }
3255
- }
3256
-
3257
- // Find user prompt by scanning backwards from the assistant uuid
3258
- var messageSnippet = "";
3259
- var searchFrom = uuidIndex >= 0 ? uuidIndex : hi;
3260
- for (var hk = searchFrom - 1; hk >= 0; hk--) {
3261
- if (session.history[hk].type === "user_message" && session.history[hk].text) {
3262
- messageSnippet = session.history[hk].text.trim().substring(0, 100);
3263
- break;
3264
- }
3265
- }
3266
-
3267
- // Collect Claude's explanation: scan backwards from tool_executing
3268
- // to find the nearest delta text block (skipping tool_start).
3269
- // If no delta found immediately before this tool, scan past
3270
- // intervening tool blocks to find the last delta text within
3271
- // the same assistant turn.
3272
- var assistantSnippet = "";
3273
- var deltaChunks = [];
3274
- for (var hd = hi - 1; hd >= 0; hd--) {
3275
- var hEntry = session.history[hd];
3276
- if (hEntry.type === "tool_start") continue;
3277
- if (hEntry.type === "delta" && hEntry.text) {
3278
- deltaChunks.unshift(hEntry.text);
3279
- } else {
3280
- break;
3281
- }
3282
- }
3283
- if (deltaChunks.length === 0) {
3284
- // No delta immediately before; scan past tool blocks
3285
- // to find the nearest preceding delta in the same turn
3286
- for (var hd2 = hi - 1; hd2 >= 0; hd2--) {
3287
- var hEntry2 = session.history[hd2];
3288
- if (hEntry2.type === "tool_start" || hEntry2.type === "tool_executing" || hEntry2.type === "tool_result") continue;
3289
- if (hEntry2.type === "delta" && hEntry2.text) {
3290
- // Found a delta before an earlier tool in the same turn.
3291
- // Collect this contiguous block of deltas.
3292
- for (var hd3 = hd2; hd3 >= 0; hd3--) {
3293
- var hEntry3 = session.history[hd3];
3294
- if (hEntry3.type === "tool_start") continue;
3295
- if (hEntry3.type === "delta" && hEntry3.text) {
3296
- deltaChunks.unshift(hEntry3.text);
3297
- } else {
3298
- break;
3299
- }
3300
- }
3301
- break;
3302
- } else {
3303
- // Hit message_uuid, user_message, etc. Stop.
3304
- break;
3305
- }
3306
- }
3307
- }
3308
- assistantSnippet = deltaChunks.join("").trim().substring(0, 150);
3309
-
3310
- // Approximate timestamp: interpolate between session creation and last activity
3311
- var tStart = session.createdAt || 0;
3312
- var tEnd = session.lastActivity || tStart;
3313
- var ts = tStart + Math.floor((hi / histLen) * (tEnd - tStart));
3314
-
3315
- var editRecord = {
3316
- source: "session",
3317
- timestamp: ts,
3318
- sessionLocalId: sessionLocalId,
3319
- sessionTitle: sessionTitle,
3320
- assistantUuid: assistantUuid,
3321
- toolId: entry.id,
3322
- messageSnippet: messageSnippet,
3323
- assistantSnippet: assistantSnippet,
3324
- toolName: entry.name,
3325
- };
3326
-
3327
- if (entry.name === "Edit") {
3328
- editRecord.old_string = entry.input.old_string || "";
3329
- editRecord.new_string = entry.input.new_string || "";
3330
- } else {
3331
- editRecord.isFullWrite = true;
3332
- }
3333
-
3334
- entries.push(editRecord);
3335
- }
3336
- });
3337
-
3338
- // Collect git commits
3339
- try {
3340
- var gitLog = execFileSync(
3341
- "git", ["log", "--format=%H|%at|%an|%s", "--follow", "--", histPath],
3342
- { cwd: cwd, encoding: "utf8", timeout: 5000 }
3343
- );
3344
- var gitLines = gitLog.trim().split("\n");
3345
- for (var gi = 0; gi < gitLines.length; gi++) {
3346
- if (!gitLines[gi]) continue;
3347
- var parts = gitLines[gi].split("|");
3348
- if (parts.length < 4) continue;
3349
- entries.push({
3350
- source: "git",
3351
- hash: parts[0],
3352
- timestamp: parseInt(parts[1], 10) * 1000,
3353
- author: parts[2],
3354
- message: parts.slice(3).join("|"),
3355
- });
3356
- }
3357
- } catch (e) {
3358
- // Not a git repo or file not tracked, that's fine
3359
- }
3360
-
3361
- // Sort by timestamp descending (newest first)
3362
- entries.sort(function (a, b) { return b.timestamp - a.timestamp; });
3363
-
3364
- sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: entries });
3365
- return;
3366
- }
3367
-
3368
- // --- Git diff for file history ---
3369
- if (msg.type === "fs_git_diff") {
3370
- var diffPath = msg.path;
3371
- var hash = msg.hash;
3372
- var hash2 = msg.hash2 || null;
3373
- if (!diffPath || !hash) {
3374
- sendTo(ws, { type: "fs_git_diff_result", hash: hash, path: diffPath, diff: "", error: "Missing params" });
3375
- return;
3376
- }
3377
- try {
3378
- var diff;
3379
- if (hash2) {
3380
- diff = execFileSync("git", ["diff", hash, hash2, "--", diffPath],
3381
- { cwd: cwd, encoding: "utf8", timeout: 5000 });
3382
- } else {
3383
- diff = execFileSync("git", ["show", hash, "--format=", "--", diffPath],
3384
- { cwd: cwd, encoding: "utf8", timeout: 5000 });
3385
- }
3386
- sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: diff || "" });
3387
- } catch (e) {
3388
- sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: "", error: e.message });
3389
- }
3390
- return;
3391
- }
3392
-
3393
- // --- File content at a git commit ---
3394
- if (msg.type === "fs_file_at") {
3395
- var atPath = msg.path;
3396
- var atHash = msg.hash;
3397
- if (!atPath || !atHash) {
3398
- sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: "Missing params" });
3399
- return;
3400
- }
3401
- try {
3402
- // Convert to repo-relative path (git show requires hash:relative/path)
3403
- var atAbsPath = path.resolve(cwd, atPath);
3404
- var atRelPath = path.relative(cwd, atAbsPath);
3405
- var content = execFileSync("git", ["show", atHash + ":" + atRelPath],
3406
- { cwd: cwd, encoding: "utf8", timeout: 5000 });
3407
- sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: content });
3408
- } catch (e) {
3409
- sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: e.message });
3410
- }
3411
- return;
3412
- }
3413
-
3414
- // --- Sticky notes ---
3415
- function syncNotesKnowledge() {
3416
- if (!isMate) return;
3417
- try {
3418
- var knDir = path.join(cwd, "knowledge");
3419
- var knFile = path.join(knDir, "sticky-notes.md");
3420
- var text = nm.getActiveNotesText();
3421
- if (text) {
3422
- fs.mkdirSync(knDir, { recursive: true });
3423
- fs.writeFileSync(knFile, text);
3424
- } else {
3425
- try { fs.unlinkSync(knFile); } catch (e) {}
3426
- }
3427
- } catch (e) {
3428
- console.error("[project] Failed to sync sticky-notes.md:", e.message);
3429
- }
3430
- }
3431
-
3432
- if (msg.type === "note_create") {
3433
- var note = nm.create(msg);
3434
- if (note) {
3435
- send({ type: "note_created", note: note });
3436
- syncNotesKnowledge();
3437
- }
3438
- return;
3439
- }
3440
-
3441
- if (msg.type === "note_update") {
3442
- if (!msg.id) return;
3443
- var updated = nm.update(msg.id, msg);
3444
- if (updated) {
3445
- send({ type: "note_updated", note: updated });
3446
- if (msg.text !== undefined || msg.hidden !== undefined) syncNotesKnowledge();
3447
- }
3448
- return;
3449
- }
3450
-
3451
- if (msg.type === "note_delete") {
3452
- if (!msg.id) return;
3453
- if (nm.remove(msg.id)) {
3454
- send({ type: "note_deleted", id: msg.id });
3455
- syncNotesKnowledge();
3456
- }
3457
- return;
3458
- }
3459
-
3460
- if (msg.type === "note_list_request") {
3461
- sendTo(ws, { type: "notes_list", notes: nm.list() });
3462
- return;
3463
- }
3464
-
3465
- if (msg.type === "note_bring_front") {
3466
- if (!msg.id) return;
3467
- var front = nm.bringToFront(msg.id);
3468
- if (front) send({ type: "note_updated", note: front });
3469
- return;
3470
- }
3471
-
3472
- // --- Web terminal ---
3473
- if (msg.type === "term_create") {
3474
- if (ws._clayUser) {
3475
- var termPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
3476
- if (!termPerms.terminal) {
3477
- sendTo(ws, { type: "term_error", error: "Terminal access is not permitted" });
3478
- return;
3479
- }
3480
- }
3481
- var t = tm.create(msg.cols || 80, msg.rows || 24, getOsUserInfoForWs(ws), ws);
3482
- if (!t) {
3483
- sendTo(ws, { type: "term_error", error: "Cannot create terminal (node-pty not available or limit reached)" });
3484
- return;
3485
- }
3486
- tm.attach(t.id, ws);
3487
- send({ type: "term_list", terminals: tm.list() });
3488
- sendTo(ws, { type: "term_created", id: t.id });
3489
- return;
3490
- }
3491
-
3492
- if (msg.type === "term_attach") {
3493
- if (msg.id) tm.attach(msg.id, ws);
3494
- return;
3495
- }
3496
-
3497
- if (msg.type === "term_detach") {
3498
- if (msg.id) tm.detach(msg.id, ws);
3499
- return;
3500
- }
3501
-
3502
- if (msg.type === "term_input") {
3503
- if (msg.id) tm.write(msg.id, msg.data);
3504
- return;
3505
- }
3506
-
3507
- if (msg.type === "term_resize") {
3508
- if (msg.id && msg.cols > 0 && msg.rows > 0) {
3509
- tm.resize(msg.id, msg.cols, msg.rows, ws);
3510
- }
3511
- return;
3512
- }
3513
-
3514
- if (msg.type === "term_close") {
3515
- if (msg.id) {
3516
- tm.close(msg.id);
3517
- send({ type: "term_list", terminals: tm.list() });
3518
- // Remove closed terminal from context sources
3519
- var saved = loadContextSources(slug);
3520
- var termKey = "term:" + msg.id;
3521
- var filtered = saved.filter(function(id) { return id !== termKey; });
3522
- if (filtered.length !== saved.length) {
3523
- saveContextSources(slug, filtered);
3524
- send({ type: "context_sources_state", active: filtered });
3525
- }
3526
- }
3527
- return;
3528
- }
3529
-
3530
- if (msg.type === "term_rename") {
3531
- if (msg.id && msg.title) {
3532
- tm.rename(msg.id, msg.title);
3533
- send({ type: "term_list", terminals: tm.list() });
3534
- }
3535
- return;
3536
- }
3537
-
3538
- // --- Context Sources ---
3539
- if (msg.type === "context_sources_save") {
3540
- var activeIds = msg.active || [];
3541
- saveContextSources(slug, activeIds);
3542
- return;
3543
- }
3544
-
3545
- // --- Browser Extension ---
3546
- if (msg.type === "browser_tab_list") {
3547
- _extensionWs = ws; // Track which client has the extension
3548
- var tabs = msg.tabs || [];
3549
- _browserTabList = {};
3550
- for (var bti = 0; bti < tabs.length; bti++) {
3551
- _browserTabList[tabs[bti].id] = tabs[bti];
3552
- }
3553
- return;
3554
- }
3555
-
3556
- if (msg.type === "extension_result") {
3557
- var pending = pendingExtensionRequests[msg.requestId];
3558
- if (pending) {
3559
- clearTimeout(pending.timer);
3560
- pending.resolve(msg.result);
3561
- delete pendingExtensionRequests[msg.requestId];
3562
- }
3563
- return;
3564
- }
3565
-
3566
- // --- Scheduled tasks permission gate ---
3567
- if (msg.type === "loop_start" || msg.type === "loop_stop" || msg.type === "loop_registry_files" ||
3568
- msg.type === "loop_registry_list" || msg.type === "loop_registry_update" || msg.type === "loop_registry_rename" ||
3569
- msg.type === "loop_registry_remove" || msg.type === "loop_registry_convert" || msg.type === "loop_registry_toggle" ||
3570
- msg.type === "loop_registry_rerun" || msg.type === "schedule_create" || msg.type === "schedule_move") {
3571
- if (ws._clayUser) {
3572
- var schPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
3573
- if (!schPerms.scheduledTasks) {
3574
- sendTo(ws, { type: "error", text: "Scheduled tasks access is not permitted" });
3575
- return;
3576
- }
3577
- }
3578
- }
3579
-
3580
- if (msg.type === "loop_start") {
3581
- // If this loop has a cron schedule, don't run immediately — just confirm registration
3582
- if (loopState.wizardData && loopState.wizardData.cron) {
3583
- loopState.active = false;
3584
- loopState.phase = "done";
3585
- saveLoopState();
3586
- send({ type: "loop_finished", reason: "scheduled", iterations: 0, results: [] });
3587
- send({ type: "ralph_phase", phase: "idle", wizardData: null });
3588
- send({ type: "loop_scheduled", recordId: loopState.loopId, cron: loopState.wizardData.cron });
3589
- return;
3590
- }
3591
- startLoop();
3592
- return;
3593
- }
3594
-
3595
- if (msg.type === "loop_stop") {
3596
- stopLoop();
3597
- return;
3598
- }
3599
-
3600
- if (msg.type === "ralph_wizard_complete") {
3601
- var wData = msg.data || {};
3602
- var maxIter = wData.maxIterations || 3;
3603
- var wizardCron = wData.cron || null;
3604
- var newLoopId = generateLoopId();
3605
- loopState.loopId = newLoopId;
3606
- loopState.wizardData = {
3607
- name: wData.name || wData.task || "Untitled",
3608
- task: wData.task || "",
3609
- maxIterations: maxIter,
3610
- cron: wizardCron,
3611
- };
3612
- loopState.phase = "crafting";
3613
- loopState.startedAt = Date.now();
3614
- saveLoopState();
3615
-
3616
- // Register in loop registry
3617
- var recordSource = wData.source === "task" ? null : "ralph";
3618
- loopRegistry.register({
3619
- id: newLoopId,
3620
- name: loopState.wizardData.name,
3621
- task: wData.task || "",
3622
- cron: wizardCron,
3623
- enabled: wizardCron ? true : false,
3624
- maxIterations: maxIter,
3625
- source: recordSource,
3626
- });
3627
-
3628
- // Create loop directory and write LOOP.json
3629
- var lDir = loopDir();
3630
- try { fs.mkdirSync(lDir, { recursive: true }); } catch (e) {}
3631
- var loopJsonPath = path.join(lDir, "LOOP.json");
3632
- var tmpLoopJson = loopJsonPath + ".tmp";
3633
- fs.writeFileSync(tmpLoopJson, JSON.stringify({ maxIterations: maxIter }, null, 2));
3634
- fs.renameSync(tmpLoopJson, loopJsonPath);
3635
-
3636
- var craftName = (loopState.wizardData && loopState.wizardData.name) || "";
3637
- var isRalphCraft = recordSource === "ralph";
3638
-
3639
- // User provided their own PROMPT.md (and optionally JUDGE.md)
3640
- if (wData.mode === "own" && wData.promptText) {
3641
- // Write PROMPT.md
3642
- var promptPath = path.join(lDir, "PROMPT.md");
3643
- var tmpPrompt = promptPath + ".tmp";
3644
- fs.writeFileSync(tmpPrompt, wData.promptText);
3645
- fs.renameSync(tmpPrompt, promptPath);
3646
-
3647
- if (wData.judgeText) {
3648
- // Both provided: write JUDGE.md too
3649
- var judgePath = path.join(lDir, "JUDGE.md");
3650
- var tmpJudge = judgePath + ".tmp";
3651
- fs.writeFileSync(tmpJudge, wData.judgeText);
3652
- fs.renameSync(tmpJudge, judgePath);
3653
- } else if (!recordSource) {
3654
- // Scheduled task with no judge: force single iteration and go to approval
3655
- var singleJson = loopJsonPath + ".tmp2";
3656
- fs.writeFileSync(singleJson, JSON.stringify({ maxIterations: 1 }, null, 2));
3657
- fs.renameSync(singleJson, loopJsonPath);
3658
-
3659
- loopState.phase = "approval";
3660
- saveLoopState();
3661
- send({ type: "ralph_phase", phase: "approval", source: recordSource, wizardData: loopState.wizardData });
3662
- send({ type: "ralph_files_status", promptReady: true, judgeReady: false, bothReady: true });
3663
- return;
3664
- } else {
3665
- // Ralph with no judge: start a crafting session to create JUDGE.md
3666
- loopState.phase = "crafting";
3667
- saveLoopState();
3668
-
3669
- var judgeCraftPrompt = "Use the /clay-ralph skill to design ONLY a JUDGE.md for an existing Ralph Loop. " +
3670
- "The user has already provided PROMPT.md, so do NOT create or modify PROMPT.md. " +
3671
- "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
3672
- "Your job is to read the existing PROMPT.md and create a JUDGE.md " +
3673
- "that will evaluate whether the coder session completed the task successfully.\n\n" +
3674
- "## Task\n" + (wData.task || "") +
3675
- "\n\n## Loop Directory\n" + lDir;
3676
-
3677
- var judgeCraftSession = sm.createSession();
3678
- judgeCraftSession.title = (isRalphCraft ? "Ralph" : "Task") + (craftName ? " " + craftName : "") + " Crafting";
3679
- judgeCraftSession.ralphCraftingMode = true;
3680
- judgeCraftSession.loop = { active: true, iteration: 0, role: "crafting", loopId: newLoopId, name: craftName, source: recordSource, startedAt: loopState.startedAt };
3681
- sm.saveSessionFile(judgeCraftSession);
3682
- sm.switchSession(judgeCraftSession.localId, null, hydrateImageRefs);
3683
- loopState.craftingSessionId = judgeCraftSession.localId;
3684
-
3685
- loopRegistry.updateRecord(newLoopId, { craftingSessionId: judgeCraftSession.localId });
3686
-
3687
- startClaudeDirWatch();
3688
-
3689
- judgeCraftSession.history.push({ type: "user_message", text: judgeCraftPrompt });
3690
- sm.appendToSessionFile(judgeCraftSession, { type: "user_message", text: judgeCraftPrompt });
3691
- sendToSession(judgeCraftSession.localId, { type: "user_message", text: judgeCraftPrompt });
3692
- judgeCraftSession.isProcessing = true;
3693
- onProcessingChanged();
3694
- judgeCraftSession.sentToolResults = {};
3695
- sendToSession(judgeCraftSession.localId, { type: "status", status: "processing" });
3696
- sdk.startQuery(judgeCraftSession, judgeCraftPrompt, undefined, getLinuxUserForSession(judgeCraftSession));
3697
-
3698
- send({ type: "ralph_crafting_started", sessionId: judgeCraftSession.localId, taskId: newLoopId, source: recordSource });
3699
- send({ type: "ralph_phase", phase: "crafting", wizardData: loopState.wizardData, craftingSessionId: judgeCraftSession.localId });
3700
- send({ type: "ralph_files_status", promptReady: true, judgeReady: false, bothReady: false });
3701
- return;
3702
- }
3703
-
3704
- // Both prompt and judge provided: go straight to approval
3705
- loopState.phase = "approval";
3706
- saveLoopState();
3707
- send({ type: "ralph_phase", phase: "approval", source: recordSource, wizardData: loopState.wizardData });
3708
- send({ type: "ralph_files_status", promptReady: true, judgeReady: true, bothReady: true });
3709
- return;
3710
- }
3711
-
3712
- // Default: "draft" mode — Clay crafts both PROMPT.md and JUDGE.md
3713
- var craftingPrompt = "Use the /clay-ralph skill to design a Ralph Loop for the following task. " +
3714
- "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
3715
- "Your job is to interview me, then create PROMPT.md and JUDGE.md files " +
3716
- "that a future autonomous session will execute.\n\n" +
3717
- "## Task\n" + (wData.task || "") +
3718
- "\n\n## Loop Directory\n" + lDir;
3719
-
3720
- // Create a new session for crafting
3721
- var craftingSession = sm.createSession();
3722
- craftingSession.title = (isRalphCraft ? "Ralph" : "Task") + (craftName ? " " + craftName : "") + " Crafting";
3723
- craftingSession.ralphCraftingMode = true;
3724
- craftingSession.loop = { active: true, iteration: 0, role: "crafting", loopId: newLoopId, name: craftName, source: recordSource, startedAt: loopState.startedAt };
3725
- sm.saveSessionFile(craftingSession);
3726
- sm.switchSession(craftingSession.localId, null, hydrateImageRefs);
3727
- loopState.craftingSessionId = craftingSession.localId;
3728
-
3729
- // Store crafting session ID in the registry record
3730
- loopRegistry.updateRecord(newLoopId, { craftingSessionId: craftingSession.localId });
3731
-
3732
- // Start .claude/ directory watcher
3733
- startClaudeDirWatch();
3734
-
3735
- // Send crafting prompt and start the conversation with Claude.
3736
- craftingSession.history.push({ type: "user_message", text: craftingPrompt });
3737
- sm.appendToSessionFile(craftingSession, { type: "user_message", text: craftingPrompt });
3738
- sendToSession(craftingSession.localId, { type: "user_message", text: craftingPrompt });
3739
- craftingSession.isProcessing = true;
3740
- onProcessingChanged();
3741
- craftingSession.sentToolResults = {};
3742
- sendToSession(craftingSession.localId, { type: "status", status: "processing" });
3743
- sdk.startQuery(craftingSession, craftingPrompt, undefined, getLinuxUserForSession(craftingSession));
3744
-
3745
- send({ type: "ralph_crafting_started", sessionId: craftingSession.localId, taskId: newLoopId, source: recordSource });
3746
- send({ type: "ralph_phase", phase: "crafting", wizardData: loopState.wizardData, craftingSessionId: craftingSession.localId });
3747
- return;
3748
- }
3749
-
3750
- if (msg.type === "loop_registry_files") {
3751
- var recId = msg.id;
3752
- var lDir = path.join(cwd, ".claude", "loops", recId);
3753
- var promptContent = "";
3754
- var judgeContent = "";
3755
- try { promptContent = fs.readFileSync(path.join(lDir, "PROMPT.md"), "utf8"); } catch (e) {}
3756
- try { judgeContent = fs.readFileSync(path.join(lDir, "JUDGE.md"), "utf8"); } catch (e) {}
3757
- send({
3758
- type: "loop_registry_files_content",
3759
- id: recId,
3760
- prompt: promptContent,
3761
- judge: judgeContent,
3762
- });
3763
- return;
3764
- }
3765
-
3766
- if (msg.type === "ralph_preview_files") {
3767
- var promptContent = "";
3768
- var judgeContent = "";
3769
- var previewDir = loopDir();
3770
- if (previewDir) {
3771
- try { promptContent = fs.readFileSync(path.join(previewDir, "PROMPT.md"), "utf8"); } catch (e) {}
3772
- try { judgeContent = fs.readFileSync(path.join(previewDir, "JUDGE.md"), "utf8"); } catch (e) {}
3773
- }
3774
- sendTo(ws, {
3775
- type: "ralph_files_content",
3776
- prompt: promptContent,
3777
- judge: judgeContent,
3778
- });
3779
- return;
3780
- }
3781
-
3782
- if (msg.type === "ralph_wizard_cancel") {
3783
- stopClaudeDirWatch();
3784
- // Clean up loop directory
3785
- var cancelDir = loopDir();
3786
- if (cancelDir) {
3787
- try { fs.rmSync(cancelDir, { recursive: true, force: true }); } catch (e) {}
3788
- }
3789
- clearLoopState();
3790
- send({ type: "ralph_phase", phase: "idle", wizardData: null });
3791
- return;
3792
- }
3793
-
3794
- if (msg.type === "ralph_cancel_crafting") {
3795
- // Abort the crafting session if running
3796
- if (loopState.craftingSessionId != null) {
3797
- var craftSession = sm.sessions.get(loopState.craftingSessionId) || null;
3798
- if (craftSession && craftSession.abortController) {
3799
- craftSession.abortController.abort();
3800
- }
3801
- }
3802
- stopClaudeDirWatch();
3803
- // Clean up loop directory
3804
- var craftCancelDir = loopDir();
3805
- if (craftCancelDir) {
3806
- try { fs.rmSync(craftCancelDir, { recursive: true, force: true }); } catch (e) {}
3807
- }
3808
- clearLoopState();
3809
- send({ type: "ralph_phase", phase: "idle", wizardData: null });
3810
- return;
3811
- }
3812
-
3813
- // --- Schedule create (from calendar click) ---
3814
- if (msg.type === "schedule_create") {
3815
- var sData = msg.data || {};
3816
- var newRec = loopRegistry.register({
3817
- name: sData.name || "Untitled",
3818
- task: sData.name || "",
3819
- description: sData.description || "",
3820
- date: sData.date || null,
3821
- time: sData.time || null,
3822
- allDay: sData.allDay !== undefined ? sData.allDay : true,
3823
- linkedTaskId: sData.taskId || null,
3824
- cron: sData.cron || null,
3825
- enabled: sData.cron ? (sData.enabled !== false) : false,
3826
- maxIterations: sData.maxIterations || 3,
3827
- source: "schedule",
3828
- color: sData.color || null,
3829
- recurrenceEnd: sData.recurrenceEnd || null,
3830
- skipIfRunning: sData.skipIfRunning !== undefined ? sData.skipIfRunning : true,
3831
- intervalEnd: sData.intervalEnd || null,
3832
- });
3833
- return;
3834
- }
3835
-
3836
- // --- Hub: cross-project schedule aggregation ---
3837
- if (msg.type === "hub_schedules_list") {
3838
- sendTo(ws, { type: "hub_schedules", schedules: getHubSchedules() });
3839
- return;
3840
- }
3841
-
3842
- // --- Loop Registry messages ---
3843
- if (msg.type === "loop_registry_list") {
3844
- sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules() });
3845
- return;
3846
- }
3847
-
3848
- if (msg.type === "loop_registry_update") {
3849
- var updatedRec = loopRegistry.update(msg.id, msg.data || {});
3850
- if (!updatedRec) {
3851
- sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
3852
- }
3853
- return;
3854
- }
3855
-
3856
- if (msg.type === "loop_registry_rename") {
3857
- if (msg.id && msg.name) {
3858
- loopRegistry.updateRecord(msg.id, { name: String(msg.name).substring(0, 100) });
3859
- sm.broadcastSessionList();
3860
- }
3861
- return;
3862
- }
3863
-
3864
- if (msg.type === "loop_registry_remove") {
3865
- var removedRec = loopRegistry.remove(msg.id);
3866
- if (!removedRec) {
3867
- sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
3868
- }
3869
- return;
3870
- }
3871
-
3872
- if (msg.type === "loop_registry_convert") {
3873
- // Convert ralph source to regular task (remove source tag)
3874
- if (msg.id) {
3875
- loopRegistry.updateRecord(msg.id, { source: null });
3876
- sm.broadcastSessionList();
3877
- }
3878
- return;
3879
- }
3880
-
3881
- if (msg.type === "delete_loop_group") {
3882
- // Delete all sessions belonging to this loopId, then remove registry record
3883
- var loopIdToDel = msg.loopId;
3884
- if (!loopIdToDel) return;
3885
- var sessionIds = [];
3886
- sm.sessions.forEach(function (s, lid) {
3887
- if (s.loop && s.loop.loopId === loopIdToDel) sessionIds.push(lid);
3888
- });
3889
- for (var di = 0; di < sessionIds.length; di++) {
3890
- sm.deleteSessionQuiet(sessionIds[di]);
3891
- }
3892
- loopRegistry.remove(loopIdToDel);
3893
- sm.broadcastSessionList();
3894
- return;
3895
- }
685
+ // --- Knowledge file management (delegated to project-knowledge.js) ---
686
+ if (_knowledge.handleKnowledgeMessage(ws, msg)) return;
3896
687
 
3897
- if (msg.type === "loop_registry_toggle") {
3898
- var toggledRec = loopRegistry.toggleEnabled(msg.id);
3899
- if (!toggledRec) {
3900
- sendTo(ws, { type: "loop_registry_error", text: "Record not found or not scheduled" });
3901
- }
3902
- return;
3903
- }
3904
-
3905
- if (msg.type === "loop_registry_rerun") {
3906
- // Re-run an existing job (one-off from library)
3907
- if (loopState.active || loopState.phase === "executing") {
3908
- sendTo(ws, { type: "loop_registry_error", text: "A loop is already running" });
3909
- return;
3910
- }
3911
- var rerunRec = loopRegistry.getById(msg.id);
3912
- if (!rerunRec) {
3913
- sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
3914
- return;
3915
- }
3916
- var rerunDir = path.join(cwd, ".claude", "loops", rerunRec.id);
3917
- try {
3918
- fs.accessSync(path.join(rerunDir, "PROMPT.md"));
3919
- } catch (e) {
3920
- sendTo(ws, { type: "loop_registry_error", text: "PROMPT.md missing for " + rerunRec.id });
3921
- return;
3922
- }
3923
- loopState.loopId = rerunRec.id;
3924
- loopState.loopFilesId = null;
3925
- activeRegistryId = null; // not a scheduled trigger
3926
- send({ type: "loop_rerun_started", recordId: rerunRec.id });
3927
- startLoop();
3928
- return;
3929
- }
3930
-
3931
- // --- Schedule message for after rate limit resets ---
3932
- if (msg.type === "schedule_message") {
3933
- var schedSession = getSessionForWs(ws);
3934
- if (!schedSession || !msg.text || !msg.resetsAt) return;
3935
- scheduleMessage(schedSession, msg.text, msg.resetsAt);
3936
- return;
3937
- }
3938
-
3939
- if (msg.type === "cancel_scheduled_message") {
3940
- var cancelSession = getSessionForWs(ws);
3941
- if (!cancelSession) return;
3942
- cancelScheduledMessage(cancelSession);
3943
- return;
3944
- }
3945
-
3946
- if (msg.type === "send_scheduled_now") {
3947
- var nowSession = getSessionForWs(ws);
3948
- if (!nowSession || !nowSession.scheduledMessage) return;
3949
- var schedText = nowSession.scheduledMessage.text;
3950
- clearTimeout(nowSession.scheduledMessage.timer);
3951
- nowSession.scheduledMessage = null;
3952
- console.log("[project] Scheduled message sent immediately for session " + nowSession.localId);
3953
- sm.sendAndRecord(nowSession, { type: "scheduled_message_sent" });
3954
- var userMsg = { type: "user_message", text: schedText };
3955
- nowSession.history.push(userMsg);
3956
- sm.appendToSessionFile(nowSession, userMsg);
3957
- sendToSession(nowSession.localId, userMsg);
3958
- nowSession.isProcessing = true;
3959
- onProcessingChanged();
3960
- sendToSession(nowSession.localId, { type: "status", status: "processing" });
3961
- sdk.startQuery(nowSession, schedText, null, getLinuxUserForSession(nowSession));
3962
- sm.broadcastSessionList();
3963
- return;
3964
- }
3965
-
3966
- if (msg.type !== "message") return;
3967
- if (!msg.text && (!msg.images || msg.images.length === 0) && (!msg.pastes || msg.pastes.length === 0)) return;
3968
-
3969
- var session = getSessionForWs(ws);
3970
- if (!session) return;
3971
-
3972
- // Backfill ownerId for legacy sessions restored without one (multi-user only)
3973
- if (!session.ownerId && ws._clayUser && usersModule.isMultiUser()) {
3974
- session.ownerId = ws._clayUser.id;
3975
- sm.saveSessionFile(session);
3976
- }
3977
-
3978
- // Keep any pending scheduled message alive when user sends a regular message
3979
-
3980
- var userMsg = { type: "user_message", text: msg.text || "" };
3981
- // Attach sender info for multi-user attribution (backward-compatible: old clients ignore these)
3982
- if (ws._clayUser) {
3983
- userMsg.from = ws._clayUser.id;
3984
- userMsg.fromName = ws._clayUser.displayName || ws._clayUser.username || "";
3985
- }
3986
- var savedImagePaths = [];
3987
- if (msg.images && msg.images.length > 0) {
3988
- userMsg.imageCount = msg.images.length;
3989
- // Save images as files, store URL references in history
3990
- var imageRefs = [];
3991
- for (var imgIdx = 0; imgIdx < msg.images.length; imgIdx++) {
3992
- var img = msg.images[imgIdx];
3993
- var savedName = saveImageFile(img.mediaType, img.data, getLinuxUserForSession(session));
3994
- if (savedName) {
3995
- imageRefs.push({ mediaType: img.mediaType, file: savedName });
3996
- savedImagePaths.push(path.join(imagesDir, savedName));
3997
- }
3998
- }
3999
- if (imageRefs.length > 0) {
4000
- userMsg.imageRefs = imageRefs;
4001
- }
4002
- }
4003
- if (msg.pastes && msg.pastes.length > 0) {
4004
- userMsg.pastes = msg.pastes;
4005
- }
4006
- session.history.push(userMsg);
4007
- sm.appendToSessionFile(session, userMsg);
4008
- sendToSessionOthers(ws, session.localId, hydrateImageRefs(userMsg));
4009
-
4010
- if (!session.title) {
4011
- session.title = (msg.text || "Image").substring(0, 50);
4012
- sm.saveSessionFile(session);
4013
- sm.broadcastSessionList();
4014
- // Sync auto-title to SDK
4015
- if (session.cliSessionId) {
4016
- getSDK().then(function(sdk) {
4017
- sdk.renameSession(session.cliSessionId, session.title, { dir: cwd }).catch(function(e) {
4018
- console.error("[project] SDK renameSession failed:", e.message);
4019
- });
4020
- }).catch(function() {});
4021
- }
4022
- }
4023
-
4024
- var fullText = msg.text || "";
4025
- // Prepend saved image paths so Claude can copy/save them
4026
- if (savedImagePaths.length > 0) {
4027
- var imgPathLines = savedImagePaths.map(function (p) { return "[Uploaded image: " + p + "]"; }).join("\n");
4028
- fullText = imgPathLines + (fullText ? "\n" + fullText : "");
4029
- }
4030
- if (msg.pastes && msg.pastes.length > 0) {
4031
- for (var pi = 0; pi < msg.pastes.length; pi++) {
4032
- if (fullText) fullText += "\n\n";
4033
- fullText += msg.pastes[pi];
4034
- }
4035
- }
4036
-
4037
- // Inject pending @mention context so the current agent sees the exchange
4038
- if (session.pendingMentionContexts && session.pendingMentionContexts.length > 0) {
4039
- var mentionPrefix = session.pendingMentionContexts.join("\n\n");
4040
- session.pendingMentionContexts = [];
4041
- fullText = mentionPrefix + "\n\n" + fullText;
4042
- }
4043
-
4044
- // Inject active terminal context sources (delta only: send new output since last message)
4045
- var TERM_CONTEXT_MAX = 8192; // 8KB max per terminal per message
4046
- var TERM_HEAD_SIZE = 2048; // keep first 2KB for error context
4047
- var TERM_TAIL_SIZE = 6144; // keep last 6KB for recent state
4048
- var ctxSources = loadContextSources(slug);
4049
- if (ctxSources.length > 0) {
4050
- if (!session._termContextCursors) session._termContextCursors = {};
4051
- var termContextParts = [];
4052
- for (var ci = 0; ci < ctxSources.length; ci++) {
4053
- var srcId = ctxSources[ci];
4054
- if (srcId.startsWith("term:")) {
4055
- var termId = parseInt(srcId.split(":")[1], 10);
4056
- var sb = tm.getScrollback(termId);
4057
- if (sb) {
4058
- var lastCursor;
4059
- if (termId in session._termContextCursors) {
4060
- lastCursor = session._termContextCursors[termId];
4061
- // Terminal was recycled (closed and reopened with same ID) — reset cursor
4062
- if (lastCursor > sb.totalBytesWritten) lastCursor = 0;
4063
- } else {
4064
- // First time seeing this terminal — include last 8KB (what user can see now)
4065
- lastCursor = Math.max(0, sb.totalBytesWritten - TERM_CONTEXT_MAX);
4066
- }
4067
- var newBytes = sb.totalBytesWritten - lastCursor;
4068
- session._termContextCursors[termId] = sb.totalBytesWritten;
4069
- if (newBytes <= 0) continue;
4070
- // Build timestamped delta from chunks
4071
- var deltaChunks = [];
4072
- var bytePos = sb.bufferStart;
4073
- for (var chunkIdx = 0; chunkIdx < sb.chunks.length; chunkIdx++) {
4074
- var chunk = sb.chunks[chunkIdx];
4075
- var chunkEnd = bytePos + chunk.data.length;
4076
- if (chunkEnd > lastCursor) {
4077
- // This chunk has new content
4078
- var chunkData = chunk.data;
4079
- if (bytePos < lastCursor) {
4080
- // Partial chunk: only the part after lastCursor
4081
- chunkData = chunkData.slice(lastCursor - bytePos);
4082
- }
4083
- deltaChunks.push({ ts: chunk.ts, data: chunkData });
4084
- }
4085
- bytePos = chunkEnd;
4086
- }
4087
- if (deltaChunks.length === 0) continue;
4088
- // Format with timestamps: group by second to avoid excessive timestamps
4089
- var lines = [];
4090
- var lastTimeSec = 0;
4091
- for (var di = 0; di < deltaChunks.length; di++) {
4092
- var dc = deltaChunks[di];
4093
- var cleaned = dc.data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
4094
- if (!cleaned) continue;
4095
- var timeSec = Math.floor(dc.ts / 1000);
4096
- if (timeSec !== lastTimeSec) {
4097
- var d = new Date(dc.ts);
4098
- var timeStr = d.toTimeString().slice(0, 8); // HH:MM:SS
4099
- lines.push("[" + timeStr + "] " + cleaned);
4100
- lastTimeSec = timeSec;
4101
- } else {
4102
- lines.push(cleaned);
4103
- }
4104
- }
4105
- var delta = lines.join("").trim();
4106
- if (!delta) continue;
4107
- var termInfo = tm.list().find(function(t) { return t.id === termId; });
4108
- var termTitle = termInfo ? termInfo.title : "Terminal " + termId;
4109
- var header;
4110
- if (delta.length > TERM_CONTEXT_MAX) {
4111
- var head = delta.slice(0, TERM_HEAD_SIZE);
4112
- var tail = delta.slice(-TERM_TAIL_SIZE);
4113
- var omittedBytes = delta.length - TERM_HEAD_SIZE - TERM_TAIL_SIZE;
4114
- var omittedLines = delta.slice(TERM_HEAD_SIZE, delta.length - TERM_TAIL_SIZE).split("\n").length;
4115
- delta = head + "\n\n... (" + omittedLines + " lines / " + Math.round(omittedBytes / 1024) + "KB omitted) ...\n\n" + tail;
4116
- header = "[New terminal output from " + termTitle + " (large output, head+tail shown)]";
4117
- } else {
4118
- header = "[New terminal output from " + termTitle + "]";
4119
- }
4120
- termContextParts.push(header + "\n```\n" + delta + "\n```");
4121
- }
4122
- }
4123
- }
4124
- if (termContextParts.length > 0) {
4125
- fullText = termContextParts.join("\n\n") + "\n\n" + fullText;
4126
- }
4127
- }
4128
-
4129
- // Collect browser tab context (async: requires round-trip to client extension)
4130
- var tabSources = ctxSources.filter(function(id) {
4131
- if (!id.startsWith("tab:")) return false;
4132
- // Only include tabs that currently exist in the browser
4133
- var tid = parseInt(id.split(":")[1], 10);
4134
- return !!_browserTabList[tid];
4135
- });
4136
-
4137
- function dispatchToSdk(finalText) {
4138
- if (!session.isProcessing) {
4139
- session.isProcessing = true;
4140
- onProcessingChanged();
4141
- session.sentToolResults = {};
4142
- sendToSession(session.localId, { type: "status", status: "processing" });
4143
- if (!session.queryInstance && (!session.worker || session.messageQueue !== "worker")) {
4144
- // No active query (or worker idle between queries): start a new query
4145
- session._queryStartTs = Date.now();
4146
- console.log("[PERF] project.js: startQuery called, localId=" + session.localId + " t=0ms");
4147
- sdk.startQuery(session, finalText, msg.images, getLinuxUserForSession(session));
4148
- } else {
4149
- sdk.pushMessage(session, finalText, msg.images);
4150
- }
4151
- } else {
4152
- sdk.pushMessage(session, finalText, msg.images);
4153
- }
4154
- sm.broadcastSessionList();
4155
- }
4156
-
4157
- if (tabSources.length > 0) {
4158
- // Request tab context from all active browser tab sources
4159
- var tabPromises = tabSources.map(function(srcId) {
4160
- var tabId = parseInt(srcId.split(":")[1], 10);
4161
- return requestTabContext(ws, tabId);
4162
- });
4163
- Promise.all(tabPromises).then(function(results) {
4164
- var tabContextParts = [];
4165
- var screenshotImages = [];
4166
-
4167
- for (var ti = 0; ti < results.length; ti++) {
4168
- if (!results[ti]) continue;
4169
- var tabId2 = parseInt(tabSources[ti].split(":")[1], 10);
4170
- var tabInfo = _browserTabList[tabId2];
4171
- var tabLabel = tabInfo ? (tabInfo.title || tabInfo.url || "Tab " + tabId2) : "Tab " + tabId2;
4172
- var r = results[ti];
4173
- var parts = [];
4174
-
4175
- // Console logs
4176
- if (r.console && r.console.logs) {
4177
- try {
4178
- var logs = typeof r.console.logs === "string" ? JSON.parse(r.console.logs) : r.console.logs;
4179
- if (logs && logs.length > 0) {
4180
- var logLines = [];
4181
- var logSlice = logs.slice(-50);
4182
- for (var li = 0; li < logSlice.length; li++) {
4183
- var entry = logSlice[li];
4184
- var ts = entry.ts ? new Date(entry.ts).toTimeString().slice(0, 8) : "";
4185
- var lvl = (entry.level || "log").toUpperCase();
4186
- logLines.push("[" + ts + " " + lvl + "] " + (entry.text || ""));
4187
- }
4188
- parts.push("Console:\n" + logLines.join("\n"));
4189
- }
4190
- } catch (e) {
4191
- // ignore parse errors
4192
- }
4193
- }
4194
-
4195
- // Network requests
4196
- if (r.network && r.network.network) {
4197
- try {
4198
- var netLog = typeof r.network.network === "string" ? JSON.parse(r.network.network) : r.network.network;
4199
- if (netLog && netLog.length > 0) {
4200
- var netLines = [];
4201
- var netSlice = netLog.slice(-30);
4202
- for (var ni = 0; ni < netSlice.length; ni++) {
4203
- var req = netSlice[ni];
4204
- var line = (req.method || "GET") + " " + (req.url || "") + " " + (req.status || 0) + " " + (req.duration || 0) + "ms";
4205
- if (req.error) line += " [" + req.error + "]";
4206
- netLines.push(line);
4207
- }
4208
- parts.push("Network (last " + netSlice.length + " requests):\n" + netLines.join("\n"));
4209
- }
4210
- } catch (e) {
4211
- // ignore parse errors
4212
- }
4213
- }
4214
-
4215
- // Page text (from tab_page_text command)
4216
- if (r.pageText && (r.pageText.text || r.pageText.value)) {
4217
- var pageContent = r.pageText.text || r.pageText.value;
4218
- if (pageContent.length > 0) {
4219
- if (pageContent.length > 32768) {
4220
- pageContent = pageContent.substring(0, 32768) + "\n... (truncated)";
4221
- }
4222
- parts.push("Page text:\n" + pageContent);
4223
- }
4224
- }
4225
-
4226
- // Screenshot — save to disk and add to images for SDK
4227
- if (r.screenshot && r.screenshot.image) {
4228
- try {
4229
- var screenshotData = r.screenshot.image;
4230
- var screenshotName = saveImageFile("image/png", screenshotData, getLinuxUserForSession(session));
4231
- if (screenshotName) {
4232
- var screenshotPath = path.join(imagesDir, screenshotName);
4233
- // Add to images array for SDK multimodal
4234
- screenshotImages.push({
4235
- mediaType: "image/png",
4236
- data: screenshotData,
4237
- file: screenshotName,
4238
- tabTitle: tabLabel,
4239
- tabUrl: tabInfo ? tabInfo.url : "",
4240
- tabFavIconUrl: tabInfo ? tabInfo.favIconUrl : ""
4241
- });
4242
- parts.push("[Screenshot saved: " + screenshotPath + "]");
4243
- }
4244
- } catch (e) {
4245
- // ignore screenshot save errors
4246
- }
4247
- }
4248
-
4249
- if (r.console && r.console.error) {
4250
- parts.push("(Console error: " + r.console.error + ")");
4251
- }
4252
- if (r.network && r.network.error) {
4253
- parts.push("(Network error: " + r.network.error + ")");
4254
- }
4255
-
4256
- if (parts.length > 0) {
4257
- tabContextParts.push("[Browser tab: " + tabLabel + "]\n" + parts.join("\n\n"));
4258
- }
4259
- }
688
+ // --- Memory (session digests) management (delegated to project-memory.js) ---
689
+ if (msg.type === "memory_list") { _memory.handleMemoryList(ws); return; }
690
+ if (msg.type === "memory_search") { _memory.handleMemorySearch(ws, msg); return; }
691
+ if (msg.type === "memory_delete") { _memory.handleMemoryDelete(ws, msg); return; }
4260
692
 
4261
- if (tabContextParts.length > 0) {
4262
- fullText = "[The following browser tab data is automatically attached as context sources. Do NOT call browser_read_page, browser_console, browser_network, or browser_screenshot for these tabs — the data is already here.]\n\n" +
4263
- tabContextParts.join("\n\n---\n\n") + "\n\n" + fullText;
4264
- }
693
+ // --- Sessions, config, project mgmt (delegated to project-sessions.js) ---
694
+ if (_sessions.handleSessionsMessage(ws, msg)) return;
4265
695
 
4266
- // If screenshots were captured, send context preview cards and add to SDK images
4267
- if (screenshotImages.length > 0) {
4268
- if (!msg.images) msg.images = [];
4269
- for (var si = 0; si < screenshotImages.length; si++) {
4270
- var ss = screenshotImages[si];
4271
- // Save context_preview to history so it restores on session load
4272
- var previewEntry = {
4273
- type: "context_preview",
4274
- tab: {
4275
- title: ss.tabTitle || "",
4276
- url: ss.tabUrl || "",
4277
- favIconUrl: ss.tabFavIconUrl || "",
4278
- screenshotFile: ss.file
4279
- }
4280
- };
4281
- session.history.push(previewEntry);
4282
- // Send context card to all clients
4283
- sendToSession(session.localId, {
4284
- type: "context_preview",
4285
- tab: {
4286
- title: ss.tabTitle || "",
4287
- url: ss.tabUrl || "",
4288
- favIconUrl: ss.tabFavIconUrl || "",
4289
- screenshotUrl: "/p/" + slug + "/images/" + ss.file
4290
- }
4291
- });
4292
- // Add to SDK images for multimodal
4293
- msg.images.push({ mediaType: ss.mediaType, data: ss.data });
4294
- }
4295
- sm.saveSessionFile(session);
4296
- }
696
+ // --- Filesystem, settings, env (delegated to project-filesystem.js) ---
697
+ if (_filesystem.handleFilesystemMessage(ws, msg)) return;
4297
698
 
4298
- dispatchToSdk(fullText);
4299
- });
4300
- } else {
4301
- dispatchToSdk(fullText);
4302
- }
699
+ // --- Notes, terminals, context, user message (delegated to project-user-message.js) ---
700
+ if (_userMessage.handleUserMessage(ws, msg)) return;
4303
701
  }
4304
702
 
4305
703
  // --- Shared helpers ---
@@ -4415,647 +813,179 @@ function createProjectContext(opts) {
4415
813
  send({ type: "session_presence", presence: presence });
4416
814
  }
4417
815
 
4418
- // --- WS disconnection handler ---
816
+ // --- WS disconnection handler (delegated to project-connection.js) ---
4419
817
  function handleDisconnection(ws) {
4420
- // Persist last active session for this user before cleanup
4421
- if (ws._clayActiveSession) {
4422
- var dcPresKey = ws._clayUser ? ws._clayUser.id : "_default";
4423
- var dcExisting = userPresence.getPresence(slug, dcPresKey);
4424
- userPresence.setPresence(slug, dcPresKey, ws._clayActiveSession, dcExisting ? dcExisting.mateDm : null);
4425
- }
4426
- tm.detachAll(ws);
4427
- clients.delete(ws);
4428
- if (clients.size === 0) {
4429
- stopFileWatch();
4430
- stopAllDirWatches();
4431
- }
4432
- broadcastClientCount();
4433
- broadcastPresence();
818
+ _connection.handleDisconnection(ws);
4434
819
  }
4435
820
 
4436
- // --- Handle project-scoped HTTP requests ---
4437
- function handleHTTP(req, res, urlPath) {
4438
- // Browser MCP extension bridge: forward commands to Chrome extension
4439
- if (req.method === "POST" && urlPath === "/ext-command") {
4440
- parseJsonBody(req).then(function (body) {
4441
- // Validate auth token
4442
- if (!body.token || body.token !== _extToken) {
4443
- res.writeHead(403, { "Content-Type": "application/json" });
4444
- res.end('{"error":"Invalid token"}');
4445
- return;
4446
- }
4447
- var command = body.command;
4448
- var args = body.args || {};
4449
- var timeout = Math.min(body.timeout || 5000, 30000); // max 30s
4450
-
4451
- // Special command: list_tabs (no extension round-trip needed)
4452
- if (command === "list_tabs") {
4453
- var tabArr = [];
4454
- for (var tid in _browserTabList) {
4455
- tabArr.push(_browserTabList[tid]);
4456
- }
4457
- res.writeHead(200, { "Content-Type": "application/json" });
4458
- res.end(JSON.stringify({ result: { tabs: tabArr } }));
4459
- return;
4460
- }
4461
-
4462
- sendExtensionCommandAny(command, args, timeout).then(function (result) {
4463
- res.writeHead(200, { "Content-Type": "application/json" });
4464
- res.end(JSON.stringify({ result: result || {} }));
4465
- }).catch(function (err) {
4466
- res.writeHead(200, { "Content-Type": "application/json" });
4467
- res.end(JSON.stringify({ error: err.message || "Extension command failed" }));
4468
- });
4469
- }).catch(function () {
4470
- res.writeHead(400, { "Content-Type": "application/json" });
4471
- res.end('{"error":"Invalid JSON body"}');
4472
- });
4473
- return true;
4474
- }
4475
-
4476
- // Serve chat images
4477
- if (req.method === "GET" && urlPath.indexOf("/images/") === 0) {
4478
- var imgName = path.basename(urlPath);
4479
- // Sanitize: only allow expected filename pattern
4480
- if (!/^\d+-[a-f0-9]+\.\w+$/.test(imgName)) {
4481
- res.writeHead(400);
4482
- res.end("Bad request");
4483
- return true;
4484
- }
4485
- var imgPath = path.join(imagesDir, imgName);
4486
- try {
4487
- var imgBuf = fs.readFileSync(imgPath);
4488
- var ext = path.extname(imgName).toLowerCase();
4489
- var mime = ext === ".png" ? "image/png" : ext === ".gif" ? "image/gif" : ext === ".webp" ? "image/webp" : "image/jpeg";
4490
- res.writeHead(200, { "Content-Type": mime, "Cache-Control": "public, max-age=86400" });
4491
- res.end(imgBuf);
4492
- } catch (e) {
4493
- res.writeHead(404);
4494
- res.end("Not found");
4495
- }
4496
- return true;
4497
- }
4498
-
4499
- // File upload
4500
- if (req.method === "POST" && urlPath === "/api/upload") {
4501
- parseJsonBody(req).then(function (body) {
4502
- var fileName = body.name;
4503
- var fileData = body.data; // base64
4504
- if (!fileName || !fileData) {
4505
- res.writeHead(400, { "Content-Type": "application/json" });
4506
- res.end('{"error":"missing name or data"}');
4507
- return;
4508
- }
4509
- // Sanitize filename — strip path separators
4510
- var safeName = path.basename(fileName).replace(/[\x00-\x1f\/\\:*?"<>|]/g, "_");
4511
- if (!safeName) safeName = "upload";
4512
-
4513
- // Check size
4514
- var estimatedBytes = fileData.length * 0.75;
4515
- if (estimatedBytes > MAX_UPLOAD_BYTES) {
4516
- res.writeHead(413, { "Content-Type": "application/json" });
4517
- res.end('{"error":"file too large (max 50MB)"}');
4518
- return;
4519
- }
4520
-
4521
- // Create tmp dir: os.tmpdir()/clay-{hash}/
4522
- var cwdHash = crypto.createHash("sha256").update(cwd).digest("hex").substring(0, 12);
4523
- var tmpDir = path.join(os.tmpdir(), "clay-" + cwdHash);
4524
- try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (e) {}
4525
-
4526
- // Add timestamp prefix to avoid collisions
4527
- var ts = Date.now();
4528
- var destName = ts + "-" + safeName;
4529
- var destPath = path.join(tmpDir, destName);
4530
-
4531
- try {
4532
- var buf = Buffer.from(fileData, "base64");
4533
- fs.writeFileSync(destPath, buf);
4534
- // Make readable by all local users and chown to session owner
4535
- try { fs.chmodSync(destPath, 0o644); } catch (e2) {}
4536
- try { fs.chmodSync(tmpDir, 0o755); } catch (e2) {}
4537
- if (req._clayUser && req._clayUser.linuxUser) {
4538
- try {
4539
- var _osUM = require("./os-users");
4540
- var _uid = _osUM.getLinuxUserUid(req._clayUser.linuxUser);
4541
- if (_uid != null) {
4542
- require("child_process").execSync("chown " + _uid + " " + JSON.stringify(destPath));
4543
- require("child_process").execSync("chown " + _uid + " " + JSON.stringify(tmpDir));
4544
- }
4545
- } catch (e2) {}
4546
- }
4547
- res.writeHead(200, { "Content-Type": "application/json" });
4548
- res.end(JSON.stringify({ path: destPath, name: safeName }));
4549
- } catch (e) {
4550
- res.writeHead(500, { "Content-Type": "application/json" });
4551
- res.end(JSON.stringify({ error: "failed to save: " + (e.message || e) }));
4552
- }
4553
- }).catch(function () {
4554
- res.writeHead(400);
4555
- res.end("Bad request");
4556
- });
4557
- return true;
4558
- }
4559
-
4560
- // Push subscribe
4561
- if (req.method === "POST" && urlPath === "/api/push-subscribe") {
4562
- parseJsonBody(req).then(function (body) {
4563
- var sub = body.subscription || body;
4564
- if (pushModule) pushModule.addSubscription(sub, body.replaceEndpoint);
4565
- res.writeHead(200, { "Content-Type": "application/json" });
4566
- res.end('{"ok":true}');
4567
- }).catch(function () {
4568
- res.writeHead(400);
4569
- res.end("Bad request");
4570
- });
4571
- return true;
4572
- }
4573
-
4574
- // Permission response from push notification
4575
- if (req.method === "POST" && urlPath === "/api/permission-response") {
4576
- parseJsonBody(req).then(function (data) {
4577
- var requestId = data.requestId;
4578
- var decision = data.decision;
4579
- if (!requestId || !decision) {
4580
- res.writeHead(400, { "Content-Type": "application/json" });
4581
- res.end('{"error":"missing requestId or decision"}');
4582
- return;
4583
- }
4584
- var found = false;
4585
- sm.sessions.forEach(function (session) {
4586
- var pending = session.pendingPermissions[requestId];
4587
- if (!pending) return;
4588
- found = true;
4589
- delete session.pendingPermissions[requestId];
4590
- if (decision === "allow") {
4591
- pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
4592
- } else {
4593
- pending.resolve({ behavior: "deny", message: "Denied via push notification" });
4594
- }
4595
- sm.sendAndRecord(session, {
4596
- type: "permission_resolved",
4597
- requestId: requestId,
4598
- decision: decision,
4599
- });
4600
- });
4601
- if (found) {
4602
- res.writeHead(200, { "Content-Type": "application/json" });
4603
- res.end('{"ok":true}');
4604
- } else {
4605
- res.writeHead(404, { "Content-Type": "application/json" });
4606
- res.end('{"error":"permission request not found"}');
4607
- }
4608
- }).catch(function () {
4609
- res.writeHead(400);
4610
- res.end("Bad request");
4611
- });
4612
- return true;
4613
- }
4614
-
4615
- // VAPID public key
4616
- if (req.method === "GET" && urlPath === "/api/vapid-public-key") {
4617
- if (pushModule) {
4618
- res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store" });
4619
- res.end(JSON.stringify({ publicKey: pushModule.publicKey }));
4620
- } else {
4621
- res.writeHead(404, { "Content-Type": "application/json" });
4622
- res.end('{"error":"push not available"}');
4623
- }
4624
- return true;
4625
- }
4626
-
4627
- // File browser: serve project images
4628
- if (req.method === "GET" && urlPath.startsWith("/api/file?")) {
4629
- var qIdx = urlPath.indexOf("?");
4630
- var params = new URLSearchParams(urlPath.substring(qIdx));
4631
- var reqFilePath = params.get("path");
4632
- if (!reqFilePath) { res.writeHead(400); res.end("Missing path"); return true; }
4633
- var absFile = safePath(cwd, reqFilePath);
4634
- if (!absFile && getOsUserInfoForReq(req)) {
4635
- absFile = safeAbsPath(reqFilePath);
4636
- }
4637
- if (!absFile) { res.writeHead(403); res.end("Access denied"); return true; }
4638
- var fileExt = path.extname(absFile).toLowerCase();
4639
- if (!IMAGE_EXTS.has(fileExt)) { res.writeHead(403); res.end("Only image files"); return true; }
4640
- try {
4641
- var fileServeUserInfo = getOsUserInfoForReq(req);
4642
- var fileContent;
4643
- if (fileServeUserInfo) {
4644
- var binResult = fsAsUser("read_binary", { file: absFile }, fileServeUserInfo);
4645
- fileContent = binResult.buffer;
4646
- } else {
4647
- fileContent = fs.readFileSync(absFile);
4648
- }
4649
- var fileMime = MIME_TYPES[fileExt] || "application/octet-stream";
4650
- res.writeHead(200, { "Content-Type": fileMime, "Cache-Control": "no-cache" });
4651
- res.end(fileContent);
4652
- } catch (e) {
4653
- res.writeHead(404); res.end("Not found");
4654
- }
4655
- return true;
4656
- }
4657
-
4658
- // Skills permission gate
4659
- if (urlPath === "/api/install-skill" || urlPath === "/api/uninstall-skill" || urlPath === "/api/installed-skills") {
4660
- if (req._clayUser) {
4661
- var skPerms = usersModule.getEffectivePermissions(req._clayUser, osUsers);
4662
- if (!skPerms.skills) {
4663
- res.writeHead(403, { "Content-Type": "application/json" });
4664
- res.end('{"error":"Skills access is not permitted"}');
4665
- return true;
4666
- }
4667
- }
4668
- }
4669
-
4670
- // Install a skill (background spawn)
4671
- if (req.method === "POST" && urlPath === "/api/install-skill") {
4672
- parseJsonBody(req).then(function (body) {
4673
- var url = body.url;
4674
- var skill = body.skill;
4675
- var scope = body.scope; // "global" or "project"
4676
- if (!url || !skill || !scope) {
4677
- res.writeHead(400, { "Content-Type": "application/json" });
4678
- res.end('{"error":"missing url, skill, or scope"}');
4679
- return;
4680
- }
4681
- // Validate skill name: alphanumeric, hyphens, underscores only
4682
- if (!/^[a-zA-Z0-9_-]+$/.test(skill)) {
4683
- res.writeHead(400, { "Content-Type": "application/json" });
4684
- res.end('{"error":"invalid skill name"}');
4685
- return;
4686
- }
4687
- // Validate URL: must be https://
4688
- if (!/^https:\/\//i.test(url)) {
4689
- res.writeHead(400, { "Content-Type": "application/json" });
4690
- res.end('{"error":"only https:// URLs are allowed"}');
4691
- return;
4692
- }
4693
- var skillUserInfo = getOsUserInfoForReq(req);
4694
- var spawnCwd = scope === "global" ? (skillUserInfo ? skillUserInfo.home : require("./config").REAL_HOME) : cwd;
4695
- var scopeFlag = scope === "global" ? "--global" : "--project";
4696
- var skillSpawnOpts = {
4697
- cwd: spawnCwd,
4698
- stdio: ["ignore", "pipe", "pipe"],
4699
- detached: false,
4700
- };
4701
- if (skillUserInfo) {
4702
- skillSpawnOpts.uid = skillUserInfo.uid;
4703
- skillSpawnOpts.gid = skillUserInfo.gid;
4704
- skillSpawnOpts.env = Object.assign({}, process.env, {
4705
- HOME: skillUserInfo.home,
4706
- npm_config_cache: require("path").join(skillUserInfo.home, ".npm"),
4707
- });
4708
- }
4709
- console.log("[skill-install] spawning: npx skills add " + url + " --skill " + skill + " --yes " + scopeFlag + " (cwd: " + spawnCwd + ")");
4710
- var child = spawn("npx", ["skills", "add", url, "--skill", skill, "--yes", scopeFlag], skillSpawnOpts);
4711
- var stdoutBuf = "";
4712
- var stderrBuf = "";
4713
- child.stdout.on("data", function (chunk) {
4714
- stdoutBuf += chunk.toString();
4715
- console.log("[skill-install] " + skill + " stdout chunk: " + chunk.toString().trim().slice(0, 500));
4716
- });
4717
- child.stderr.on("data", function (chunk) {
4718
- stderrBuf += chunk.toString();
4719
- console.log("[skill-install] " + skill + " stderr chunk: " + chunk.toString().trim().slice(0, 500));
4720
- });
4721
- // Timeout after 60 seconds
4722
- var installTimeout = setTimeout(function () {
4723
- console.error("[skill-install] " + skill + " timed out after 60s, killing process");
4724
- try { child.kill("SIGTERM"); } catch (e) {}
4725
- try {
4726
- send({ type: "skill_installed", skill: skill, scope: scope, success: false, error: "Installation timed out after 60 seconds" });
4727
- } catch (e) {}
4728
- }, 60000);
4729
- child.on("close", function (code) {
4730
- clearTimeout(installTimeout);
4731
- console.log("[skill-install] " + skill + " exited with code " + code + " (stdout=" + stdoutBuf.length + "b, stderr=" + stderrBuf.length + "b)");
4732
- if (stdoutBuf) console.log("[skill-install] stdout: " + stdoutBuf.slice(0, 2000));
4733
- if (stderrBuf) console.log("[skill-install] stderr: " + stderrBuf.slice(0, 2000));
4734
- try {
4735
- var success = code === 0;
4736
- send({
4737
- type: "skill_installed",
4738
- skill: skill,
4739
- scope: scope,
4740
- success: success,
4741
- error: success ? null : "Process exited with code " + code,
4742
- });
4743
- } catch (e) {
4744
- console.error("[project] skill_installed send failed:", e.message || e);
4745
- }
4746
- });
4747
- child.on("error", function (err) {
4748
- clearTimeout(installTimeout);
4749
- console.error("[skill-install] " + skill + " spawn error:", err.message || err);
4750
- try {
4751
- send({
4752
- type: "skill_installed",
4753
- skill: skill,
4754
- scope: scope,
4755
- success: false,
4756
- error: err.message,
4757
- });
4758
- } catch (e) {
4759
- console.error("[skill-install] " + skill + " send failed:", e.message || e);
4760
- }
4761
- });
4762
- res.writeHead(200, { "Content-Type": "application/json" });
4763
- res.end('{"ok":true}');
4764
- }).catch(function () {
4765
- res.writeHead(400);
4766
- res.end("Bad request");
4767
- });
4768
- return true;
4769
- }
4770
-
4771
- // Uninstall a skill (remove directory)
4772
- if (req.method === "POST" && urlPath === "/api/uninstall-skill") {
4773
- parseJsonBody(req).then(function (body) {
4774
- var skill = body.skill;
4775
- var scope = body.scope; // "global" or "project"
4776
- if (!skill || !scope) {
4777
- res.writeHead(400, { "Content-Type": "application/json" });
4778
- res.end('{"error":"missing skill or scope"}');
4779
- return;
4780
- }
4781
- // Validate skill name
4782
- if (!/^[a-zA-Z0-9_-]+$/.test(skill)) {
4783
- res.writeHead(400, { "Content-Type": "application/json" });
4784
- res.end('{"error":"invalid skill name"}');
4785
- return;
4786
- }
4787
- var uninstallUserInfo = getOsUserInfoForReq(req);
4788
- var baseDir = scope === "global" ? (uninstallUserInfo ? uninstallUserInfo.home : require("./config").REAL_HOME) : cwd;
4789
- var skillDir = path.join(baseDir, ".claude", "skills", skill);
4790
- // Safety: ensure skillDir is inside the expected .claude/skills directory
4791
- var expectedParent = path.join(baseDir, ".claude", "skills");
4792
- var resolved = path.resolve(skillDir);
4793
- if (!resolved.startsWith(expectedParent + path.sep)) {
4794
- res.writeHead(403, { "Content-Type": "application/json" });
4795
- res.end('{"error":"invalid skill path"}');
4796
- return;
4797
- }
4798
- try {
4799
- if (uninstallUserInfo) {
4800
- // Run rm as target user to respect permissions
4801
- var rmScript = "var fs = require('fs'); fs.rmSync(" + JSON.stringify(resolved) + ", { recursive: true, force: true });";
4802
- execFileSync(process.execPath, ["-e", rmScript], {
4803
- uid: uninstallUserInfo.uid,
4804
- gid: uninstallUserInfo.gid,
4805
- timeout: 10000,
4806
- });
4807
- } else {
4808
- fs.rmSync(resolved, { recursive: true, force: true });
4809
- }
4810
- send({
4811
- type: "skill_uninstalled",
4812
- skill: skill,
4813
- scope: scope,
4814
- success: true,
4815
- });
4816
- res.writeHead(200, { "Content-Type": "application/json" });
4817
- res.end('{"ok":true}');
4818
- } catch (err) {
4819
- send({
4820
- type: "skill_uninstalled",
4821
- skill: skill,
4822
- scope: scope,
4823
- success: false,
4824
- error: err.message,
4825
- });
4826
- res.writeHead(500, { "Content-Type": "application/json" });
4827
- res.end(JSON.stringify({ error: err.message }));
4828
- }
4829
- }).catch(function () {
4830
- res.writeHead(400);
4831
- res.end("Bad request");
4832
- });
4833
- return true;
4834
- }
4835
-
4836
- // Installed skills (global + project)
4837
- if (req.method === "GET" && urlPath === "/api/installed-skills") {
4838
- var installed = {};
4839
- var globalDir = path.join(require("./config").REAL_HOME, ".claude", "skills");
4840
- var projectDir = path.join(cwd, ".claude", "skills");
4841
- var scanDirs = [
4842
- { dir: globalDir, scope: "global" },
4843
- { dir: projectDir, scope: "project" },
4844
- ];
4845
- for (var sd = 0; sd < scanDirs.length; sd++) {
4846
- var entries;
4847
- try { entries = fs.readdirSync(scanDirs[sd].dir, { withFileTypes: true }); } catch (e) { continue; }
4848
- for (var si = 0; si < entries.length; si++) {
4849
- var ent = entries[si];
4850
- if (!ent.isDirectory() && !ent.isSymbolicLink()) continue;
4851
- var mdPath = path.join(scanDirs[sd].dir, ent.name, "SKILL.md");
4852
- try {
4853
- var mdContent = fs.readFileSync(mdPath, "utf8");
4854
- var desc = "";
4855
- // Parse YAML frontmatter for description
4856
- var version = "";
4857
- if (mdContent.startsWith("---")) {
4858
- var endIdx = mdContent.indexOf("---", 3);
4859
- if (endIdx !== -1) {
4860
- var frontmatter = mdContent.substring(3, endIdx);
4861
- var descMatch = frontmatter.match(/^description:\s*(.+)/m);
4862
- if (descMatch) desc = descMatch[1].trim();
4863
- var verMatch = frontmatter.match(/version:\s*"?([^"\n]+)"?/m);
4864
- if (verMatch) version = verMatch[1].trim();
4865
- }
4866
- }
4867
- if (!installed[ent.name]) {
4868
- installed[ent.name] = { scope: scanDirs[sd].scope, description: desc, version: version, path: path.join(scanDirs[sd].dir, ent.name) };
4869
- } else {
4870
- // project-level adds to existing global entry
4871
- installed[ent.name].scope = "both";
4872
- if (desc && !installed[ent.name].description) installed[ent.name].description = desc;
4873
- if (version && !installed[ent.name].version) installed[ent.name].version = version;
4874
- }
4875
- } catch (e) {}
4876
- }
4877
- }
4878
- res.writeHead(200, { "Content-Type": "application/json" });
4879
- res.end(JSON.stringify({ installed: installed }));
4880
- return true;
4881
- }
4882
-
4883
- // Check skill updates (compare installed vs remote versions)
4884
- if (req.method === "POST" && urlPath === "/api/check-skill-updates") {
4885
- parseJsonBody(req).then(function (body) {
4886
- var skills = body.skills; // [{ name, url, scope }]
4887
- if (!Array.isArray(skills) || skills.length === 0) {
4888
- res.writeHead(400, { "Content-Type": "application/json" });
4889
- res.end('{"error":"missing skills array"}');
4890
- return;
4891
- }
4892
- // Read installed versions (use requesting user's home in multi-user setups)
4893
- var skillUserHome = (function () {
4894
- var sui = getOsUserInfoForReq(req);
4895
- return sui ? sui.home : require("./config").REAL_HOME;
4896
- })();
4897
- var globalSkillsDir = path.join(skillUserHome, ".claude", "skills");
4898
- var projectSkillsDir = path.join(cwd, ".claude", "skills");
4899
- var results = [];
4900
- var pending = skills.length;
4901
-
4902
- function parseVersionFromSkillMd(content) {
4903
- if (!content || !content.startsWith("---")) return "";
4904
- var endIdx = content.indexOf("---", 3);
4905
- if (endIdx === -1) return "";
4906
- var fm = content.substring(3, endIdx);
4907
- var m = fm.match(/version:\s*"?([^"\n]+)"?/m);
4908
- return m ? m[1].trim() : "";
4909
- }
4910
-
4911
- function getInstalledVersion(name) {
4912
- var dirs = [path.join(globalSkillsDir, name, "SKILL.md"), path.join(projectSkillsDir, name, "SKILL.md")];
4913
- for (var d = 0; d < dirs.length; d++) {
4914
- try {
4915
- var c = fs.readFileSync(dirs[d], "utf8");
4916
- var v = parseVersionFromSkillMd(c);
4917
- if (v) return v;
4918
- } catch (e) {}
4919
- }
4920
- return "";
4921
- }
4922
-
4923
- function compareVersions(a, b) {
4924
- // returns -1 if a < b, 0 if equal, 1 if a > b
4925
- if (!a && !b) return 0;
4926
- if (!a) return -1;
4927
- if (!b) return 1;
4928
- var pa = a.split(".").map(Number);
4929
- var pb = b.split(".").map(Number);
4930
- for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
4931
- var va = pa[i] || 0;
4932
- var vb = pb[i] || 0;
4933
- if (va < vb) return -1;
4934
- if (va > vb) return 1;
4935
- }
4936
- return 0;
4937
- }
4938
-
4939
- function finishOne() {
4940
- pending--;
4941
- if (pending === 0) {
4942
- res.writeHead(200, { "Content-Type": "application/json" });
4943
- res.end(JSON.stringify({ results: results }));
4944
- }
4945
- }
4946
-
4947
- for (var si = 0; si < skills.length; si++) {
4948
- (function (skill) {
4949
- var installedVer = getInstalledVersion(skill.name);
4950
- var installed = !!installedVer;
4951
- console.log("[skill-check] " + skill.name + " installed=" + installed + " localVersion=" + (installedVer || "none"));
4952
- // Convert GitHub repo URL to raw SKILL.md URL
4953
- var rawUrl = "";
4954
- var ghMatch = skill.url.match(/github\.com\/([^/]+)\/([^/]+)/);
4955
- if (ghMatch) {
4956
- rawUrl = "https://raw.githubusercontent.com/" + ghMatch[1] + "/" + ghMatch[2] + "/main/SKILL.md";
4957
- }
4958
- if (!rawUrl) {
4959
- console.log("[skill-check] " + skill.name + " no valid GitHub URL, skipping remote check");
4960
- results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "missing" });
4961
- finishOne();
4962
- return;
4963
- }
4964
- console.log("[skill-check] " + skill.name + " fetching remote: " + rawUrl);
4965
- // Fetch remote SKILL.md
4966
- var https = require("https");
4967
- https.get(rawUrl, function (resp) {
4968
- console.log("[skill-check] " + skill.name + " remote response status=" + resp.statusCode);
4969
- var data = "";
4970
- resp.on("data", function (chunk) { data += chunk; });
4971
- resp.on("end", function () {
4972
- try {
4973
- var remoteVer = parseVersionFromSkillMd(data);
4974
- var status = "ok";
4975
- if (!installed) {
4976
- status = "missing";
4977
- } else if (remoteVer && compareVersions(installedVer, remoteVer) < 0) {
4978
- status = "outdated";
4979
- }
4980
- console.log("[skill-check] " + skill.name + " remoteVersion=" + remoteVer + " status=" + status);
4981
- results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: remoteVer, status: status });
4982
- finishOne();
4983
- } catch (e) {
4984
- console.error("[skill-check] " + skill.name + " version parse failed:", e.message || e);
4985
- results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "error" });
4986
- finishOne();
4987
- }
4988
- });
4989
- }).on("error", function (err) {
4990
- console.error("[skill-check] " + skill.name + " fetch error:", err.message || err);
4991
- results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "missing" });
4992
- finishOne();
4993
- });
4994
- })(skills[si]);
4995
- }
4996
- }).catch(function () {
4997
- res.writeHead(400);
4998
- res.end("Bad request");
4999
- });
5000
- return true;
5001
- }
821
+ // --- Sessions/config/project handler (delegated to project-sessions.js) ---
822
+ var _sessions = attachSessions({
823
+ cwd: cwd,
824
+ slug: slug,
825
+ isMate: isMate,
826
+ osUsers: osUsers,
827
+ debug: debug,
828
+ dangerouslySkipPermissions: dangerouslySkipPermissions,
829
+ currentVersion: currentVersion,
830
+ sm: sm,
831
+ sdk: sdk,
832
+ tm: tm,
833
+ clients: clients,
834
+ send: send,
835
+ sendTo: sendTo,
836
+ sendToAdmins: sendToAdmins,
837
+ sendToSession: sendToSession,
838
+ sendToSessionOthers: sendToSessionOthers,
839
+ opts: opts,
840
+ usersModule: usersModule,
841
+ userPresence: userPresence,
842
+ matesModule: matesModule,
843
+ pushModule: pushModule,
844
+ getSessionForWs: getSessionForWs,
845
+ getLinuxUserForSession: getLinuxUserForSession,
846
+ getOsUserInfoForWs: getOsUserInfoForWs,
847
+ hydrateImageRefs: hydrateImageRefs,
848
+ onProcessingChanged: onProcessingChanged,
849
+ broadcastPresence: broadcastPresence,
850
+ getSDK: getSDK,
851
+ getProjectList: getProjectList,
852
+ getProjectCount: getProjectCount,
853
+ getScheduleCount: getScheduleCount,
854
+ moveScheduleToProject: moveScheduleToProject,
855
+ moveAllSchedulesToProject: moveAllSchedulesToProject,
856
+ getHubSchedules: getHubSchedules,
857
+ fetchVersion: fetchVersion,
858
+ isNewer: isNewer,
859
+ scheduleMessage: scheduleMessage,
860
+ cancelScheduledMessage: cancelScheduledMessage,
861
+ getProjectOwnerId: function () { return projectOwnerId; },
862
+ setProjectOwnerId: function (id) { projectOwnerId = id; },
863
+ getUpdateChannel: function () { return updateChannel; },
864
+ setUpdateChannel: function (ch) { updateChannel = ch; },
865
+ getLatestVersion: function () { return latestVersion; },
866
+ setLatestVersion: function (v) { latestVersion = v; },
867
+ });
5002
868
 
5003
- // Git dirty check
5004
- if (req.method === "GET" && urlPath === "/api/git-dirty") {
5005
- var execSync = require("child_process").execSync;
5006
- try {
5007
- var out = execSync("git status --porcelain", { cwd: cwd, encoding: "utf8", timeout: 5000 });
5008
- var dirty = out.trim().split("\n").some(function (line) {
5009
- return line.trim().length > 0 && !line.startsWith("??");
5010
- });
5011
- res.writeHead(200, { "Content-Type": "application/json" });
5012
- res.end(JSON.stringify({ dirty: dirty }));
5013
- } catch (e) {
5014
- res.writeHead(200, { "Content-Type": "application/json" });
5015
- res.end(JSON.stringify({ dirty: false }));
5016
- }
5017
- return true;
5018
- }
869
+ // --- User message handler (delegated to project-user-message.js) ---
870
+ var _userMessage = attachUserMessage({
871
+ cwd: cwd,
872
+ slug: slug,
873
+ isMate: isMate,
874
+ osUsers: osUsers,
875
+ sm: sm,
876
+ sdk: sdk,
877
+ nm: nm,
878
+ tm: tm,
879
+ clients: clients,
880
+ send: send,
881
+ sendTo: sendTo,
882
+ sendToSession: sendToSession,
883
+ sendToSessionOthers: sendToSessionOthers,
884
+ opts: opts,
885
+ usersModule: usersModule,
886
+ matesModule: matesModule,
887
+ getSessionForWs: getSessionForWs,
888
+ getLinuxUserForSession: getLinuxUserForSession,
889
+ getOsUserInfoForWs: getOsUserInfoForWs,
890
+ hydrateImageRefs: hydrateImageRefs,
891
+ saveImageFile: saveImageFile,
892
+ imagesDir: imagesDir,
893
+ onProcessingChanged: onProcessingChanged,
894
+ _loop: _loop,
895
+ browserState: { _browserTabList: _browserTabList, _extensionWs: _extensionWs, pendingExtensionRequests: pendingExtensionRequests },
896
+ sendExtensionCommandAny: sendExtensionCommandAny,
897
+ scheduleMessage: scheduleMessage,
898
+ cancelScheduledMessage: cancelScheduledMessage,
899
+ loadContextSources: loadContextSources,
900
+ saveContextSources: saveContextSources,
901
+ digestDmTurn: digestDmTurn,
902
+ gateMemory: gateMemory,
903
+ escapeRegex: escapeRegex,
904
+ getSDK: getSDK,
905
+ getHubSchedules: getHubSchedules,
906
+ getProjectOwnerId: function () { return projectOwnerId; },
907
+ });
5019
908
 
5020
- // List branches for worktree modal
5021
- if (req.method === "GET" && urlPath === "/api/branches") {
5022
- try {
5023
- var brRaw = execFileSync("git", ["branch", "-a", "--format=%(refname:short)"], {
5024
- cwd: cwd, timeout: 5000, encoding: "utf8"
5025
- });
5026
- var brList = brRaw.trim().split("\n").filter(Boolean);
5027
- var defBr = "main";
5028
- try {
5029
- var hrRef = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], {
5030
- cwd: cwd, timeout: 3000, encoding: "utf8"
5031
- }).trim();
5032
- defBr = hrRef.replace(/^origin\//, "");
5033
- } catch (e) {}
5034
- res.writeHead(200, { "Content-Type": "application/json" });
5035
- res.end(JSON.stringify({ branches: brList, defaultBranch: defBr }));
5036
- } catch (e) {
5037
- res.writeHead(200, { "Content-Type": "application/json" });
5038
- res.end(JSON.stringify({ branches: ["main"], defaultBranch: "main" }));
5039
- }
5040
- return true;
5041
- }
909
+ // --- Filesystem handler (delegated to project-filesystem.js) ---
910
+ var _filesystem = attachFilesystem({
911
+ cwd: cwd,
912
+ slug: slug,
913
+ osUsers: osUsers,
914
+ sm: sm,
915
+ send: send,
916
+ sendTo: sendTo,
917
+ safePath: safePath,
918
+ safeAbsPath: safeAbsPath,
919
+ getOsUserInfoForWs: getOsUserInfoForWs,
920
+ startFileWatch: startFileWatch,
921
+ stopFileWatch: stopFileWatch,
922
+ startDirWatch: startDirWatch,
923
+ usersModule: usersModule,
924
+ fsAsUser: fsAsUser,
925
+ validateEnvString: validateEnvString,
926
+ opts: opts,
927
+ IGNORED_DIRS: IGNORED_DIRS,
928
+ BINARY_EXTS: BINARY_EXTS,
929
+ IMAGE_EXTS: IMAGE_EXTS,
930
+ FS_MAX_SIZE: FS_MAX_SIZE,
931
+ });
5042
932
 
5043
- // Info endpoint
5044
- if (req.method === "GET" && urlPath === "/info") {
5045
- res.writeHead(200, {
5046
- "Content-Type": "application/json",
5047
- "Access-Control-Allow-Origin": "*",
5048
- });
5049
- res.end(JSON.stringify({ cwd: cwd, project: project, slug: slug }));
5050
- return true;
5051
- }
933
+ // --- HTTP handler (delegated to project-http.js) ---
934
+ var _http = attachHTTP({
935
+ cwd: cwd,
936
+ slug: slug,
937
+ project: title || project,
938
+ sm: sm,
939
+ send: send,
940
+ imagesDir: imagesDir,
941
+ osUsers: osUsers,
942
+ pushModule: pushModule,
943
+ safePath: safePath,
944
+ safeAbsPath: safeAbsPath,
945
+ getOsUserInfoForReq: getOsUserInfoForReq,
946
+ sendExtensionCommandAny: sendExtensionCommandAny,
947
+ _extToken: _extToken,
948
+ _browserTabList: _browserTabList,
949
+ });
950
+ var handleHTTP = _http.handleHTTP;
5052
951
 
5053
- return false; // not handled
5054
- }
952
+ // --- Connection handler (delegated to project-connection.js) ---
953
+ var _connection = attachConnection({
954
+ cwd: cwd,
955
+ slug: slug,
956
+ isMate: isMate,
957
+ osUsers: osUsers,
958
+ debug: debug,
959
+ dangerouslySkipPermissions: dangerouslySkipPermissions,
960
+ currentVersion: currentVersion,
961
+ lanHost: lanHost,
962
+ sm: sm,
963
+ tm: tm,
964
+ nm: nm,
965
+ clients: clients,
966
+ send: send,
967
+ sendTo: sendTo,
968
+ opts: opts,
969
+ _loop: _loop,
970
+ hydrateImageRefs: hydrateImageRefs,
971
+ broadcastClientCount: broadcastClientCount,
972
+ broadcastPresence: broadcastPresence,
973
+ getProjectList: getProjectList,
974
+ getHubSchedules: getHubSchedules,
975
+ loadContextSources: loadContextSources,
976
+ restoreDebateState: restoreDebateState,
977
+ stopFileWatch: stopFileWatch,
978
+ stopAllDirWatches: stopAllDirWatches,
979
+ getProjectOwnerId: function () { return projectOwnerId; },
980
+ setProjectOwnerId: function (id) { projectOwnerId = id; },
981
+ getLatestVersion: function () { return latestVersion; },
982
+ getTitle: function () { return title; },
983
+ getProject: function () { return project; },
984
+ });
5055
985
 
5056
986
  // --- Destroy ---
5057
987
  function destroy() {
5058
- loopRegistry.stopTimer();
988
+ _loop.stopTimer();
5059
989
  stopFileWatch();
5060
990
  stopAllDirWatches();
5061
991
  // Abort all active sessions and clean up mention sessions
@@ -5232,9 +1162,9 @@ function createProjectContext(opts) {
5232
1162
  handleHTTP: handleHTTP,
5233
1163
  getStatus: getStatus,
5234
1164
  getSessionManager: function () { return sm; },
5235
- getSchedules: function () { return loopRegistry.getAll(); },
5236
- importSchedule: function (data) { return loopRegistry.register(data); },
5237
- removeSchedule: function (id) { return loopRegistry.remove(id); },
1165
+ getSchedules: _loop.getSchedules,
1166
+ importSchedule: _loop.importSchedule,
1167
+ removeSchedule: _loop.removeSchedule,
5238
1168
  setTitle: setTitle,
5239
1169
  setIcon: setIcon,
5240
1170
  setProjectOwner: function (ownerId) { projectOwnerId = ownerId; },
@@ -5259,15 +1189,4 @@ function createProjectContext(opts) {
5259
1189
  };
5260
1190
  }
5261
1191
 
5262
- function parseJsonBody(req) {
5263
- return new Promise(function (resolve, reject) {
5264
- var body = "";
5265
- req.on("data", function (chunk) { body += chunk; });
5266
- req.on("end", function () {
5267
- try { resolve(JSON.parse(body)); }
5268
- catch (e) { reject(e); }
5269
- });
5270
- });
5271
- }
5272
-
5273
1192
  module.exports = { createProjectContext: createProjectContext, safePath: safePath, validateEnvString: validateEnvString };