clay-server 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/project.js CHANGED
@@ -222,6 +222,520 @@ function createProjectContext(opts) {
222
222
  onProcessingChanged: onProcessingChanged,
223
223
  });
224
224
 
225
+ // --- Ralph Loop state ---
226
+ var loopState = {
227
+ active: false,
228
+ phase: "idle", // idle | crafting | approval | executing | done
229
+ promptText: "",
230
+ judgeText: "",
231
+ iteration: 0,
232
+ maxIterations: 20,
233
+ baseCommit: null,
234
+ currentSessionId: null,
235
+ judgeSessionId: null,
236
+ results: [],
237
+ stopping: false,
238
+ wizardData: null,
239
+ craftingSessionId: null,
240
+ startedAt: null,
241
+ loopId: null,
242
+ };
243
+
244
+ function loopDir() {
245
+ if (!loopState.loopId) return null;
246
+ return path.join(cwd, ".claude", "loops", loopState.loopId);
247
+ }
248
+
249
+ function generateLoopId() {
250
+ return "loop_" + Date.now() + "_" + crypto.randomBytes(3).toString("hex");
251
+ }
252
+
253
+ // Loop state persistence
254
+ var _loopConfig = require("./config");
255
+ var _loopUtils = require("./utils");
256
+ var _loopEncodedCwd = _loopUtils.encodeCwd(cwd);
257
+ var _loopDir = path.join(_loopConfig.CONFIG_DIR, "loops");
258
+ var _loopStatePath = path.join(_loopDir, _loopEncodedCwd + ".json");
259
+
260
+ function saveLoopState() {
261
+ try {
262
+ fs.mkdirSync(_loopDir, { recursive: true });
263
+ var data = {
264
+ phase: loopState.phase,
265
+ active: loopState.active,
266
+ iteration: loopState.iteration,
267
+ maxIterations: loopState.maxIterations,
268
+ baseCommit: loopState.baseCommit,
269
+ results: loopState.results,
270
+ wizardData: loopState.wizardData,
271
+ startedAt: loopState.startedAt,
272
+ loopId: loopState.loopId,
273
+ };
274
+ var tmpPath = _loopStatePath + ".tmp";
275
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
276
+ fs.renameSync(tmpPath, _loopStatePath);
277
+ } catch (e) {
278
+ console.error("[ralph-loop] Failed to save state:", e.message);
279
+ }
280
+ }
281
+
282
+ function loadLoopState() {
283
+ try {
284
+ var raw = fs.readFileSync(_loopStatePath, "utf8");
285
+ var data = JSON.parse(raw);
286
+ loopState.phase = data.phase || "idle";
287
+ loopState.active = data.active || false;
288
+ loopState.iteration = data.iteration || 0;
289
+ loopState.maxIterations = data.maxIterations || 20;
290
+ loopState.baseCommit = data.baseCommit || null;
291
+ loopState.results = data.results || [];
292
+ loopState.wizardData = data.wizardData || null;
293
+ loopState.startedAt = data.startedAt || null;
294
+ loopState.loopId = data.loopId || null;
295
+ // SDK sessions cannot survive daemon restart
296
+ loopState.currentSessionId = null;
297
+ loopState.judgeSessionId = null;
298
+ loopState.craftingSessionId = null;
299
+ loopState.stopping = false;
300
+ // If was executing, schedule resume after SDK is ready
301
+ if (loopState.phase === "executing" && loopState.active) {
302
+ loopState._needsResume = true;
303
+ }
304
+ // If was crafting, check if files exist and move to approval
305
+ if (loopState.phase === "crafting") {
306
+ var hasFiles = checkLoopFilesExist();
307
+ if (hasFiles) {
308
+ loopState.phase = "approval";
309
+ saveLoopState();
310
+ } else {
311
+ loopState.phase = "idle";
312
+ saveLoopState();
313
+ }
314
+ }
315
+ } catch (e) {
316
+ // No saved state, use defaults
317
+ }
318
+ }
319
+
320
+ function clearLoopState() {
321
+ loopState.active = false;
322
+ loopState.phase = "idle";
323
+ loopState.promptText = "";
324
+ loopState.judgeText = "";
325
+ loopState.iteration = 0;
326
+ loopState.maxIterations = 20;
327
+ loopState.baseCommit = null;
328
+ loopState.currentSessionId = null;
329
+ loopState.judgeSessionId = null;
330
+ loopState.results = [];
331
+ loopState.stopping = false;
332
+ loopState.wizardData = null;
333
+ loopState.craftingSessionId = null;
334
+ loopState.startedAt = null;
335
+ loopState.loopId = null;
336
+ saveLoopState();
337
+ }
338
+
339
+ function checkLoopFilesExist() {
340
+ var dir = loopDir();
341
+ if (!dir) return false;
342
+ var hasPrompt = false;
343
+ var hasJudge = false;
344
+ try { fs.accessSync(path.join(dir, "PROMPT.md")); hasPrompt = true; } catch (e) {}
345
+ try { fs.accessSync(path.join(dir, "JUDGE.md")); hasJudge = true; } catch (e) {}
346
+ return hasPrompt && hasJudge;
347
+ }
348
+
349
+ // .claude/ directory watcher for PROMPT.md / JUDGE.md
350
+ var claudeDirWatcher = null;
351
+ var claudeDirDebounce = null;
352
+
353
+ function startClaudeDirWatch() {
354
+ if (claudeDirWatcher) return;
355
+ var watchDir = loopDir();
356
+ if (!watchDir) return;
357
+ try { fs.mkdirSync(watchDir, { recursive: true }); } catch (e) {}
358
+ try {
359
+ claudeDirWatcher = fs.watch(watchDir, function () {
360
+ if (claudeDirDebounce) clearTimeout(claudeDirDebounce);
361
+ claudeDirDebounce = setTimeout(function () {
362
+ broadcastLoopFilesStatus();
363
+ }, 300);
364
+ });
365
+ claudeDirWatcher.on("error", function () {});
366
+ } catch (e) {
367
+ console.error("[ralph-loop] Failed to watch .claude/:", e.message);
368
+ }
369
+ }
370
+
371
+ function stopClaudeDirWatch() {
372
+ if (claudeDirWatcher) {
373
+ claudeDirWatcher.close();
374
+ claudeDirWatcher = null;
375
+ }
376
+ if (claudeDirDebounce) {
377
+ clearTimeout(claudeDirDebounce);
378
+ claudeDirDebounce = null;
379
+ }
380
+ }
381
+
382
+ function broadcastLoopFilesStatus() {
383
+ var dir = loopDir();
384
+ var hasPrompt = false;
385
+ var hasJudge = false;
386
+ var hasLoopJson = false;
387
+ if (dir) {
388
+ try { fs.accessSync(path.join(dir, "PROMPT.md")); hasPrompt = true; } catch (e) {}
389
+ try { fs.accessSync(path.join(dir, "JUDGE.md")); hasJudge = true; } catch (e) {}
390
+ try { fs.accessSync(path.join(dir, "LOOP.json")); hasLoopJson = true; } catch (e) {}
391
+ }
392
+ send({
393
+ type: "ralph_files_status",
394
+ promptReady: hasPrompt,
395
+ judgeReady: hasJudge,
396
+ loopJsonReady: hasLoopJson,
397
+ bothReady: hasPrompt && hasJudge,
398
+ });
399
+ // Auto-transition to approval phase when both files appear
400
+ if (hasPrompt && hasJudge && loopState.phase === "crafting") {
401
+ loopState.phase = "approval";
402
+ saveLoopState();
403
+ }
404
+ }
405
+
406
+ // Load persisted state on startup
407
+ loadLoopState();
408
+
409
+ function startLoop(opts) {
410
+ var loopOpts = opts || {};
411
+ var dir = loopDir();
412
+ if (!dir) {
413
+ send({ type: "loop_error", text: "No loop directory. Run the wizard first." });
414
+ return;
415
+ }
416
+ var promptPath = path.join(dir, "PROMPT.md");
417
+ var judgePath = path.join(dir, "JUDGE.md");
418
+ var promptText, judgeText;
419
+ try {
420
+ promptText = fs.readFileSync(promptPath, "utf8");
421
+ } catch (e) {
422
+ send({ type: "loop_error", text: "Missing PROMPT.md in " + dir });
423
+ return;
424
+ }
425
+ try {
426
+ judgeText = fs.readFileSync(judgePath, "utf8");
427
+ } catch (e) {
428
+ send({ type: "loop_error", text: "Missing JUDGE.md in " + dir });
429
+ return;
430
+ }
431
+
432
+ var baseCommit;
433
+ try {
434
+ baseCommit = execFileSync("git", ["rev-parse", "HEAD"], {
435
+ cwd: cwd, encoding: "utf8", timeout: 5000,
436
+ }).trim();
437
+ } catch (e) {
438
+ send({ type: "loop_error", text: "Failed to get git HEAD: " + e.message });
439
+ return;
440
+ }
441
+
442
+ // Read loop config from LOOP.json in loop directory
443
+ var loopConfig = {};
444
+ try {
445
+ loopConfig = JSON.parse(fs.readFileSync(path.join(dir, "LOOP.json"), "utf8"));
446
+ } catch (e) {}
447
+
448
+ loopState.active = true;
449
+ loopState.phase = "executing";
450
+ loopState.promptText = promptText;
451
+ loopState.judgeText = judgeText;
452
+ loopState.iteration = 0;
453
+ loopState.maxIterations = loopConfig.maxIterations || loopOpts.maxIterations || 20;
454
+ loopState.baseCommit = baseCommit;
455
+ loopState.currentSessionId = null;
456
+ loopState.judgeSessionId = null;
457
+ loopState.results = [];
458
+ loopState.stopping = false;
459
+ loopState.startedAt = Date.now();
460
+ saveLoopState();
461
+
462
+ stopClaudeDirWatch();
463
+
464
+ send({ type: "loop_started", maxIterations: loopState.maxIterations });
465
+ runNextIteration();
466
+ }
467
+
468
+ function runNextIteration() {
469
+ console.log("[ralph-loop] runNextIteration called, iteration: " + loopState.iteration + ", active: " + loopState.active + ", stopping: " + loopState.stopping);
470
+ if (!loopState.active || loopState.stopping) {
471
+ finishLoop("stopped");
472
+ return;
473
+ }
474
+
475
+ loopState.iteration++;
476
+ if (loopState.iteration > loopState.maxIterations) {
477
+ finishLoop("max_iterations");
478
+ return;
479
+ }
480
+
481
+ var session = sm.createSession();
482
+ session.loop = { active: true, iteration: loopState.iteration, role: "coder" };
483
+ var loopName = (loopState.wizardData && loopState.wizardData.name) || "";
484
+ session.title = "Ralph" + (loopName ? " " + loopName : "") + " #" + loopState.iteration;
485
+ sm.saveSessionFile(session);
486
+ sm.broadcastSessionList();
487
+
488
+ loopState.currentSessionId = session.localId;
489
+
490
+ send({
491
+ type: "loop_iteration",
492
+ iteration: loopState.iteration,
493
+ maxIterations: loopState.maxIterations,
494
+ sessionId: session.localId,
495
+ });
496
+
497
+ session.onQueryComplete = function(completedSession) {
498
+ console.log("[ralph-loop] Coder #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
499
+ if (!loopState.active) { console.log("[ralph-loop] Coder: loopState.active is false, skipping"); return; }
500
+ // Check if session ended with error
501
+ var lastItems = completedSession.history.slice(-3);
502
+ var hadError = false;
503
+ for (var i = 0; i < lastItems.length; i++) {
504
+ if (lastItems[i].type === "error" || (lastItems[i].type === "done" && lastItems[i].code === 1)) {
505
+ hadError = true;
506
+ break;
507
+ }
508
+ }
509
+ if (hadError) {
510
+ loopState.results.push({
511
+ iteration: loopState.iteration,
512
+ verdict: "error",
513
+ summary: "Iteration ended with error",
514
+ });
515
+ send({
516
+ type: "loop_verdict",
517
+ iteration: loopState.iteration,
518
+ verdict: "error",
519
+ summary: "Iteration ended with error, retrying...",
520
+ });
521
+ setTimeout(function() { runNextIteration(); }, 2000);
522
+ return;
523
+ }
524
+ runJudge();
525
+ };
526
+
527
+ var userMsg = { type: "user_message", text: loopState.promptText };
528
+ session.history.push(userMsg);
529
+ sm.appendToSessionFile(session, userMsg);
530
+
531
+ session.isProcessing = true;
532
+ onProcessingChanged();
533
+ session.sentToolResults = {};
534
+ send({ type: "status", status: "processing" });
535
+ session.acceptEditsAfterStart = true;
536
+ session.singleTurn = true;
537
+ sdk.startQuery(session, loopState.promptText);
538
+ }
539
+
540
+ function runJudge() {
541
+ if (!loopState.active || loopState.stopping) {
542
+ finishLoop("stopped");
543
+ return;
544
+ }
545
+
546
+ var diff;
547
+ try {
548
+ diff = execFileSync("git", ["diff", loopState.baseCommit], {
549
+ cwd: cwd, encoding: "utf8", timeout: 30000,
550
+ maxBuffer: 10 * 1024 * 1024,
551
+ });
552
+ } catch (e) {
553
+ send({ type: "loop_error", text: "Failed to generate git diff: " + e.message });
554
+ finishLoop("error");
555
+ return;
556
+ }
557
+
558
+ var judgePrompt = "You are a judge evaluating whether a coding task has been completed.\n\n" +
559
+ "## Original Task (PROMPT.md)\n\n" + loopState.promptText + "\n\n" +
560
+ "## Evaluation Criteria (JUDGE.md)\n\n" + loopState.judgeText + "\n\n" +
561
+ "## Changes Made (git diff)\n\n```diff\n" + diff + "\n```\n\n" +
562
+ "Based on the evaluation criteria, has the task been completed successfully?\n\n" +
563
+ "Respond with exactly one of:\n" +
564
+ "- PASS: [brief explanation]\n" +
565
+ "- FAIL: [brief explanation of what is still missing]\n\n" +
566
+ "Do NOT use any tools. Just analyze and respond.";
567
+
568
+ var judgeSession = sm.createSession();
569
+ judgeSession.loop = { active: true, iteration: loopState.iteration, role: "judge" };
570
+ var judgeName = (loopState.wizardData && loopState.wizardData.name) || "";
571
+ judgeSession.title = "Ralph" + (judgeName ? " " + judgeName : "") + " Judge #" + loopState.iteration;
572
+ sm.saveSessionFile(judgeSession);
573
+ sm.broadcastSessionList();
574
+ loopState.judgeSessionId = judgeSession.localId;
575
+
576
+ send({
577
+ type: "loop_judging",
578
+ iteration: loopState.iteration,
579
+ sessionId: judgeSession.localId,
580
+ });
581
+
582
+ judgeSession.onQueryComplete = function(completedSession) {
583
+ console.log("[ralph-loop] Judge #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
584
+ var verdict = parseJudgeVerdict(completedSession);
585
+ console.log("[ralph-loop] Judge verdict: " + (verdict.pass ? "PASS" : "FAIL") + " - " + verdict.explanation);
586
+
587
+ loopState.results.push({
588
+ iteration: loopState.iteration,
589
+ verdict: verdict.pass ? "pass" : "fail",
590
+ summary: verdict.explanation,
591
+ });
592
+
593
+ send({
594
+ type: "loop_verdict",
595
+ iteration: loopState.iteration,
596
+ verdict: verdict.pass ? "pass" : "fail",
597
+ summary: verdict.explanation,
598
+ });
599
+
600
+ if (verdict.pass) {
601
+ finishLoop("pass");
602
+ } else {
603
+ setTimeout(function() { runNextIteration(); }, 1000);
604
+ }
605
+ };
606
+
607
+ var userMsg = { type: "user_message", text: judgePrompt };
608
+ judgeSession.history.push(userMsg);
609
+ sm.appendToSessionFile(judgeSession, userMsg);
610
+
611
+ judgeSession.isProcessing = true;
612
+ onProcessingChanged();
613
+ judgeSession.sentToolResults = {};
614
+ judgeSession.acceptEditsAfterStart = true;
615
+ judgeSession.singleTurn = true;
616
+ sdk.startQuery(judgeSession, judgePrompt);
617
+ }
618
+
619
+ function parseJudgeVerdict(session) {
620
+ var text = "";
621
+ for (var i = 0; i < session.history.length; i++) {
622
+ var h = session.history[i];
623
+ if (h.type === "delta" && h.text) text += h.text;
624
+ if (h.type === "text" && h.text) text += h.text;
625
+ }
626
+ console.log("[ralph-loop] Judge raw text (last 500 chars): " + text.slice(-500));
627
+ var upper = text.toUpperCase();
628
+ var passIdx = upper.indexOf("PASS");
629
+ var failIdx = upper.indexOf("FAIL");
630
+ if (passIdx !== -1 && (failIdx === -1 || passIdx < failIdx)) {
631
+ var explanation = text.substring(passIdx + 4).replace(/^[\s:]+/, "").split("\n")[0].trim();
632
+ return { pass: true, explanation: explanation || "Task completed" };
633
+ }
634
+ if (failIdx !== -1) {
635
+ var explanation = text.substring(failIdx + 4).replace(/^[\s:]+/, "").split("\n")[0].trim();
636
+ return { pass: false, explanation: explanation || "Task not yet complete" };
637
+ }
638
+ return { pass: false, explanation: "Could not parse judge verdict" };
639
+ }
640
+
641
+ function finishLoop(reason) {
642
+ console.log("[ralph-loop] finishLoop called, reason: " + reason + ", iteration: " + loopState.iteration);
643
+ loopState.active = false;
644
+ loopState.phase = "done";
645
+ loopState.stopping = false;
646
+ loopState.currentSessionId = null;
647
+ loopState.judgeSessionId = null;
648
+ saveLoopState();
649
+
650
+ send({
651
+ type: "loop_finished",
652
+ reason: reason,
653
+ iterations: loopState.iteration,
654
+ results: loopState.results,
655
+ });
656
+
657
+ if (pushModule) {
658
+ var body = reason === "pass"
659
+ ? "Task completed after " + loopState.iteration + " iteration(s)"
660
+ : reason === "max_iterations"
661
+ ? "Reached max iterations (" + loopState.maxIterations + ")"
662
+ : reason === "stopped"
663
+ ? "Loop stopped by user"
664
+ : "Loop ended due to error";
665
+ pushModule.sendPush({
666
+ type: "done",
667
+ slug: slug,
668
+ title: "Ralph Loop Complete",
669
+ body: body,
670
+ tag: "ralph-loop-done",
671
+ });
672
+ }
673
+ }
674
+
675
+ function resumeLoop() {
676
+ var dir = loopDir();
677
+ if (!dir) {
678
+ console.error("[ralph-loop] Cannot resume: no loop directory");
679
+ loopState.active = false;
680
+ loopState.phase = "idle";
681
+ saveLoopState();
682
+ return;
683
+ }
684
+ try {
685
+ loopState.promptText = fs.readFileSync(path.join(dir, "PROMPT.md"), "utf8");
686
+ } catch (e) {
687
+ console.error("[ralph-loop] Cannot resume: missing PROMPT.md");
688
+ loopState.active = false;
689
+ loopState.phase = "idle";
690
+ saveLoopState();
691
+ return;
692
+ }
693
+ try {
694
+ loopState.judgeText = fs.readFileSync(path.join(dir, "JUDGE.md"), "utf8");
695
+ } catch (e) {
696
+ console.error("[ralph-loop] Cannot resume: missing JUDGE.md");
697
+ loopState.active = false;
698
+ loopState.phase = "idle";
699
+ saveLoopState();
700
+ return;
701
+ }
702
+ // Retry the interrupted iteration (runNextIteration will increment)
703
+ if (loopState.iteration > 0) {
704
+ loopState.iteration--;
705
+ }
706
+ console.log("[ralph-loop] Resuming loop, next iteration will be " + (loopState.iteration + 1) + "/" + loopState.maxIterations);
707
+ send({ type: "loop_started", maxIterations: loopState.maxIterations });
708
+ runNextIteration();
709
+ }
710
+
711
+ function stopLoop() {
712
+ if (!loopState.active) return;
713
+ console.log("[ralph-loop] stopLoop called");
714
+ loopState.stopping = true;
715
+
716
+ // Abort all loop-related sessions (coder + judge)
717
+ var sessionIds = [loopState.currentSessionId, loopState.judgeSessionId];
718
+ for (var i = 0; i < sessionIds.length; i++) {
719
+ if (sessionIds[i] == null) continue;
720
+ var s = sm.sessions.get(sessionIds[i]);
721
+ if (!s) continue;
722
+ // End message queue so SDK exits prompt wait
723
+ if (s.messageQueue) { try { s.messageQueue.end(); } catch (e) {} }
724
+ // Abort active API call
725
+ if (s.abortController) { try { s.abortController.abort(); } catch (e) {} }
726
+ }
727
+
728
+ send({ type: "loop_stopping" });
729
+
730
+ // Fallback: force finish if onQueryComplete hasn't fired after 5s
731
+ setTimeout(function() {
732
+ if (loopState.active && loopState.stopping) {
733
+ console.log("[ralph-loop] Stop fallback triggered — forcing finishLoop");
734
+ finishLoop("stopped");
735
+ }
736
+ }, 5000);
737
+ }
738
+
225
739
  // --- Terminal manager ---
226
740
  var tm = createTerminalManager({ cwd: cwd, send: send, sendTo: sendTo });
227
741
  var nm = createNotesManager({ cwd: cwd, send: send, sendTo: sendTo });
@@ -239,6 +753,12 @@ function createProjectContext(opts) {
239
753
  clients.add(ws);
240
754
  broadcastClientCount();
241
755
 
756
+ // Resume loop if server restarted mid-execution (deferred so client gets initial state first)
757
+ if (loopState._needsResume) {
758
+ delete loopState._needsResume;
759
+ setTimeout(function() { resumeLoop(); }, 500);
760
+ }
761
+
242
762
  // Send cached state
243
763
  sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, dangerouslySkipPermissions: dangerouslySkipPermissions, lanHost: lanHost, projectCount: getProjectCount(), projects: getProjectList() });
244
764
  if (latestVersion) {
@@ -254,6 +774,44 @@ function createProjectContext(opts) {
254
774
  sendTo(ws, { type: "term_list", terminals: tm.list() });
255
775
  sendTo(ws, { type: "notes_list", notes: nm.list() });
256
776
 
777
+ // Ralph Loop availability
778
+ var hasLoopFiles = false;
779
+ try {
780
+ fs.accessSync(path.join(cwd, ".claude", "PROMPT.md"));
781
+ fs.accessSync(path.join(cwd, ".claude", "JUDGE.md"));
782
+ hasLoopFiles = true;
783
+ } catch (e) {}
784
+ sendTo(ws, {
785
+ type: "loop_available",
786
+ available: hasLoopFiles,
787
+ active: loopState.active,
788
+ iteration: loopState.iteration,
789
+ maxIterations: loopState.maxIterations,
790
+ });
791
+
792
+ // Ralph phase state
793
+ sendTo(ws, {
794
+ type: "ralph_phase",
795
+ phase: loopState.phase,
796
+ wizardData: loopState.wizardData,
797
+ craftingSessionId: loopState.craftingSessionId || null,
798
+ });
799
+ if (loopState.phase === "crafting" || loopState.phase === "approval") {
800
+ var _hasPrompt = false;
801
+ var _hasJudge = false;
802
+ var _lDir = loopDir();
803
+ if (_lDir) {
804
+ try { fs.accessSync(path.join(_lDir, "PROMPT.md")); _hasPrompt = true; } catch (e) {}
805
+ try { fs.accessSync(path.join(_lDir, "JUDGE.md")); _hasJudge = true; } catch (e) {}
806
+ }
807
+ sendTo(ws, {
808
+ type: "ralph_files_status",
809
+ promptReady: _hasPrompt,
810
+ judgeReady: _hasJudge,
811
+ bothReady: _hasPrompt && _hasJudge,
812
+ });
813
+ }
814
+
257
815
  // Session list
258
816
  sendTo(ws, {
259
817
  type: "session_list",
@@ -1019,6 +1577,20 @@ function createProjectContext(opts) {
1019
1577
  return;
1020
1578
  }
1021
1579
 
1580
+ if (msg.type === "restart_server") {
1581
+ if (typeof opts.onRestart === "function") {
1582
+ sendTo(ws, { type: "restart_server_result", ok: true });
1583
+ send({ type: "toast", level: "info", message: "Server is restarting..." });
1584
+ // Small delay so the response has time to reach clients
1585
+ setTimeout(function () {
1586
+ opts.onRestart();
1587
+ }, 500);
1588
+ } else {
1589
+ sendTo(ws, { type: "restart_server_result", ok: false, error: "Restart not supported" });
1590
+ }
1591
+ return;
1592
+ }
1593
+
1022
1594
  // --- File browser ---
1023
1595
  if (msg.type === "fs_list") {
1024
1596
  var fsDir = safePath(cwd, msg.path || ".");
@@ -1446,6 +2018,117 @@ function createProjectContext(opts) {
1446
2018
  return;
1447
2019
  }
1448
2020
 
2021
+ if (msg.type === "loop_start") {
2022
+ startLoop();
2023
+ return;
2024
+ }
2025
+
2026
+ if (msg.type === "loop_stop") {
2027
+ stopLoop();
2028
+ return;
2029
+ }
2030
+
2031
+ if (msg.type === "ralph_wizard_complete") {
2032
+ var wData = msg.data || {};
2033
+ var maxIter = wData.maxIterations || 25;
2034
+ var newLoopId = generateLoopId();
2035
+ loopState.loopId = newLoopId;
2036
+ loopState.wizardData = {
2037
+ name: (wData.name || "").replace(/[^a-zA-Z0-9_-]/g, "") || "ralph",
2038
+ task: wData.task || "",
2039
+ maxIterations: maxIter,
2040
+ };
2041
+ loopState.phase = "crafting";
2042
+ loopState.startedAt = Date.now();
2043
+ saveLoopState();
2044
+
2045
+ // Create loop directory and write LOOP.json
2046
+ var lDir = loopDir();
2047
+ try { fs.mkdirSync(lDir, { recursive: true }); } catch (e) {}
2048
+ var loopJsonPath = path.join(lDir, "LOOP.json");
2049
+ var tmpLoopJson = loopJsonPath + ".tmp";
2050
+ fs.writeFileSync(tmpLoopJson, JSON.stringify({ maxIterations: maxIter }, null, 2));
2051
+ fs.renameSync(tmpLoopJson, loopJsonPath);
2052
+
2053
+ // Assemble prompt for clay-ralph skill (include loop dir path so skill knows where to write)
2054
+ var craftingPrompt = "/clay-ralph\n## Task\n" + (wData.task || "") +
2055
+ "\n## Loop Directory\n" + lDir;
2056
+
2057
+ // Create a new session for crafting
2058
+ var craftingSession = sm.createSession();
2059
+ var craftName = (loopState.wizardData && loopState.wizardData.name) || "";
2060
+ craftingSession.title = "Ralph" + (craftName ? " " + craftName : "") + " Crafting";
2061
+ craftingSession.ralphCraftingMode = true;
2062
+ sm.saveSessionFile(craftingSession);
2063
+ sm.switchSession(craftingSession.localId);
2064
+ sm.broadcastSessionList();
2065
+ loopState.craftingSessionId = craftingSession.localId;
2066
+
2067
+ // Start .claude/ directory watcher
2068
+ startClaudeDirWatch();
2069
+
2070
+ // Start query
2071
+ craftingSession.history.push({ type: "user_message", text: craftingPrompt });
2072
+ sm.appendToSessionFile(craftingSession, { type: "user_message", text: craftingPrompt });
2073
+ send({ type: "user_message", text: craftingPrompt });
2074
+ craftingSession.isProcessing = true;
2075
+ onProcessingChanged();
2076
+ craftingSession.sentToolResults = {};
2077
+ send({ type: "status", status: "processing" });
2078
+ sdk.startQuery(craftingSession, craftingPrompt);
2079
+
2080
+ send({ type: "ralph_crafting_started", sessionId: craftingSession.localId });
2081
+ send({ type: "ralph_phase", phase: "crafting", wizardData: loopState.wizardData, craftingSessionId: craftingSession.localId });
2082
+ return;
2083
+ }
2084
+
2085
+ if (msg.type === "ralph_preview_files") {
2086
+ var promptContent = "";
2087
+ var judgeContent = "";
2088
+ var previewDir = loopDir();
2089
+ if (previewDir) {
2090
+ try { promptContent = fs.readFileSync(path.join(previewDir, "PROMPT.md"), "utf8"); } catch (e) {}
2091
+ try { judgeContent = fs.readFileSync(path.join(previewDir, "JUDGE.md"), "utf8"); } catch (e) {}
2092
+ }
2093
+ sendTo(ws, {
2094
+ type: "ralph_files_content",
2095
+ prompt: promptContent,
2096
+ judge: judgeContent,
2097
+ });
2098
+ return;
2099
+ }
2100
+
2101
+ if (msg.type === "ralph_wizard_cancel") {
2102
+ stopClaudeDirWatch();
2103
+ // Clean up loop directory
2104
+ var cancelDir = loopDir();
2105
+ if (cancelDir) {
2106
+ try { fs.rmSync(cancelDir, { recursive: true, force: true }); } catch (e) {}
2107
+ }
2108
+ clearLoopState();
2109
+ send({ type: "ralph_phase", phase: "idle", wizardData: null });
2110
+ return;
2111
+ }
2112
+
2113
+ if (msg.type === "ralph_cancel_crafting") {
2114
+ // Abort the crafting session if running
2115
+ if (loopState.craftingSessionId != null) {
2116
+ var craftSession = sm.sessions.get(loopState.craftingSessionId) || null;
2117
+ if (craftSession && craftSession.abortController) {
2118
+ craftSession.abortController.abort();
2119
+ }
2120
+ }
2121
+ stopClaudeDirWatch();
2122
+ // Clean up loop directory
2123
+ var craftCancelDir = loopDir();
2124
+ if (craftCancelDir) {
2125
+ try { fs.rmSync(craftCancelDir, { recursive: true, force: true }); } catch (e) {}
2126
+ }
2127
+ clearLoopState();
2128
+ send({ type: "ralph_phase", phase: "idle", wizardData: null });
2129
+ return;
2130
+ }
2131
+
1449
2132
  if (msg.type !== "message") return;
1450
2133
  if (!msg.text && (!msg.images || msg.images.length === 0) && (!msg.pastes || msg.pastes.length === 0)) return;
1451
2134
 
@@ -1752,12 +2435,23 @@ function createProjectContext(opts) {
1752
2435
  if (!ent.isDirectory() && !ent.isSymbolicLink()) continue;
1753
2436
  var mdPath = path.join(scanDirs[sd].dir, ent.name, "SKILL.md");
1754
2437
  try {
1755
- fs.accessSync(mdPath, fs.constants.R_OK);
2438
+ var mdContent = fs.readFileSync(mdPath, "utf8");
2439
+ var desc = "";
2440
+ // Parse YAML frontmatter for description
2441
+ if (mdContent.startsWith("---")) {
2442
+ var endIdx = mdContent.indexOf("---", 3);
2443
+ if (endIdx !== -1) {
2444
+ var frontmatter = mdContent.substring(3, endIdx);
2445
+ var descMatch = frontmatter.match(/^description:\s*(.+)/m);
2446
+ if (descMatch) desc = descMatch[1].trim();
2447
+ }
2448
+ }
1756
2449
  if (!installed[ent.name]) {
1757
- installed[ent.name] = { scope: scanDirs[sd].scope };
2450
+ installed[ent.name] = { scope: scanDirs[sd].scope, description: desc, path: path.join(scanDirs[sd].dir, ent.name) };
1758
2451
  } else {
1759
2452
  // project-level adds to existing global entry
1760
2453
  installed[ent.name].scope = "both";
2454
+ if (desc && !installed[ent.name].description) installed[ent.name].description = desc;
1761
2455
  }
1762
2456
  } catch (e) {}
1763
2457
  }
@@ -1767,6 +2461,21 @@ function createProjectContext(opts) {
1767
2461
  return true;
1768
2462
  }
1769
2463
 
2464
+ // Git dirty check
2465
+ if (req.method === "GET" && urlPath === "/api/git-dirty") {
2466
+ var execSync = require("child_process").execSync;
2467
+ try {
2468
+ var out = execSync("git status --porcelain", { cwd: cwd, encoding: "utf8", timeout: 5000 });
2469
+ var dirty = out.trim().length > 0;
2470
+ res.writeHead(200, { "Content-Type": "application/json" });
2471
+ res.end(JSON.stringify({ dirty: dirty }));
2472
+ } catch (e) {
2473
+ res.writeHead(200, { "Content-Type": "application/json" });
2474
+ res.end(JSON.stringify({ dirty: false }));
2475
+ }
2476
+ return true;
2477
+ }
2478
+
1770
2479
  // Info endpoint
1771
2480
  if (req.method === "GET" && urlPath === "/info") {
1772
2481
  res.writeHead(200, {