clay-server 2.6.0 → 2.7.1

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.
Files changed (38) hide show
  1. package/bin/cli.js +53 -4
  2. package/lib/config.js +15 -6
  3. package/lib/daemon.js +47 -5
  4. package/lib/ipc.js +12 -0
  5. package/lib/notes.js +2 -2
  6. package/lib/project.js +883 -2
  7. package/lib/public/app.js +862 -14
  8. package/lib/public/css/diff.css +12 -0
  9. package/lib/public/css/filebrowser.css +1 -1
  10. package/lib/public/css/loop.css +841 -0
  11. package/lib/public/css/menus.css +5 -0
  12. package/lib/public/css/mobile-nav.css +15 -15
  13. package/lib/public/css/rewind.css +23 -0
  14. package/lib/public/css/scheduler-modal.css +546 -0
  15. package/lib/public/css/scheduler.css +944 -0
  16. package/lib/public/css/sidebar.css +1 -0
  17. package/lib/public/css/skills.css +59 -0
  18. package/lib/public/css/sticky-notes.css +486 -0
  19. package/lib/public/css/title-bar.css +83 -3
  20. package/lib/public/index.html +181 -3
  21. package/lib/public/modules/diff.js +3 -3
  22. package/lib/public/modules/filebrowser.js +169 -45
  23. package/lib/public/modules/input.js +17 -3
  24. package/lib/public/modules/markdown.js +10 -0
  25. package/lib/public/modules/qrcode.js +23 -26
  26. package/lib/public/modules/scheduler.js +1240 -0
  27. package/lib/public/modules/server-settings.js +40 -0
  28. package/lib/public/modules/sidebar.js +12 -0
  29. package/lib/public/modules/skills.js +84 -0
  30. package/lib/public/modules/sticky-notes.js +617 -52
  31. package/lib/public/modules/theme.js +9 -19
  32. package/lib/public/modules/tools.js +16 -2
  33. package/lib/public/style.css +3 -0
  34. package/lib/scheduler.js +362 -0
  35. package/lib/sdk-bridge.js +36 -0
  36. package/lib/sessions.js +9 -5
  37. package/lib/utils.js +49 -3
  38. package/package.json +1 -1
package/lib/project.js CHANGED
@@ -8,6 +8,7 @@ var { createTerminalManager } = require("./terminal-manager");
8
8
  var { createNotesManager } = require("./notes");
9
9
  var { fetchLatestVersion, isNewer } = require("./updater");
10
10
  var { execFileSync, spawn } = require("child_process");
11
+ var { createLoopRegistry } = require("./scheduler");
11
12
 
12
13
  var MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
13
14
 
@@ -222,6 +223,587 @@ function createProjectContext(opts) {
222
223
  onProcessingChanged: onProcessingChanged,
223
224
  });
224
225
 
