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