clay-server 2.5.1 → 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.
Files changed (41) hide show
  1. package/bin/claude-relay.js +6 -0
  2. package/bin/cli.js +77 -11
  3. package/lib/cli-sessions.js +2 -7
  4. package/lib/config.js +86 -7
  5. package/lib/daemon.js +279 -6
  6. package/lib/ipc.js +12 -0
  7. package/lib/notes.js +4 -3
  8. package/lib/project.js +1174 -28
  9. package/lib/public/app.js +879 -31
  10. package/lib/public/css/diff.css +15 -4
  11. package/lib/public/css/filebrowser.css +363 -3
  12. package/lib/public/css/icon-strip.css +317 -1
  13. package/lib/public/css/input.css +127 -50
  14. package/lib/public/css/loop.css +780 -0
  15. package/lib/public/css/messages.css +1 -1
  16. package/lib/public/css/mobile-nav.css +6 -2
  17. package/lib/public/css/rewind.css +23 -0
  18. package/lib/public/css/server-settings.css +67 -20
  19. package/lib/public/css/sidebar.css +10 -4
  20. package/lib/public/css/skills.css +789 -0
  21. package/lib/public/css/sticky-notes.css +486 -0
  22. package/lib/public/css/title-bar.css +157 -7
  23. package/lib/public/index.html +366 -55
  24. package/lib/public/modules/diff.js +3 -3
  25. package/lib/public/modules/filebrowser.js +169 -45
  26. package/lib/public/modules/input.js +123 -56
  27. package/lib/public/modules/project-settings.js +906 -0
  28. package/lib/public/modules/qrcode.js +23 -26
  29. package/lib/public/modules/server-settings.js +449 -53
  30. package/lib/public/modules/sidebar.js +732 -1
  31. package/lib/public/modules/skills.js +794 -0
  32. package/lib/public/modules/sticky-notes.js +617 -52
  33. package/lib/public/modules/terminal.js +7 -0
  34. package/lib/public/modules/theme.js +9 -19
  35. package/lib/public/modules/tools.js +16 -2
  36. package/lib/public/style.css +2 -0
  37. package/lib/sdk-bridge.js +46 -7
  38. package/lib/server.js +305 -1
  39. package/lib/sessions.js +11 -5
  40. package/lib/utils.js +18 -0
  41. package/package.json +3 -2
package/lib/project.js CHANGED
@@ -1,11 +1,15 @@
1
1
  var fs = require("fs");
2
2
  var path = require("path");
3
+ var os = require("os");
4
+ var crypto = require("crypto");
3
5
  var { createSessionManager } = require("./sessions");
4
6
  var { createSDKBridge } = require("./sdk-bridge");
5
7
  var { createTerminalManager } = require("./terminal-manager");
6
8
  var { createNotesManager } = require("./notes");
7
9
  var { fetchLatestVersion, isNewer } = require("./updater");
8
- var { execFileSync } = require("child_process");
10
+ var { execFileSync, spawn } = require("child_process");
11
+
12
+ var MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
9
13
 
10
14
  // SDK loaded dynamically (ESM module)
11
15
  var sdkModule = null;