226
+ // --- Ralph Loop state ---
227
+ var loopState = {
228
+ active: false,
229
+ phase: "idle", // idle | crafting | approval | executing | done
230
+ promptText: "",
231
+ judgeText: "",
232
+ iteration: 0,
233
+ maxIterations: 20,
234
+ baseCommit: null,
235
+ currentSessionId: null,
236
+ judgeSessionId: null,
237
+ results: [],
238
+ stopping: false,
239
+ wizardData: null,
240
+ craftingSessionId: null,
241
+ startedAt: null,
242
+ loopId: null,
243
+ };
244
+
245
+ function loopDir() {
246
+ if (!loopState.loopId) return null;
247
+ return path.join(cwd, ".claude", "loops", loopState.loopId);
248
+ }
249
+
250
+ function generateLoopId() {
251
+ return "loop_" + Date.now() + "_" + crypto.randomBytes(3).toString("hex");
252
+ }
253
+
254
+ // Loop state persistence
255
+ var _loopConfig = require("./config");
256
+ var _loopUtils = require("./utils");
257
+ var _loopDir = path.join(_loopConfig.CONFIG_DIR, "loops");
258
+ var _loopEncodedCwd = _loopUtils.resolveEncodedFile(_loopDir, cwd, ".json");
259
+ var _loopStatePath = path.join(_loopDir, _loopEncodedCwd + ".json");
260
+
261
+ function saveLoopState() {
262
+ try {
263
+ fs.mkdirSync(_loopDir, { recursive: true });
264
+ var data = {
265
+ phase: loopState.phase,
266
+ active: loopState.active,
267
+ iteration: loopState.iteration,
268
+ maxIterations: loopState.maxIterations,
269
+ baseCommit: loopState.baseCommit,
270
+ results: loopState.results,
271
+ wizardData: loopState.wizardData,
272
+ startedAt: loopState.startedAt,
273
+ loopId: loopState.loopId,
274
+ };
275
+ var tmpPath = _loopStatePath + ".tmp";
276
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
277
+ fs.renameSync(tmpPath, _loopStatePath);
278
+ } catch (e) {
279
+ console.error("[ralph-loop] Failed to save state:", e.message);
280
+ }
281
+ }
282
+
283
+ function loadLoopState() {
284
+ try {
285
+ var raw = fs.readFileSync(_loopStatePath, "utf8");
286
+ var data = JSON.parse(raw);
287
+ loopState.phase = data.phase || "idle";
288
+ loopState.active = data.active || false;
289
+ loopState.iteration = data.iteration || 0;
290
+ loopState.maxIterations = data.maxIterations || 20;
291
+ loopState.baseCommit = data.baseCommit || null;
292
+ loopState.results = data.results || [];
293
+ loopState.wizardData = data.wizardData || null;
294
+ loopState.startedAt = data.startedAt || null;
295
+ loopState.loopId = data.loopId || null;
296
+ // SDK sessions cannot survive daemon restart
297
+ loopState.currentSessionId = null;
298
+ loopState.judgeSessionId = null;
299
+ loopState.craftingSessionId = null;
300
+ loopState.stopping = false;
301
+ // If was executing, schedule resume after SDK is ready
302
+ if (loopState.phase === "executing" && loopState.active) {
303
+ loopState._needsResume = true;
304
+ }
305
+ // If was crafting, check if files exist and move to approval
306
+ if (loopState.phase === "crafting") {
307
+ var hasFiles = checkLoopFilesExist();
308
+ if (hasFiles) {
309
+ loopState.phase = "approval";
310
+ saveLoopState();
311
+ } else {
312
+ loopState.phase = "idle";
313
+ saveLoopState();
314
+ }
315
+ }
316
+ } catch (e) {
317
+ // No saved state, use defaults
318
+ }
319
+ }
320
+
321
+ function clearLoopState() {
322
+ loopState.active = false;
323
+ loopState.phase = "idle";
324
+ loopState.promptText = "";
325
+ loopState.judgeText = "";
326
+ loopState.iteration = 0;
327
+ loopState.maxIterations = 20;
328
+ loopState.baseCommit = null;
329
+ loopState.currentSessionId = null;
330
+ loopState.judgeSessionId = null;
331
+ loopState.results = [];
332
+ loopState.stopping = false;
333
+ loopState.wizardData = null;
334
+ loopState.craftingSessionId = null;
335
+ loopState.startedAt = null;
336
+ loopState.loopId = null;
337
+ saveLoopState();
338
+ }
339
+
340
+ function checkLoopFilesExist() {
341
+ var dir = loopDir();
342
+ if (!dir) return false;
343
+ var hasPrompt = false;
344
+ var hasJudge = false;
345
+ try { fs.accessSync(path.join(dir, "PROMPT.md")); hasPrompt = true; } catch (e) {}
346
+ try { fs.accessSync(path.join(dir, "JUDGE.md")); hasJudge = true; } catch (e) {}
347
+ return hasPrompt && hasJudge;
348
+ }
349
+
350
+ // .claude/ directory watcher for PROMPT.md / JUDGE.md
351
+ var claudeDirWatcher = null;
352
+ var claudeDirDebounce = null;
353
+
354
+ function startClaudeDirWatch() {
355
+ if (claudeDirWatcher) return;
356
+ var watchDir = loopDir();
357
+ if (!watchDir) return;
358
+ try { fs.mkdirSync(watchDir, { recursive: true }); } catch (e) {}
359
+ try {
360
+ claudeDirWatcher = fs.watch(watchDir, function () {
361
+ if (claudeDirDebounce) clearTimeout(claudeDirDebounce);
362
+ claudeDirDebounce = setTimeout(function () {
363
+ broadcastLoopFilesStatus();
364
+ }, 300);
365
+ });
366
+ claudeDirWatcher.on("error", function () {});
367
+ } catch (e) {
368
+ console.error("[ralph-loop] Failed to watch .claude/:", e.message);
369
+ }
370
+ }
371
+
372
+ function stopClaudeDirWatch() {
373
+ if (claudeDirWatcher) {
374
+ claudeDirWatcher.close();
375
+ claudeDirWatcher = null;
376
+ }
377
+ if (claudeDirDebounce) {
378
+ clearTimeout(claudeDirDebounce);
379
+ claudeDirDebounce = null;
380
+ }
381
+ }
382
+
383
+ function broadcastLoopFilesStatus() {
384
+ var dir = loopDir();
385
+ var hasPrompt = false;
386
+ var hasJudge = false;
387
+ var hasLoopJson = false;
388
+ if (dir) {
389
+ try { fs.accessSync(path.join(dir, "PROMPT.md")); hasPrompt = true; } catch (e) {}
390
+ try { fs.accessSync(path.join(dir, "JUDGE.md")); hasJudge = true; } catch (e) {}
391
+ try { fs.accessSync(path.join(dir, "LOOP.json")); hasLoopJson = true; } catch (e) {}
392
+ }
393
+ send({
394
+ type: "ralph_files_status",
395
+ promptReady: hasPrompt,
396
+ judgeReady: hasJudge,
397
+ loopJsonReady: hasLoopJson,
398
+ bothReady: hasPrompt && hasJudge,
399
+ taskId: loopState.loopId,
400
+ });
401
+ // Auto-transition to approval phase when both files appear
402
+ if (hasPrompt && hasJudge && loopState.phase === "crafting") {
403
+ loopState.phase = "approval";
404
+ saveLoopState();
405
+
406
+ // Parse recommended title from crafting session conversation
407
+ if (loopState.craftingSessionId && loopState.loopId) {
408
+ var craftSess = sm.sessions.get(loopState.craftingSessionId);
409
+ if (craftSess && craftSess.history) {
410
+ for (var hi = craftSess.history.length - 1; hi >= 0; hi--) {
411
+ var entry = craftSess.history[hi];
412
+ var entryText = entry.text || "";
413
+ var titleMatch = entryText.match(/\[\[LOOP_TITLE:\s*(.+?)\]\]/);
414
+ if (titleMatch) {
415
+ var suggestedTitle = titleMatch[1].trim();
416
+ if (suggestedTitle) {
417
+ loopRegistry.updateRecord(loopState.loopId, { name: suggestedTitle });
418
+ }
419
+ break;
420
+ }
421
+ }
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ // Load persisted state on startup
428
+ loadLoopState();
429
+
430
+ // --- Loop Registry (unified one-off + scheduled) ---
431
+ var activeRegistryId = null; // track which registry record triggered current loop
432
+
433
+ var loopRegistry = createLoopRegistry({
434
+ cwd: cwd,
435
+ onTrigger: function (record) {
436
+ // Only trigger if no loop is currently active
437
+ if (loopState.active || loopState.phase === "executing") {
438
+ console.log("[loop-registry] Skipping trigger — loop already active");
439
+ return;
440
+ }
441
+ // Verify the loop directory and files exist
442
+ var recDir = path.join(cwd, ".claude", "loops", record.id);
443
+ try {
444
+ fs.accessSync(path.join(recDir, "PROMPT.md"));
445
+ fs.accessSync(path.join(recDir, "JUDGE.md"));
446
+ } catch (e) {
447
+ console.error("[loop-registry] Loop files missing for " + record.id);
448
+ return;
449
+ }
450
+ // Set the loopId and start
451
+ loopState.loopId = record.id;
452
+ activeRegistryId = record.id;
453
+ console.log("[loop-registry] Auto-starting loop: " + record.name);
454
+ send({ type: "schedule_run_started", recordId: record.id });
455
+ startLoop();
456
+ },
457
+ onChange: function (records) {
458
+ send({ type: "loop_registry_updated", records: records });
459
+ },
460
+ });
461
+ loopRegistry.load();
462
+ loopRegistry.startTimer();
463
+
464
+ function startLoop(opts) {
465
+ var loopOpts = opts || {};
466
+ var dir = loopDir();
467
+ if (!dir) {
468
+ send({ type: "loop_error", text: "No loop directory. Run the wizard first." });
469
+ return;
470
+ }
471
+ var promptPath = path.join(dir, "PROMPT.md");
472
+ var judgePath = path.join(dir, "JUDGE.md");
473
+ var promptText, judgeText;
474
+ try {
475
+ promptText = fs.readFileSync(promptPath, "utf8");
476
+ } catch (e) {
477
+ send({ type: "loop_error", text: "Missing PROMPT.md in " + dir });
478
+ return;
479
+ }
480
+ try {
481
+ judgeText = fs.readFileSync(judgePath, "utf8");
482
+ } catch (e) {
483
+ send({ type: "loop_error", text: "Missing JUDGE.md in " + dir });
484
+ return;
485
+ }
486
+
487
+ var baseCommit;
488
+ try {
489
+ baseCommit = execFileSync("git", ["rev-parse", "HEAD"], {
490
+ cwd: cwd, encoding: "utf8", timeout: 5000,
491
+ }).trim();
492
+ } catch (e) {
493
+ send({ type: "loop_error", text: "Failed to get git HEAD: " + e.message });
494
+ return;
495
+ }
496
+
497
+ // Read loop config from LOOP.json in loop directory
498
+ var loopConfig = {};
499
+ try {
500
+ loopConfig = JSON.parse(fs.readFileSync(path.join(dir, "LOOP.json"), "utf8"));
501
+ } catch (e) {}
502
+
503
+ loopState.active = true;
504
+ loopState.phase = "executing";
505
+ loopState.promptText = promptText;
506
+ loopState.judgeText = judgeText;
507
+ loopState.iteration = 0;
508
+ loopState.maxIterations = loopConfig.maxIterations || loopOpts.maxIterations || 20;
509
+ loopState.baseCommit = baseCommit;
510
+ loopState.currentSessionId = null;
511
+ loopState.judgeSessionId = null;
512
+ loopState.results = [];
513
+ loopState.stopping = false;
514
+ loopState.startedAt = Date.now();
515
+ saveLoopState();
516
+
517
+ stopClaudeDirWatch();
518
+
519
+ send({ type: "loop_started", maxIterations: loopState.maxIterations });
520
+ runNextIteration();
521
+ }
522
+
523
+ function runNextIteration() {
524
+ console.log("[ralph-loop] runNextIteration called, iteration: " + loopState.iteration + ", active: " + loopState.active + ", stopping: " + loopState.stopping);
525
+ if (!loopState.active || loopState.stopping) {
526
+ finishLoop("stopped");
527
+ return;
528
+ }
529
+
530
+ loopState.iteration++;
531
+ if (loopState.iteration > loopState.maxIterations) {
532
+ finishLoop("max_iterations");
533
+ return;
534
+ }
535
+
536
+ var session = sm.createSession();
537
+ session.loop = { active: true, iteration: loopState.iteration, role: "coder" };
538
+ var loopName = (loopState.wizardData && loopState.wizardData.name) || "";
539
+ session.title = "Ralph" + (loopName ? " " + loopName : "") + " #" + loopState.iteration;
540
+ sm.saveSessionFile(session);
541
+ sm.broadcastSessionList();
542
+
543
+ loopState.currentSessionId = session.localId;
544
+
545
+ send({
546
+ type: "loop_iteration",
547
+ iteration: loopState.iteration,
548
+ maxIterations: loopState.maxIterations,
549
+ sessionId: session.localId,
550
+ });
551
+
552
+ session.onQueryComplete = function(completedSession) {
553
+ console.log("[ralph-loop] Coder #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
554
+ if (!loopState.active) { console.log("[ralph-loop] Coder: loopState.active is false, skipping"); return; }
555
+ // Check if session ended with error
556
+ var lastItems = completedSession.history.slice(-3);
557
+ var hadError = false;
558
+ for (var i = 0; i < lastItems.length; i++) {
559
+ if (lastItems[i].type === "error" || (lastItems[i].type === "done" && lastItems[i].code === 1)) {
560
+ hadError = true;
561
+ break;
562
+ }
563
+ }
564
+ if (hadError) {
565
+ loopState.results.push({
566
+ iteration: loopState.iteration,
567
+ verdict: "error",
568
+ summary: "Iteration ended with error",
569
+ });
570
+ send({
571
+ type: "loop_verdict",
572
+ iteration: loopState.iteration,
573
+ verdict: "error",
574
+ summary: "Iteration ended with error, retrying...",
575
+ });
576
+ setTimeout(function() { runNextIteration(); }, 2000);
577
+ return;
578
+ }
579
+ runJudge();
580
+ };
581
+
582
+ var userMsg = { type: "user_message", text: loopState.promptText };
583
+ session.history.push(userMsg);
584
+ sm.appendToSessionFile(session, userMsg);
585
+
586
+ session.isProcessing = true;
587
+ onProcessingChanged();
588
+ session.sentToolResults = {};
589
+ send({ type: "status", status: "processing" });
590
+ session.acceptEditsAfterStart = true;
591
+ session.singleTurn = true;
592
+ sdk.startQuery(session, loopState.promptText);
593
+ }
594
+
595
+ function runJudge() {
596
+ if (!loopState.active || loopState.stopping) {
597
+ finishLoop("stopped");
598
+ return;
599
+ }
600
+
601
+ var diff;
602
+ try {
603
+ diff = execFileSync("git", ["diff", loopState.baseCommit], {
604
+ cwd: cwd, encoding: "utf8", timeout: 30000,
605
+ maxBuffer: 10 * 1024 * 1024,
606
+ });
607
+ } catch (e) {
608
+ send({ type: "loop_error", text: "Failed to generate git diff: " + e.message });
609
+ finishLoop("error");
610
+ return;
611
+ }
612
+
613
+ var judgePrompt = "You are a judge evaluating whether a coding task has been completed.\n\n" +
614
+ "## Original Task (PROMPT.md)\n\n" + loopState.promptText + "\n\n" +
615
+ "## Evaluation Criteria (JUDGE.md)\n\n" + loopState.judgeText + "\n\n" +
616
+ "## Changes Made (git diff)\n\n```diff\n" + diff + "\n```\n\n" +
617
+ "Based on the evaluation criteria, has the task been completed successfully?\n\n" +
618
+ "Respond with exactly one of:\n" +
619
+ "- PASS: [brief explanation]\n" +
620
+ "- FAIL: [brief explanation of what is still missing]\n\n" +
621
+ "Do NOT use any tools. Just analyze and respond.";
622
+
623
+ var judgeSession = sm.createSession();
624
+ judgeSession.loop = { active: true, iteration: loopState.iteration, role: "judge" };
625
+ var judgeName = (loopState.wizardData && loopState.wizardData.name) || "";
626
+ judgeSession.title = "Ralph" + (judgeName ? " " + judgeName : "") + " Judge #" + loopState.iteration;
627
+ sm.saveSessionFile(judgeSession);
628
+ sm.broadcastSessionList();
629
+ loopState.judgeSessionId = judgeSession.localId;
630
+
631
+ send({
632
+ type: "loop_judging",
633
+ iteration: loopState.iteration,
634
+ sessionId: judgeSession.localId,
635
+ });
636
+
637
+ judgeSession.onQueryComplete = function(completedSession) {
638
+ console.log("[ralph-loop] Judge #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
639
+ var verdict = parseJudgeVerdict(completedSession);
640
+ console.log("[ralph-loop] Judge verdict: " + (verdict.pass ? "PASS" : "FAIL") + " - " + verdict.explanation);
641
+
642
+ loopState.results.push({
643
+ iteration: loopState.iteration,
644
+ verdict: verdict.pass ? "pass" : "fail",
645
+ summary: verdict.explanation,
646
+ });
647
+
648
+ send({
649
+ type: "loop_verdict",
650
+ iteration: loopState.iteration,
651
+ verdict: verdict.pass ? "pass" : "fail",
652
+ summary: verdict.explanation,
653
+ });
654
+
655
+ if (verdict.pass) {
656
+ finishLoop("pass");
657
+ } else {
658
+ setTimeout(function() { runNextIteration(); }, 1000);
659
+ }
660
+ };
661
+
662
+ var userMsg = { type: "user_message", text: judgePrompt };
663
+ judgeSession.history.push(userMsg);
664
+ sm.appendToSessionFile(judgeSession, userMsg);
665
+
666
+ judgeSession.isProcessing = true;
667
+ onProcessingChanged();
668
+ judgeSession.sentToolResults = {};
669
+ judgeSession.acceptEditsAfterStart = true;
670
+ judgeSession.singleTurn = true;
671
+ sdk.startQuery(judgeSession, judgePrompt);
672
+ }
673
+
674
+ function parseJudgeVerdict(session) {
675
+ var text = "";
676
+ for (var i = 0; i < session.history.length; i++) {
677
+ var h = session.history[i];
678
+ if (h.type === "delta" && h.text) text += h.text;
679
+ if (h.type === "text" && h.text) text += h.text;
680
+ }
681
+ console.log("[ralph-loop] Judge raw text (last 500 chars): " + text.slice(-500));
682
+ var upper = text.toUpperCase();
683
+ var passIdx = upper.indexOf("PASS");
684
+ var failIdx = upper.indexOf("FAIL");
685
+ if (passIdx !== -1 && (failIdx === -1 || passIdx < failIdx)) {
686
+ var explanation = text.substring(passIdx + 4).replace(/^[\s:]+/, "").split("\n")[0].trim();
687
+ return { pass: true, explanation: explanation || "Task completed" };
688
+ }
689
+ if (failIdx !== -1) {
690
+ var explanation = text.substring(failIdx + 4).replace(/^[\s:]+/, "").split("\n")[0].trim();
691
+ return { pass: false, explanation: explanation || "Task not yet complete" };
692
+ }
693
+ return { pass: false, explanation: "Could not parse judge verdict" };
694
+ }
695
+
696
+ function finishLoop(reason) {
697
+ console.log("[ralph-loop] finishLoop called, reason: " + reason + ", iteration: " + loopState.iteration);
698
+ loopState.active = false;
699
+ loopState.phase = "done";
700
+ loopState.stopping = false;
701
+ loopState.currentSessionId = null;
702
+ loopState.judgeSessionId = null;
703
+ saveLoopState();
704
+
705
+ send({
706
+ type: "loop_finished",
707
+ reason: reason,
708
+ iterations: loopState.iteration,
709
+ results: loopState.results,
710
+ });
711
+
712
+ // Record result in loop registry
713
+ if (loopState.loopId) {
714
+ loopRegistry.recordRun(loopState.loopId, {
715
+ reason: reason,
716
+ startedAt: loopState.startedAt,
717
+ iterations: loopState.iteration,
718
+ });
719
+ }
720
+ if (activeRegistryId) {
721
+ send({ type: "schedule_run_finished", recordId: activeRegistryId, reason: reason, iterations: loopState.iteration });
722
+ activeRegistryId = null;
723
+ }
724
+
725
+ if (pushModule) {
726
+ var body = reason === "pass"
727
+ ? "Task completed after " + loopState.iteration + " iteration(s)"
728
+ : reason === "max_iterations"
729
+ ? "Reached max iterations (" + loopState.maxIterations + ")"
730
+ : reason === "stopped"
731
+ ? "Loop stopped by user"
732
+ : "Loop ended due to error";
733
+ pushModule.sendPush({
734
+ type: "done",
735
+ slug: slug,
736
+ title: "Ralph Loop Complete",
737
+ body: body,
738
+ tag: "ralph-loop-done",
739
+ });
740
+ }
741
+ }
742
+
743
+ function resumeLoop() {
744
+ var dir = loopDir();
745
+ if (!dir) {
746
+ console.error("[ralph-loop] Cannot resume: no loop directory");
747
+ loopState.active = false;
748
+ loopState.phase = "idle";
749
+ saveLoopState();
750
+ return;
751
+ }
752
+ try {
753
+ loopState.promptText = fs.readFileSync(path.join(dir, "PROMPT.md"), "utf8");
754
+ } catch (e) {
755
+ console.error("[ralph-loop] Cannot resume: missing PROMPT.md");
756
+ loopState.active = false;
757
+ loopState.phase = "idle";
758
+ saveLoopState();
759
+ return;
760
+ }
761
+ try {
762
+ loopState.judgeText = fs.readFileSync(path.join(dir, "JUDGE.md"), "utf8");
763
+ } catch (e) {
764
+ console.error("[ralph-loop] Cannot resume: missing JUDGE.md");
765
+ loopState.active = false;
766
+ loopState.phase = "idle";
767
+ saveLoopState();
768
+ return;
769
+ }
770
+ // Retry the interrupted iteration (runNextIteration will increment)
771
+ if (loopState.iteration > 0) {
772
+ loopState.iteration--;
773
+ }
774
+ console.log("[ralph-loop] Resuming loop, next iteration will be " + (loopState.iteration + 1) + "/" + loopState.maxIterations);
775
+ send({ type: "loop_started", maxIterations: loopState.maxIterations });
776
+ runNextIteration();
777
+ }
778
+
779
+ function stopLoop() {
780
+ if (!loopState.active) return;
781
+ console.log("[ralph-loop] stopLoop called");
782
+ loopState.stopping = true;
783
+
784
+ // Abort all loop-related sessions (coder + judge)
785
+ var sessionIds = [loopState.currentSessionId, loopState.judgeSessionId];
786
+ for (var i = 0; i < sessionIds.length; i++) {
787
+ if (sessionIds[i] == null) continue;
788
+ var s = sm.sessions.get(sessionIds[i]);
789
+ if (!s) continue;
790
+ // End message queue so SDK exits prompt wait
791
+ if (s.messageQueue) { try { s.messageQueue.end(); } catch (e) {} }
792
+ // Abort active API call
793
+ if (s.abortController) { try { s.abortController.abort(); } catch (e) {} }
794
+ }
795
+
796
+ send({ type: "loop_stopping" });
797
+
798
+ // Fallback: force finish if onQueryComplete hasn't fired after 5s
799
+ setTimeout(function() {
800
+ if (loopState.active && loopState.stopping) {
801
+ console.log("[ralph-loop] Stop fallback triggered — forcing finishLoop");
802
+ finishLoop("stopped");
803
+ }
804
+ }, 5000);
805
+ }
806
+
225
807
  // --- Terminal manager ---
226
808
  var tm = createTerminalManager({ cwd: cwd, send: send, sendTo: sendTo });
227
809
  var nm = createNotesManager({ cwd: cwd, send: send, sendTo: sendTo });
@@ -239,6 +821,12 @@ function createProjectContext(opts) {
239
821
  clients.add(ws);
240
822
  broadcastClientCount();
241
823
 
824
+ // Resume loop if server restarted mid-execution (deferred so client gets initial state first)
825
+ if (loopState._needsResume) {
826
+ delete loopState._needsResume;
827
+ setTimeout(function() { resumeLoop(); }, 500);
828
+ }
829
+
242
830
  // Send cached state
243
831
  sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, dangerouslySkipPermissions: dangerouslySkipPermissions, lanHost: lanHost, projectCount: getProjectCount(), projects: getProjectList() });
244
832
  if (latestVersion) {
@@ -253,6 +841,46 @@ function createProjectContext(opts) {
253
841
  sendTo(ws, { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
254
842
  sendTo(ws, { type: "term_list", terminals: tm.list() });
255
843
  sendTo(ws, { type: "notes_list", notes: nm.list() });
844
+ sendTo(ws, { type: "loop_registry_updated", records: loopRegistry.getAll() });
845
+
846
+ // Ralph Loop availability
847
+ var hasLoopFiles = false;
848
+ try {
849
+ fs.accessSync(path.join(cwd, ".claude", "PROMPT.md"));
850
+ fs.accessSync(path.join(cwd, ".claude", "JUDGE.md"));
851
+ hasLoopFiles = true;
852
+ } catch (e) {}
853
+ sendTo(ws, {
854
+ type: "loop_available",
855
+ available: hasLoopFiles,
856
+ active: loopState.active,
857
+ iteration: loopState.iteration,
858
+ maxIterations: loopState.maxIterations,
859
+ });
860
+
861
+ // Ralph phase state
862
+ sendTo(ws, {
863
+ type: "ralph_phase",
864
+ phase: loopState.phase,
865
+ wizardData: loopState.wizardData,
866
+ craftingSessionId: loopState.craftingSessionId || null,
867
+ });
868
+ if (loopState.phase === "crafting" || loopState.phase === "approval") {
869
+ var _hasPrompt = false;
870
+ var _hasJudge = false;
871
+ var _lDir = loopDir();
872
+ if (_lDir) {
873
+ try { fs.accessSync(path.join(_lDir, "PROMPT.md")); _hasPrompt = true; } catch (e) {}
874
+ try { fs.accessSync(path.join(_lDir, "JUDGE.md")); _hasJudge = true; } catch (e) {}
875
+ }
876
+ sendTo(ws, {
877
+ type: "ralph_files_status",
878
+ promptReady: _hasPrompt,
879
+ judgeReady: _hasJudge,
880
+ bothReady: _hasPrompt && _hasJudge,
881
+ taskId: loopState.loopId,
882
+ });
883
+ }
256
884
 
257
885
  // Session list
258
886
  sendTo(ws, {
@@ -1019,6 +1647,20 @@ function createProjectContext(opts) {
1019
1647
  return;
1020
1648
  }
1021
1649
 
1650
+ if (msg.type === "restart_server") {
1651
+ if (typeof opts.onRestart === "function") {
1652
+ sendTo(ws, { type: "restart_server_result", ok: true });
1653
+ send({ type: "toast", level: "info", message: "Server is restarting..." });
1654
+ // Small delay so the response has time to reach clients
1655
+ setTimeout(function () {
1656
+ opts.onRestart();
1657
+ }, 500);
1658
+ } else {
1659
+ sendTo(ws, { type: "restart_server_result", ok: false, error: "Restart not supported" });
1660
+ }
1661
+ return;
1662
+ }
1663
+
1022
1664
  // --- File browser ---
1023
1665
  if (msg.type === "fs_list") {
1024
1666
  var fsDir = safePath(cwd, msg.path || ".");
@@ -1446,6 +2088,218 @@ function createProjectContext(opts) {
1446
2088
  return;
1447
2089
  }
1448
2090
 
2091
+ if (msg.type === "loop_start") {
2092
+ // If this loop has a cron schedule, don't run immediately — just confirm registration
2093
+ if (loopState.wizardData && loopState.wizardData.cron) {
2094
+ loopState.active = false;
2095
+ loopState.phase = "done";
2096
+ saveLoopState();
2097
+ send({ type: "loop_finished", reason: "scheduled", iterations: 0, results: [] });
2098
+ send({ type: "ralph_phase", phase: "idle", wizardData: null });
2099
+ send({ type: "loop_scheduled", recordId: loopState.loopId, cron: loopState.wizardData.cron });
2100
+ return;
2101
+ }
2102
+ startLoop();
2103
+ return;
2104
+ }
2105
+
2106
+ if (msg.type === "loop_stop") {
2107
+ stopLoop();
2108
+ return;
2109
+ }
2110
+
2111
+ if (msg.type === "ralph_wizard_complete") {
2112
+ var wData = msg.data || {};
2113
+ var maxIter = wData.maxIterations || 25;
2114
+ var wizardCron = wData.cron || null;
2115
+ var newLoopId = generateLoopId();
2116
+ loopState.loopId = newLoopId;
2117
+ loopState.wizardData = {
2118
+ name: wData.name || wData.task || "Untitled",
2119
+ task: wData.task || "",
2120
+ maxIterations: maxIter,
2121
+ cron: wizardCron,
2122
+ };
2123
+ loopState.phase = "crafting";
2124
+ loopState.startedAt = Date.now();
2125
+ saveLoopState();
2126
+
2127
+ // Register in loop registry
2128
+ loopRegistry.register({
2129
+ id: newLoopId,
2130
+ name: loopState.wizardData.name,
2131
+ task: wData.task || "",
2132
+ cron: wizardCron,
2133
+ enabled: wizardCron ? true : false,
2134
+ maxIterations: maxIter,
2135
+ });
2136
+
2137
+ // Create loop directory and write LOOP.json
2138
+ var lDir = loopDir();
2139
+ try { fs.mkdirSync(lDir, { recursive: true }); } catch (e) {}
2140
+ var loopJsonPath = path.join(lDir, "LOOP.json");
2141
+ var tmpLoopJson = loopJsonPath + ".tmp";
2142
+ fs.writeFileSync(tmpLoopJson, JSON.stringify({ maxIterations: maxIter }, null, 2));
2143
+ fs.renameSync(tmpLoopJson, loopJsonPath);
2144
+
2145
+ // Assemble prompt for clay-ralph skill (include loop dir path so skill knows where to write)
2146
+ var craftingPrompt = "Use the /clay-ralph skill to design a Ralph Loop for the following task. " +
2147
+ "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
2148
+ "Your job is to interview me, then create PROMPT.md and JUDGE.md files " +
2149
+ "that a future autonomous session will execute.\n\n" +
2150
+ "## Task\n" + (wData.task || "") +
2151
+ "\n\n## Loop Directory\n" + lDir;
2152
+
2153
+ // Create a new session for crafting
2154
+ var craftingSession = sm.createSession();
2155
+ var craftName = (loopState.wizardData && loopState.wizardData.name) || "";
2156
+ craftingSession.title = "Ralph" + (craftName ? " " + craftName : "") + " Crafting";
2157
+ craftingSession.ralphCraftingMode = true;
2158
+ craftingSession.hidden = true;
2159
+ sm.saveSessionFile(craftingSession);
2160
+ sm.switchSession(craftingSession.localId);
2161
+ loopState.craftingSessionId = craftingSession.localId;
2162
+
2163
+ // Store crafting session ID in the registry record
2164
+ loopRegistry.updateRecord(newLoopId, { craftingSessionId: craftingSession.localId });
2165
+
2166
+ // Start .claude/ directory watcher
2167
+ startClaudeDirWatch();
2168
+
2169
+ // Start query
2170
+ craftingSession.history.push({ type: "user_message", text: craftingPrompt });
2171
+ sm.appendToSessionFile(craftingSession, { type: "user_message", text: craftingPrompt });
2172
+ send({ type: "user_message", text: craftingPrompt });
2173
+ craftingSession.isProcessing = true;
2174
+ onProcessingChanged();
2175
+ craftingSession.sentToolResults = {};
2176
+ send({ type: "status", status: "processing" });
2177
+ sdk.startQuery(craftingSession, craftingPrompt);
2178
+
2179
+ send({ type: "ralph_crafting_started", sessionId: craftingSession.localId, taskId: newLoopId });
2180
+ send({ type: "ralph_phase", phase: "crafting", wizardData: loopState.wizardData, craftingSessionId: craftingSession.localId });
2181
+ return;
2182
+ }
2183
+
2184
+ if (msg.type === "loop_registry_files") {
2185
+ var recId = msg.id;
2186
+ var lDir = path.join(cwd, ".claude", "loops", recId);
2187
+ var promptContent = "";
2188
+ var judgeContent = "";
2189
+ try { promptContent = fs.readFileSync(path.join(lDir, "PROMPT.md"), "utf8"); } catch (e) {}
2190
+ try { judgeContent = fs.readFileSync(path.join(lDir, "JUDGE.md"), "utf8"); } catch (e) {}
2191
+ send({
2192
+ type: "loop_registry_files_content",
2193
+ id: recId,
2194
+ prompt: promptContent,
2195
+ judge: judgeContent,
2196
+ });
2197
+ return;
2198
+ }
2199
+
2200
+ if (msg.type === "ralph_preview_files") {
2201
+ var promptContent = "";
2202
+ var judgeContent = "";
2203
+ var previewDir = loopDir();
2204
+ if (previewDir) {
2205
+ try { promptContent = fs.readFileSync(path.join(previewDir, "PROMPT.md"), "utf8"); } catch (e) {}
2206
+ try { judgeContent = fs.readFileSync(path.join(previewDir, "JUDGE.md"), "utf8"); } catch (e) {}
2207
+ }
2208
+ sendTo(ws, {
2209
+ type: "ralph_files_content",
2210
+ prompt: promptContent,
2211
+ judge: judgeContent,
2212
+ });
2213
+ return;
2214
+ }
2215
+
2216
+ if (msg.type === "ralph_wizard_cancel") {
2217
+ stopClaudeDirWatch();
2218
+ // Clean up loop directory
2219
+ var cancelDir = loopDir();
2220
+ if (cancelDir) {
2221
+ try { fs.rmSync(cancelDir, { recursive: true, force: true }); } catch (e) {}
2222
+ }
2223
+ clearLoopState();
2224
+ send({ type: "ralph_phase", phase: "idle", wizardData: null });
2225
+ return;
2226
+ }
2227
+
2228
+ if (msg.type === "ralph_cancel_crafting") {
2229
+ // Abort the crafting session if running
2230
+ if (loopState.craftingSessionId != null) {
2231
+ var craftSession = sm.sessions.get(loopState.craftingSessionId) || null;
2232
+ if (craftSession && craftSession.abortController) {
2233
+ craftSession.abortController.abort();
2234
+ }
2235
+ }
2236
+ stopClaudeDirWatch();
2237
+ // Clean up loop directory
2238
+ var craftCancelDir = loopDir();
2239
+ if (craftCancelDir) {
2240
+ try { fs.rmSync(craftCancelDir, { recursive: true, force: true }); } catch (e) {}
2241
+ }
2242
+ clearLoopState();
2243
+ send({ type: "ralph_phase", phase: "idle", wizardData: null });
2244
+ return;
2245
+ }
2246
+
2247
+ // --- Loop Registry messages ---
2248
+ if (msg.type === "loop_registry_list") {
2249
+ sendTo(ws, { type: "loop_registry_updated", records: loopRegistry.getAll() });
2250
+ return;
2251
+ }
2252
+
2253
+ if (msg.type === "loop_registry_update") {
2254
+ var updatedRec = loopRegistry.update(msg.id, msg.data || {});
2255
+ if (!updatedRec) {
2256
+ sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
2257
+ }
2258
+ return;
2259
+ }
2260
+
2261
+ if (msg.type === "loop_registry_remove") {
2262
+ var removedRec = loopRegistry.remove(msg.id);
2263
+ if (!removedRec) {
2264
+ sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
2265
+ }
2266
+ return;
2267
+ }
2268
+
2269
+ if (msg.type === "loop_registry_toggle") {
2270
+ var toggledRec = loopRegistry.toggleEnabled(msg.id);
2271
+ if (!toggledRec) {
2272
+ sendTo(ws, { type: "loop_registry_error", text: "Record not found or not scheduled" });
2273
+ }
2274
+ return;
2275
+ }
2276
+
2277
+ if (msg.type === "loop_registry_rerun") {
2278
+ // Re-run an existing job (one-off from library)
2279
+ if (loopState.active || loopState.phase === "executing") {
2280
+ sendTo(ws, { type: "loop_registry_error", text: "A loop is already running" });
2281
+ return;
2282
+ }
2283
+ var rerunRec = loopRegistry.getById(msg.id);
2284
+ if (!rerunRec) {
2285
+ sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
2286
+ return;
2287
+ }
2288
+ var rerunDir = path.join(cwd, ".claude", "loops", rerunRec.id);
2289
+ try {
2290
+ fs.accessSync(path.join(rerunDir, "PROMPT.md"));
2291
+ fs.accessSync(path.join(rerunDir, "JUDGE.md"));
2292
+ } catch (e) {
2293
+ sendTo(ws, { type: "loop_registry_error", text: "Loop files missing for " + rerunRec.id });
2294
+ return;
2295
+ }
2296
+ loopState.loopId = rerunRec.id;
2297
+ activeRegistryId = null; // not a scheduled trigger
2298
+ send({ type: "loop_rerun_started", recordId: rerunRec.id });
2299
+ startLoop();
2300
+ return;
2301
+ }
2302
+
1449
2303
  if (msg.type !== "message") return;
1450
2304
  if (!msg.text && (!msg.images || msg.images.length === 0) && (!msg.pastes || msg.pastes.length === 0)) return;
1451
2305
 
@@ -1752,12 +2606,23 @@ function createProjectContext(opts) {
1752
2606
  if (!ent.isDirectory() && !ent.isSymbolicLink()) continue;
1753
2607
  var mdPath = path.join(scanDirs[sd].dir, ent.name, "SKILL.md");
1754
2608
  try {
1755
- fs.accessSync(mdPath, fs.constants.R_OK);
2609
+ var mdContent = fs.readFileSync(mdPath, "utf8");
2610
+ var desc = "";
2611
+ // Parse YAML frontmatter for description
2612
+ if (mdContent.startsWith("---")) {
2613
+ var endIdx = mdContent.indexOf("---", 3);
2614
+ if (endIdx !== -1) {
2615
+ var frontmatter = mdContent.substring(3, endIdx);
2616
+ var descMatch = frontmatter.match(/^description:\s*(.+)/m);
2617
+ if (descMatch) desc = descMatch[1].trim();
2618
+ }
2619
+ }
1756
2620
  if (!installed[ent.name]) {
1757
- installed[ent.name] = { scope: scanDirs[sd].scope };
2621
+ installed[ent.name] = { scope: scanDirs[sd].scope, description: desc, path: path.join(scanDirs[sd].dir, ent.name) };
1758
2622
  } else {
1759
2623
  // project-level adds to existing global entry
1760
2624
  installed[ent.name].scope = "both";
2625
+ if (desc && !installed[ent.name].description) installed[ent.name].description = desc;
1761
2626
  }
1762
2627
  } catch (e) {}
1763
2628
  }
@@ -1767,6 +2632,21 @@ function createProjectContext(opts) {
1767
2632
  return true;
1768
2633
  }
1769
2634
 
2635
+ // Git dirty check
2636
+ if (req.method === "GET" && urlPath === "/api/git-dirty") {
2637
+ var execSync = require("child_process").execSync;
2638
+ try {
2639
+ var out = execSync("git status --porcelain", { cwd: cwd, encoding: "utf8", timeout: 5000 });
2640
+ var dirty = out.trim().length > 0;
2641
+ res.writeHead(200, { "Content-Type": "application/json" });
2642
+ res.end(JSON.stringify({ dirty: dirty }));
2643
+ } catch (e) {
2644
+ res.writeHead(200, { "Content-Type": "application/json" });
2645
+ res.end(JSON.stringify({ dirty: false }));
2646
+ }
2647
+ return true;
2648
+ }
2649
+
1770
2650
  // Info endpoint
1771
2651
  if (req.method === "GET" && urlPath === "/info") {
1772
2652
  res.writeHead(200, {
@@ -1782,6 +2662,7 @@ function createProjectContext(opts) {
1782
2662
 
1783
2663
  // --- Destroy ---
1784
2664
  function destroy() {
2665
+ loopRegistry.stopTimer();
1785
2666
  stopFileWatch();
1786
2667
  stopAllDirWatches();
1787
2668
  // Abort all active sessions