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/bin/cli.js +51 -3
- package/lib/config.js +8 -2
- package/lib/daemon.js +47 -5
- package/lib/ipc.js +12 -0
- package/lib/notes.js +1 -1
- package/lib/project.js +711 -2
- package/lib/public/app.js +693 -8
- package/lib/public/css/diff.css +12 -0
- package/lib/public/css/filebrowser.css +1 -1
- package/lib/public/css/loop.css +780 -0
- package/lib/public/css/rewind.css +23 -0
- package/lib/public/css/sidebar.css +1 -0
- package/lib/public/css/skills.css +59 -0
- package/lib/public/css/sticky-notes.css +486 -0
- package/lib/public/css/title-bar.css +83 -3
- package/lib/public/index.html +107 -3
- package/lib/public/modules/diff.js +3 -3
- package/lib/public/modules/filebrowser.js +169 -45
- package/lib/public/modules/input.js +4 -0
- package/lib/public/modules/qrcode.js +23 -26
- package/lib/public/modules/server-settings.js +40 -0
- package/lib/public/modules/sidebar.js +12 -0
- package/lib/public/modules/skills.js +84 -0
- package/lib/public/modules/sticky-notes.js +617 -52
- package/lib/public/modules/theme.js +9 -19
- package/lib/public/modules/tools.js +16 -2
- package/lib/public/style.css +1 -0
- package/lib/sdk-bridge.js +28 -0
- package/lib/sessions.js +2 -1
- package/package.json +1 -1
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.
|
|
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, {
|