@@ -63,6 +67,7 @@ function createProjectContext(opts) {
63
67
  var slug = opts.slug;
64
68
  var project = path.basename(cwd);
65
69
  var title = opts.title || null;
70
+ var icon = opts.icon || null;
66
71
  var pushModule = opts.pushModule || null;
67
72
  var debug = opts.debug || false;
68
73
  var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
@@ -70,6 +75,7 @@ function createProjectContext(opts) {
70
75
  var lanHost = opts.lanHost || null;
71
76
  var getProjectCount = opts.getProjectCount || function () { return 1; };
72
77
  var getProjectList = opts.getProjectList || function () { return []; };
78
+ var onProcessingChanged = opts.onProcessingChanged || function () {};
73
79
  var latestVersion = null;
74
80
 
75
81
  // --- Per-project clients ---
@@ -192,8 +198,17 @@ function createProjectContext(opts) {
192
198
 
193
199
  // --- Session manager ---
194
200
  var sm = createSessionManager({ cwd: cwd, send: send });
195
- sm.currentPermissionMode = "default";
196
- sm.currentEffort = "high";
201
+ var _projMode = typeof opts.onGetProjectDefaultMode === "function" ? opts.onGetProjectDefaultMode(slug) : null;
202
+ var _srvMode = typeof opts.onGetServerDefaultMode === "function" ? opts.onGetServerDefaultMode() : null;
203
+ sm.currentPermissionMode = (_projMode && _projMode.mode) || (_srvMode && _srvMode.mode) || "default";
204
+
205
+ var _projEffort = typeof opts.onGetProjectDefaultEffort === "function" ? opts.onGetProjectDefaultEffort(slug) : null;
206
+ var _srvEffort = typeof opts.onGetServerDefaultEffort === "function" ? opts.onGetServerDefaultEffort() : null;
207
+ sm.currentEffort = (_projEffort && _projEffort.effort) || (_srvEffort && _srvEffort.effort) || "medium";
208
+
209
+ var _projModel = typeof opts.onGetProjectDefaultModel === "function" ? opts.onGetProjectDefaultModel(slug) : null;
210
+ var _srvModel = typeof opts.onGetServerDefaultModel === "function" ? opts.onGetServerDefaultModel() : null;
211
+ sm._savedDefaultModel = (_projModel && _projModel.model) || (_srvModel && _srvModel.model) || null;
197
212
 
198
213
  // --- SDK bridge ---
199
214
  var sdk = createSDKBridge({
@@ -204,8 +219,523 @@ function createProjectContext(opts) {
204
219
  pushModule: pushModule,
205
220
  getSDK: getSDK,
206
221
  dangerouslySkipPermissions: dangerouslySkipPermissions,
222
+ onProcessingChanged: onProcessingChanged,
207
223
  });
208
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
+
209
739
  // --- Terminal manager ---
210
740
  var tm = createTerminalManager({ cwd: cwd, send: send, sendTo: sendTo });
211
741
  var nm = createNotesManager({ cwd: cwd, send: send, sendTo: sendTo });
@@ -223,6 +753,12 @@ function createProjectContext(opts) {
223
753
  clients.add(ws);
224
754
  broadcastClientCount();
225
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
+
226
762
  // Send cached state
227
763
  sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, dangerouslySkipPermissions: dangerouslySkipPermissions, lanHost: lanHost, projectCount: getProjectCount(), projects: getProjectList() });
228
764
  if (latestVersion) {
@@ -234,10 +770,48 @@ function createProjectContext(opts) {
234
770
  if (sm.currentModel) {
235
771
  sendTo(ws, { type: "model_info", model: sm.currentModel, models: sm.availableModels || [] });
236
772
  }
237
- sendTo(ws, { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "high", betas: sm.currentBetas || [] });
773
+ sendTo(ws, { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
238
774
  sendTo(ws, { type: "term_list", terminals: tm.list() });
239
775
  sendTo(ws, { type: "notes_list", notes: nm.list() });
240
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
+
241
815
  // Session list
242
816
  sendTo(ws, {
243
817
  type: "session_list",
@@ -489,13 +1063,70 @@ function createProjectContext(opts) {
489
1063
  return;
490
1064
  }
491
1065
 
1066
+ if (msg.type === "set_server_default_model" && msg.model) {
1067
+ if (typeof opts.onSetServerDefaultModel === "function") {
1068
+ opts.onSetServerDefaultModel(msg.model);
1069
+ }
1070
+ var session = sm.getActiveSession();
1071
+ if (session) {
1072
+ sdk.setModel(session, msg.model);
1073
+ }
1074
+ return;
1075
+ }
1076
+
1077
+ if (msg.type === "set_project_default_model" && msg.model) {
1078
+ if (typeof opts.onSetProjectDefaultModel === "function") {
1079
+ opts.onSetProjectDefaultModel(slug, msg.model);
1080
+ }
1081
+ var session = sm.getActiveSession();
1082
+ if (session) {
1083
+ sdk.setModel(session, msg.model);
1084
+ }
1085
+ return;
1086
+ }
1087
+
492
1088
  if (msg.type === "set_permission_mode" && msg.mode) {
1089
+ // When dangerouslySkipPermissions is active, don't allow UI to change mode
1090
+ if (dangerouslySkipPermissions) {
1091
+ send({ type: "config_state", model: sm.currentModel || "", mode: "bypassPermissions", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1092
+ return;
1093
+ }
493
1094
  sm.currentPermissionMode = msg.mode;
494
1095
  var session = sm.getActiveSession();
495
1096
  if (session) {
496
1097
  sdk.setPermissionMode(session, msg.mode);
497
1098
  }
498
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "high", betas: sm.currentBetas || [] });
1099
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1100
+ return;
1101
+ }
1102
+
1103
+ if (msg.type === "set_server_default_mode" && msg.mode) {
1104
+ if (typeof opts.onSetServerDefaultMode === "function") {
1105
+ opts.onSetServerDefaultMode(msg.mode);
1106
+ }
1107
+ if (!dangerouslySkipPermissions) {
1108
+ sm.currentPermissionMode = msg.mode;
1109
+ var session = sm.getActiveSession();
1110
+ if (session) {
1111
+ sdk.setPermissionMode(session, msg.mode);
1112
+ }
1113
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1114
+ }
1115
+ return;
1116
+ }
1117
+
1118
+ if (msg.type === "set_project_default_mode" && msg.mode) {
1119
+ if (typeof opts.onSetProjectDefaultMode === "function") {
1120
+ opts.onSetProjectDefaultMode(slug, msg.mode);
1121
+ }
1122
+ if (!dangerouslySkipPermissions) {
1123
+ sm.currentPermissionMode = msg.mode;
1124
+ var session = sm.getActiveSession();
1125
+ if (session) {
1126
+ sdk.setPermissionMode(session, msg.mode);
1127
+ }
1128
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1129
+ }
499
1130
  return;
500
1131
  }
501
1132
 
@@ -505,9 +1136,27 @@ function createProjectContext(opts) {
505
1136
  return;
506
1137
  }
507
1138
 
1139
+ if (msg.type === "set_server_default_effort" && msg.effort) {
1140
+ if (typeof opts.onSetServerDefaultEffort === "function") {
1141
+ opts.onSetServerDefaultEffort(msg.effort);
1142
+ }
1143
+ sm.currentEffort = msg.effort;
1144
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
1145
+ return;
1146
+ }
1147
+
1148
+ if (msg.type === "set_project_default_effort" && msg.effort) {
1149
+ if (typeof opts.onSetProjectDefaultEffort === "function") {
1150
+ opts.onSetProjectDefaultEffort(slug, msg.effort);
1151
+ }
1152
+ sm.currentEffort = msg.effort;
1153
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
1154
+ return;
1155
+ }
1156
+
508
1157
  if (msg.type === "set_betas") {
509
1158
  sm.currentBetas = msg.betas || [];
510
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "high", betas: sm.currentBetas });
1159
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas });
511
1160
  return;
512
1161
  }
513
1162
 
@@ -593,6 +1242,7 @@ function createProjectContext(opts) {
593
1242
  session.pendingPermissions = {};
594
1243
  session.pendingAskUser = {};
595
1244
  session.isProcessing = false;
1245
+ onProcessingChanged();
596
1246
 
597
1247
  sm.saveSessionFile(session);
598
1248
  sm.switchSession(session.localId);
@@ -641,7 +1291,7 @@ function createProjectContext(opts) {
641
1291
  if (decision === "allow_accept_edits") {
642
1292
  sdk.setPermissionMode(session, "acceptEdits");
643
1293
  sm.currentPermissionMode = "acceptEdits";
644
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "high", betas: sm.currentBetas || [] });
1294
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
645
1295
  pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
646
1296
  sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
647
1297
  return;
@@ -653,14 +1303,24 @@ function createProjectContext(opts) {
653
1303
  pending.resolve({ behavior: "deny", message: "User chose to clear context and restart" });
654
1304
  sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
655
1305
 
656
- // Abort the old session's query so it stops processing immediately
657
- if (session.abortController) {
658
- session.abortController.abort();
659
- }
1306
+ // Abort the old session's query but defer to next tick so the SDK's
1307
+ // deny write (scheduled as microtask by pending.resolve) completes first.
1308
+ // Aborting synchronously would kill the subprocess before the write,
1309
+ // causing an "Operation aborted" crash in the SDK.
660
1310
  session.isProcessing = false;
1311
+ onProcessingChanged();
661
1312
  session.pendingPermissions = {};
662
1313
  session.pendingAskUser = {};
663
1314
  sm.broadcastSessionList();
1315
+ setImmediate(function () {
1316
+ if (session.abortController) {
1317
+ session.abortController.abort();
1318
+ }
1319
+ });
1320
+
1321
+ // Update permission mode for the new session
1322
+ sm.currentPermissionMode = "acceptEdits";
1323
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
664
1324
 
665
1325
  // Build prompt from plan content (sent from client) or plan file path
666
1326
  var clientPlanContent = msg.planContent || "";
@@ -672,24 +1332,34 @@ function createProjectContext(opts) {
672
1332
  planPrompt = "Execute the plan in " + planFilePath + ". Do NOT re-enter plan mode — read the plan file and implement it step by step.";
673
1333
  }
674
1334
 
675
- // Wait a tick for the deny to propagate, then create new session + send plan
676
- setTimeout(function () {
677
- var newSession = sm.createSession();
678
- // Send the plan as the first user message (with planContent for UI rendering)
679
- var userMsg = { type: "user_message", text: planPrompt, planContent: clientPlanContent || null };
680
- newSession.history.push(userMsg);
681
- sm.appendToSessionFile(newSession, userMsg);
682
- newSession.title = "Plan execution (cleared context)";
683
- sm.saveSessionFile(newSession);
684
- sm.broadcastSessionList();
685
- send(userMsg);
1335
+ // Wait for old query stream to fully terminate, then create new session + send plan
1336
+ var oldStreamPromise = session.streamPromise || Promise.resolve();
1337
+ Promise.race([
1338
+ oldStreamPromise,
1339
+ new Promise(function (resolve) { setTimeout(resolve, 3000); }),
1340
+ ]).then(function () {
1341
+ try {
1342
+ var newSession = sm.createSession();
1343
+ // Send the plan as the first user message (with planContent for UI rendering)
1344
+ var userMsg = { type: "user_message", text: planPrompt, planContent: clientPlanContent || null };
1345
+ newSession.history.push(userMsg);
1346
+ sm.appendToSessionFile(newSession, userMsg);
1347
+ newSession.title = "Plan execution (cleared context)";
1348
+ sm.saveSessionFile(newSession);
1349
+ sm.broadcastSessionList();
1350
+ send(userMsg);
686
1351
 
687
- newSession.isProcessing = true;
688
- newSession.sentToolResults = {};
689
- send({ type: "status", status: "processing" });
690
- newSession.acceptEditsAfterStart = true;
691
- sdk.startQuery(newSession, planPrompt);
692
- }, 200);
1352
+ newSession.isProcessing = true;
1353
+ onProcessingChanged();
1354
+ newSession.sentToolResults = {};
1355
+ send({ type: "status", status: "processing" });
1356
+ newSession.acceptEditsAfterStart = true;
1357
+ sdk.startQuery(newSession, planPrompt);
1358
+ } catch (e) {
1359
+ console.error("[project] Error starting plan execution:", e);
1360
+ send({ type: "error", text: "Failed to start plan execution: " + (e.message || e) });
1361
+ }
1362
+ });
693
1363
  return;
694
1364
  }
695
1365
 
@@ -709,6 +1379,7 @@ function createProjectContext(opts) {
709
1379
 
710
1380
  if (!session.isProcessing) {
711
1381
  session.isProcessing = true;
1382
+ onProcessingChanged();
712
1383
  session.sentToolResults = {};
713
1384
  send({ type: "status", status: "processing" });
714
1385
  if (!session.queryInstance) {
@@ -820,6 +1491,52 @@ function createProjectContext(opts) {
820
1491
  return;
821
1492
  }
822
1493
 
1494
+ // --- Reorder projects ---
1495
+ if (msg.type === "reorder_projects") {
1496
+ var slugs = msg.slugs;
1497
+ if (!Array.isArray(slugs) || slugs.length === 0) {
1498
+ sendTo(ws, { type: "reorder_projects_result", ok: false, error: "Missing slugs" });
1499
+ return;
1500
+ }
1501
+ if (typeof opts.onReorderProjects === "function") {
1502
+ var reorderResult = opts.onReorderProjects(slugs);
1503
+ sendTo(ws, { type: "reorder_projects_result", ok: reorderResult.ok, error: reorderResult.error });
1504
+ } else {
1505
+ sendTo(ws, { type: "reorder_projects_result", ok: false, error: "Not supported" });
1506
+ }
1507
+ return;
1508
+ }
1509
+
1510
+ // --- Set project title (rename) ---
1511
+ if (msg.type === "set_project_title") {
1512
+ if (!msg.slug) {
1513
+ sendTo(ws, { type: "set_project_title_result", ok: false, error: "Missing slug" });
1514
+ return;
1515
+ }
1516
+ if (typeof opts.onSetProjectTitle === "function") {
1517
+ var titleResult = opts.onSetProjectTitle(msg.slug, msg.title || null);
1518
+ sendTo(ws, { type: "set_project_title_result", ok: titleResult.ok, slug: msg.slug, error: titleResult.error });
1519
+ } else {
1520
+ sendTo(ws, { type: "set_project_title_result", ok: false, error: "Not supported" });
1521
+ }
1522
+ return;
1523
+ }
1524
+
1525
+ // --- Set project icon (emoji) ---
1526
+ if (msg.type === "set_project_icon") {
1527
+ if (!msg.slug) {
1528
+ sendTo(ws, { type: "set_project_icon_result", ok: false, error: "Missing slug" });
1529
+ return;
1530
+ }
1531
+ if (typeof opts.onSetProjectIcon === "function") {
1532
+ var iconResult = opts.onSetProjectIcon(msg.slug, msg.icon || null);
1533
+ sendTo(ws, { type: "set_project_icon_result", ok: iconResult.ok, slug: msg.slug, error: iconResult.error });
1534
+ } else {
1535
+ sendTo(ws, { type: "set_project_icon_result", ok: false, error: "Not supported" });
1536
+ }
1537
+ return;
1538
+ }
1539
+
823
1540
  // --- Daemon config (server settings) ---
824
1541
  if (msg.type === "get_daemon_config") {
825
1542
  if (typeof opts.onGetDaemonConfig === "function") {
@@ -860,6 +1577,20 @@ function createProjectContext(opts) {
860
1577
  return;
861
1578
  }
862
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
+
863
1594
  // --- File browser ---
864
1595
  if (msg.type === "fs_list") {
865
1596
  var fsDir = safePath(cwd, msg.path || ".");
@@ -915,6 +1646,98 @@ function createProjectContext(opts) {
915
1646
  return;
916
1647
  }
917
1648
 
1649
+ // --- File write ---
1650
+ if (msg.type === "fs_write") {
1651
+ var fsWriteFile = safePath(cwd, msg.path);
1652
+ if (!fsWriteFile) {
1653
+ sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: "Access denied" });
1654
+ return;
1655
+ }
1656
+ try {
1657
+ fs.writeFileSync(fsWriteFile, msg.content || "", "utf8");
1658
+ sendTo(ws, { type: "fs_write_result", path: msg.path, ok: true });
1659
+ } catch (e) {
1660
+ sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: e.message });
1661
+ }
1662
+ return;
1663
+ }
1664
+
1665
+ // --- Project environment variables ---
1666
+ if (msg.type === "get_project_env") {
1667
+ var envrc = "";
1668
+ var hasEnvrc = false;
1669
+ if (typeof opts.onGetProjectEnv === "function") {
1670
+ var envResult = opts.onGetProjectEnv(msg.slug);
1671
+ envrc = envResult.envrc || "";
1672
+ }
1673
+ try {
1674
+ var envrcPath = path.join(cwd, ".envrc");
1675
+ hasEnvrc = fs.existsSync(envrcPath);
1676
+ } catch (e) {}
1677
+ sendTo(ws, { type: "project_env_result", slug: msg.slug, envrc: envrc, hasEnvrc: hasEnvrc });
1678
+ return;
1679
+ }
1680
+
1681
+ if (msg.type === "set_project_env") {
1682
+ if (typeof opts.onSetProjectEnv === "function") {
1683
+ var setResult = opts.onSetProjectEnv(msg.slug, msg.envrc || "");
1684
+ sendTo(ws, { type: "set_project_env_result", ok: setResult.ok, slug: msg.slug, error: setResult.error });
1685
+ } else {
1686
+ sendTo(ws, { type: "set_project_env_result", ok: false, error: "Not supported" });
1687
+ }
1688
+ return;
1689
+ }
1690
+
1691
+ // --- Global CLAUDE.md ---
1692
+ if (msg.type === "read_global_claude_md") {
1693
+ var os = require("os");
1694
+ var globalMdPath = path.join(os.homedir(), ".claude", "CLAUDE.md");
1695
+ try {
1696
+ var globalMdContent = fs.readFileSync(globalMdPath, "utf8");
1697
+ sendTo(ws, { type: "global_claude_md_result", content: globalMdContent });
1698
+ } catch (e) {
1699
+ sendTo(ws, { type: "global_claude_md_result", error: e.message });
1700
+ }
1701
+ return;
1702
+ }
1703
+
1704
+ if (msg.type === "write_global_claude_md") {
1705
+ var os2 = require("os");
1706
+ var globalMdDir = path.join(os2.homedir(), ".claude");
1707
+ var globalMdWritePath = path.join(globalMdDir, "CLAUDE.md");
1708
+ try {
1709
+ if (!fs.existsSync(globalMdDir)) {
1710
+ fs.mkdirSync(globalMdDir, { recursive: true });
1711
+ }
1712
+ fs.writeFileSync(globalMdWritePath, msg.content || "", "utf8");
1713
+ sendTo(ws, { type: "write_global_claude_md_result", ok: true });
1714
+ } catch (e) {
1715
+ sendTo(ws, { type: "write_global_claude_md_result", ok: false, error: e.message });
1716
+ }
1717
+ return;
1718
+ }
1719
+
1720
+ // --- Shared environment variables ---
1721
+ if (msg.type === "get_shared_env") {
1722
+ var sharedEnvrc = "";
1723
+ if (typeof opts.onGetSharedEnv === "function") {
1724
+ var sharedResult = opts.onGetSharedEnv();
1725
+ sharedEnvrc = sharedResult.envrc || "";
1726
+ }
1727
+ sendTo(ws, { type: "shared_env_result", envrc: sharedEnvrc });
1728
+ return;
1729
+ }
1730
+
1731
+ if (msg.type === "set_shared_env") {
1732
+ if (typeof opts.onSetSharedEnv === "function") {
1733
+ var sharedSetResult = opts.onSetSharedEnv(msg.envrc || "");
1734
+ sendTo(ws, { type: "set_shared_env_result", ok: sharedSetResult.ok, error: sharedSetResult.error });
1735
+ } else {
1736
+ sendTo(ws, { type: "set_shared_env_result", ok: false, error: "Not supported" });
1737
+ }
1738
+ return;
1739
+ }
1740
+
918
1741
  // --- File watcher ---
919
1742
  if (msg.type === "fs_watch") {
920
1743
  if (msg.path) startFileWatch(msg.path);
@@ -1195,6 +2018,117 @@ function createProjectContext(opts) {
1195
2018
  return;
1196
2019
  }
1197
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
+
1198
2132
  if (msg.type !== "message") return;
1199
2133
  if (!msg.text && (!msg.images || msg.images.length === 0) && (!msg.pastes || msg.pastes.length === 0)) return;
1200
2134
 
@@ -1228,6 +2162,7 @@ function createProjectContext(opts) {
1228
2162
 
1229
2163
  if (!session.isProcessing) {
1230
2164
  session.isProcessing = true;
2165
+ onProcessingChanged();
1231
2166
  session.sentToolResults = {};
1232
2167
  send({ type: "status", status: "processing" });
1233
2168
  if (!session.queryInstance) {
@@ -1254,6 +2189,54 @@ function createProjectContext(opts) {
1254
2189
 
1255
2190
  // --- Handle project-scoped HTTP requests ---
1256
2191
  function handleHTTP(req, res, urlPath) {
2192
+ // File upload
2193
+ if (req.method === "POST" && urlPath === "/api/upload") {
2194
+ parseJsonBody(req).then(function (body) {
2195
+ var fileName = body.name;
2196
+ var fileData = body.data; // base64
2197
+ if (!fileName || !fileData) {
2198
+ res.writeHead(400, { "Content-Type": "application/json" });
2199
+ res.end('{"error":"missing name or data"}');
2200
+ return;
2201
+ }
2202
+ // Sanitize filename — strip path separators
2203
+ var safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._\-\(\)\[\] ]/g, "_");
2204
+ if (!safeName) safeName = "upload";
2205
+
2206
+ // Check size
2207
+ var estimatedBytes = fileData.length * 0.75;
2208
+ if (estimatedBytes > MAX_UPLOAD_BYTES) {
2209
+ res.writeHead(413, { "Content-Type": "application/json" });
2210
+ res.end('{"error":"file too large (max 50MB)"}');
2211
+ return;
2212
+ }
2213
+
2214
+ // Create tmp dir: os.tmpdir()/clay-{hash}/
2215
+ var cwdHash = crypto.createHash("sha256").update(cwd).digest("hex").substring(0, 12);
2216
+ var tmpDir = path.join(os.tmpdir(), "clay-" + cwdHash);
2217
+ try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (e) {}
2218
+
2219
+ // Add timestamp prefix to avoid collisions
2220
+ var ts = Date.now();
2221
+ var destName = ts + "-" + safeName;
2222
+ var destPath = path.join(tmpDir, destName);
2223
+
2224
+ try {
2225
+ var buf = Buffer.from(fileData, "base64");
2226
+ fs.writeFileSync(destPath, buf);
2227
+ res.writeHead(200, { "Content-Type": "application/json" });
2228
+ res.end(JSON.stringify({ path: destPath, name: safeName }));
2229
+ } catch (e) {
2230
+ res.writeHead(500, { "Content-Type": "application/json" });
2231
+ res.end(JSON.stringify({ error: "failed to save: " + (e.message || e) }));
2232
+ }
2233
+ }).catch(function () {
2234
+ res.writeHead(400);
2235
+ res.end("Bad request");
2236
+ });
2237
+ return true;
2238
+ }
2239
+
1257
2240
  // Push subscribe
1258
2241
  if (req.method === "POST" && urlPath === "/api/push-subscribe") {
1259
2242
  parseJsonBody(req).then(function (body) {
@@ -1342,6 +2325,157 @@ function createProjectContext(opts) {
1342
2325
  return true;
1343
2326
  }
1344
2327
 
2328
+ // Install a skill (background spawn)
2329
+ if (req.method === "POST" && urlPath === "/api/install-skill") {
2330
+ parseJsonBody(req).then(function (body) {
2331
+ var url = body.url;
2332
+ var skill = body.skill;
2333
+ var scope = body.scope; // "global" or "project"
2334
+ if (!url || !skill || !scope) {
2335
+ res.writeHead(400, { "Content-Type": "application/json" });
2336
+ res.end('{"error":"missing url, skill, or scope"}');
2337
+ return;
2338
+ }
2339
+ var spawnCwd = scope === "global" ? os.homedir() : cwd;
2340
+ var child = spawn("npx", ["skills", "add", url, "--skill", skill], {
2341
+ cwd: spawnCwd,
2342
+ stdio: "ignore",
2343
+ detached: false,
2344
+ });
2345
+ child.on("close", function (code) {
2346
+ var success = code === 0;
2347
+ send({
2348
+ type: "skill_installed",
2349
+ skill: skill,
2350
+ scope: scope,
2351
+ success: success,
2352
+ error: success ? null : "Process exited with code " + code,
2353
+ });
2354
+ });
2355
+ child.on("error", function (err) {
2356
+ send({
2357
+ type: "skill_installed",
2358
+ skill: skill,
2359
+ scope: scope,
2360
+ success: false,
2361
+ error: err.message,
2362
+ });
2363
+ });
2364
+ res.writeHead(200, { "Content-Type": "application/json" });
2365
+ res.end('{"ok":true}');
2366
+ }).catch(function () {
2367
+ res.writeHead(400);
2368
+ res.end("Bad request");
2369
+ });
2370
+ return true;
2371
+ }
2372
+
2373
+ // Uninstall a skill (remove directory)
2374
+ if (req.method === "POST" && urlPath === "/api/uninstall-skill") {
2375
+ parseJsonBody(req).then(function (body) {
2376
+ var skill = body.skill;
2377
+ var scope = body.scope; // "global" or "project"
2378
+ if (!skill || !scope) {
2379
+ res.writeHead(400, { "Content-Type": "application/json" });
2380
+ res.end('{"error":"missing skill or scope"}');
2381
+ return;
2382
+ }
2383
+ var baseDir = scope === "global" ? os.homedir() : cwd;
2384
+ var skillDir = path.join(baseDir, ".claude", "skills", skill);
2385
+ // Safety: ensure skillDir is inside the expected .claude/skills directory
2386
+ var expectedParent = path.join(baseDir, ".claude", "skills");
2387
+ var resolved = path.resolve(skillDir);
2388
+ if (!resolved.startsWith(expectedParent + path.sep)) {
2389
+ res.writeHead(403, { "Content-Type": "application/json" });
2390
+ res.end('{"error":"invalid skill path"}');
2391
+ return;
2392
+ }
2393
+ try {
2394
+ fs.rmSync(resolved, { recursive: true, force: true });
2395
+ send({
2396
+ type: "skill_uninstalled",
2397
+ skill: skill,
2398
+ scope: scope,
2399
+ success: true,
2400
+ });
2401
+ res.writeHead(200, { "Content-Type": "application/json" });
2402
+ res.end('{"ok":true}');
2403
+ } catch (err) {
2404
+ send({
2405
+ type: "skill_uninstalled",
2406
+ skill: skill,
2407
+ scope: scope,
2408
+ success: false,
2409
+ error: err.message,
2410
+ });
2411
+ res.writeHead(500, { "Content-Type": "application/json" });
2412
+ res.end(JSON.stringify({ error: err.message }));
2413
+ }
2414
+ }).catch(function () {
2415
+ res.writeHead(400);
2416
+ res.end("Bad request");
2417
+ });
2418
+ return true;
2419
+ }
2420
+
2421
+ // Installed skills (global + project)
2422
+ if (req.method === "GET" && urlPath === "/api/installed-skills") {
2423
+ var installed = {};
2424
+ var globalDir = path.join(os.homedir(), ".claude", "skills");
2425
+ var projectDir = path.join(cwd, ".claude", "skills");
2426
+ var scanDirs = [
2427
+ { dir: globalDir, scope: "global" },
2428
+ { dir: projectDir, scope: "project" },
2429
+ ];
2430
+ for (var sd = 0; sd < scanDirs.length; sd++) {
2431
+ var entries;
2432
+ try { entries = fs.readdirSync(scanDirs[sd].dir, { withFileTypes: true }); } catch (e) { continue; }
2433
+ for (var si = 0; si < entries.length; si++) {
2434
+ var ent = entries[si];
2435
+ if (!ent.isDirectory() && !ent.isSymbolicLink()) continue;
2436
+ var mdPath = path.join(scanDirs[sd].dir, ent.name, "SKILL.md");
2437
+ try {
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
+ }
2449
+ if (!installed[ent.name]) {
2450
+ installed[ent.name] = { scope: scanDirs[sd].scope, description: desc, path: path.join(scanDirs[sd].dir, ent.name) };
2451
+ } else {
2452
+ // project-level adds to existing global entry
2453
+ installed[ent.name].scope = "both";
2454
+ if (desc && !installed[ent.name].description) installed[ent.name].description = desc;
2455
+ }
2456
+ } catch (e) {}
2457
+ }
2458
+ }
2459
+ res.writeHead(200, { "Content-Type": "application/json" });
2460
+ res.end(JSON.stringify({ installed: installed }));
2461
+ return true;
2462
+ }
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
+
1345
2479
  // Info endpoint
1346
2480
  if (req.method === "GET" && urlPath === "/info") {
1347
2481
  res.writeHead(200, {
@@ -1374,6 +2508,12 @@ function createProjectContext(opts) {
1374
2508
  try { ws.close(); } catch (e) {}
1375
2509
  }
1376
2510
  clients.clear();
2511
+ // Cleanup tmp upload directory
2512
+ try {
2513
+ var cwdHash = crypto.createHash("sha256").update(cwd).digest("hex").substring(0, 12);
2514
+ var tmpDir = path.join(os.tmpdir(), "clay-" + cwdHash);
2515
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2516
+ } catch (e) {}
1377
2517
  }
1378
2518
 
1379
2519
  // --- Status info ---
@@ -1388,6 +2528,7 @@ function createProjectContext(opts) {
1388
2528
  path: cwd,
1389
2529
  project: project,
1390
2530
  title: title,
2531
+ icon: icon,
1391
2532
  clients: clients.size,
1392
2533
  sessions: sessionCount,
1393
2534
  isProcessing: hasProcessing,
@@ -1399,6 +2540,10 @@ function createProjectContext(opts) {
1399
2540
  send({ type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, lanHost: lanHost, projectCount: getProjectCount(), projects: getProjectList() });
1400
2541
  }
1401
2542
 
2543
+ function setIcon(newIcon) {
2544
+ icon = newIcon || null;
2545
+ }
2546
+
1402
2547
  return {
1403
2548
  cwd: cwd,
1404
2549
  slug: slug,
@@ -1414,6 +2559,7 @@ function createProjectContext(opts) {
1414
2559
  handleHTTP: handleHTTP,
1415
2560
  getStatus: getStatus,
1416
2561
  setTitle: setTitle,
2562
+ setIcon: setIcon,
1417
2563
  warmup: function () { sdk.warmup(); },
1418
2564
  destroy: destroy,
1419
2565
  };