clay-server 2.7.2 → 2.8.2

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 (55) hide show
  1. package/bin/cli.js +31 -17
  2. package/lib/config.js +7 -4
  3. package/lib/project.js +343 -15
  4. package/lib/public/app.js +1039 -134
  5. package/lib/public/apple-touch-icon-dark.png +0 -0
  6. package/lib/public/apple-touch-icon.png +0 -0
  7. package/lib/public/clay-logo.png +0 -0
  8. package/lib/public/css/base.css +18 -1
  9. package/lib/public/css/filebrowser.css +1 -0
  10. package/lib/public/css/home-hub.css +455 -0
  11. package/lib/public/css/icon-strip.css +6 -5
  12. package/lib/public/css/loop.css +141 -23
  13. package/lib/public/css/messages.css +2 -0
  14. package/lib/public/css/mobile-nav.css +38 -12
  15. package/lib/public/css/overlays.css +205 -169
  16. package/lib/public/css/playbook.css +264 -0
  17. package/lib/public/css/profile.css +268 -0
  18. package/lib/public/css/scheduler-modal.css +1429 -0
  19. package/lib/public/css/scheduler.css +1305 -0
  20. package/lib/public/css/sidebar.css +305 -11
  21. package/lib/public/css/sticky-notes.css +23 -19
  22. package/lib/public/css/stt.css +155 -0
  23. package/lib/public/css/title-bar.css +14 -6
  24. package/lib/public/favicon-banded-32.png +0 -0
  25. package/lib/public/favicon-banded.png +0 -0
  26. package/lib/public/icon-192-dark.png +0 -0
  27. package/lib/public/icon-192.png +0 -0
  28. package/lib/public/icon-512-dark.png +0 -0
  29. package/lib/public/icon-512.png +0 -0
  30. package/lib/public/icon-banded-76.png +0 -0
  31. package/lib/public/icon-banded-96.png +0 -0
  32. package/lib/public/index.html +336 -44
  33. package/lib/public/modules/ascii-logo.js +442 -0
  34. package/lib/public/modules/markdown.js +18 -0
  35. package/lib/public/modules/notifications.js +50 -63
  36. package/lib/public/modules/playbook.js +578 -0
  37. package/lib/public/modules/profile.js +357 -0
  38. package/lib/public/modules/project-settings.js +1 -9
  39. package/lib/public/modules/scheduler.js +2826 -0
  40. package/lib/public/modules/server-settings.js +1 -1
  41. package/lib/public/modules/sidebar.js +376 -32
  42. package/lib/public/modules/stt.js +272 -0
  43. package/lib/public/modules/terminal.js +32 -0
  44. package/lib/public/modules/theme.js +3 -10
  45. package/lib/public/style.css +6 -0
  46. package/lib/public/sw.js +82 -3
  47. package/lib/public/wordmark-banded-20.png +0 -0
  48. package/lib/public/wordmark-banded-32.png +0 -0
  49. package/lib/public/wordmark-banded-64.png +0 -0
  50. package/lib/public/wordmark-banded-80.png +0 -0
  51. package/lib/scheduler.js +402 -0
  52. package/lib/sdk-bridge.js +3 -2
  53. package/lib/server.js +124 -3
  54. package/lib/sessions.js +35 -2
  55. package/package.json +1 -1
package/bin/cli.js CHANGED
@@ -21,8 +21,9 @@ var { execSync, execFileSync, spawn } = require("child_process");
21
21
  var qrcode = require("qrcode-terminal");
22
22
  var net = require("net");
23
23
 
24
- // Detect dev mode (no separate storage — dev and prod share ~/.clay)
24
+ // Detect dev mode — dev and prod use separate daemon files so they can run simultaneously
25
25
  var _isDev = (process.argv[1] && path.basename(process.argv[1]) === "clay-dev") || process.argv.includes("--dev");
26
+ if (_isDev) process.env.CLAY_DEV = "1";
26
27
 
27
28
  var { loadConfig, saveConfig, configPath, socketPath, logPath, ensureConfigDir, isDaemonAlive, isDaemonAliveAsync, generateSlug, clearStaleConfig, loadClayrc, saveClayrc, readCrashInfo } = require("../lib/config");
28
29
  var { sendIPCCommand } = require("../lib/ipc");
@@ -120,7 +121,7 @@ for (var i = 0; i < args.length; i++) {
120
121
  console.log(" --list List all registered projects");
121
122
  console.log(" --headless Start daemon and exit immediately (implies --yes)");
122
123
  console.log(" --dangerously-skip-permissions");
123
- console.log(" Bypass all permission prompts (requires --pin)");
124
+ console.log(" Bypass all permission prompts");
124
125
  process.exit(0);
125
126
  }
126
127
  }
@@ -1242,7 +1243,30 @@ function setup(callback) {
1242
1243
  port = p;
1243
1244
  log(sym.bar);
1244
1245
 
1245
- promptPin(function (pin) {
1246
+ function askPin() {
1247
+ promptPin(function (pin) {
1248
+ if (dangerouslySkipPermissions && !pin) {
1249
+ log(sym.bar);
1250
+ log(sym.warn + " " + a.yellow + "WARNING: No PIN + skip permissions = anyone with the URL" + a.reset);
1251
+ log(sym.bar + " " + a.yellow + "can execute any command without approval." + a.reset);
1252
+ log(sym.bar);
1253
+ promptToggle("Continue without PIN?", null, false, function (confirmed) {
1254
+ if (!confirmed) {
1255
+ clearUp(6);
1256
+ log(sym.done + " PIN protection " + a.dim + "·" + a.reset + " " + a.yellow + "Required for skip permissions" + a.reset);
1257
+ log(sym.bar);
1258
+ askPin();
1259
+ return;
1260
+ }
1261
+ afterPin(pin);
1262
+ });
1263
+ } else {
1264
+ afterPin(pin);
1265
+ }
1266
+ });
1267
+ }
1268
+
1269
+ function afterPin(pin) {
1246
1270
  if (process.platform === "darwin") {
1247
1271
  promptToggle("Keep awake", "Prevent system sleep while relay is running", false, function (keepAwake) {
1248
1272
  callback(pin, keepAwake);
@@ -1250,7 +1274,9 @@ function setup(callback) {
1250
1274
  } else {
1251
1275
  callback(pin, false);
1252
1276
  }
1253
- });
1277
+ }
1278
+
1279
+ askPin();
1254
1280
  });
1255
1281
  });
1256
1282
  }
@@ -2397,15 +2423,10 @@ var currentVersion = require("../package.json").version;
2397
2423
  // No daemon running — first-time setup
2398
2424
  if (autoYes) {
2399
2425
  var pin = cliPin || null;
2400
- if (dangerouslySkipPermissions && !pin) {
2401
- console.error(" " + sym.warn + " " + a.red + "--dangerously-skip-permissions requires --pin <pin>" + a.reset);
2402
- process.exit(1);
2403
- return;
2404
- }
2405
2426
  console.log(" " + sym.done + " Auto-accepted disclaimer");
2406
2427
  console.log(" " + sym.done + " PIN: " + (pin ? "Enabled" : "Skipped"));
2407
2428
  if (dangerouslySkipPermissions) {
2408
- console.log(" " + sym.warn + " " + a.yellow + "Skip permissions mode enabled" + a.reset);
2429
+ console.log(" " + sym.warn + " " + a.yellow + "Skip permissions mode enabled" + (pin ? "" : " (no PIN)") + a.reset);
2409
2430
  }
2410
2431
  var autoRc = loadClayrc();
2411
2432
  var autoRestorable = (autoRc.recentProjects || []).filter(function (p) {
@@ -2422,13 +2443,6 @@ var currentVersion = require("../package.json").version;
2422
2443
  await forkDaemon(pin, false, autoRestorable.length > 0 ? autoRestorable : undefined, addCwd);
2423
2444
  } else {
2424
2445
  setup(function (pin, keepAwake) {
2425
- if (dangerouslySkipPermissions && !pin) {
2426
- log(sym.warn + " " + a.red + "--dangerously-skip-permissions requires a PIN." + a.reset);
2427
- log(a.dim + " Please set a PIN to use skip permissions mode." + a.reset);
2428
- process.exit(1);
2429
- return;
2430
- }
2431
-
2432
2446
  // Check ~/.clayrc for previous projects to restore
2433
2447
  var rc = loadClayrc();
2434
2448
  var restorable = (rc.recentProjects || []).filter(function (p) {
package/lib/config.js CHANGED
@@ -70,20 +70,23 @@ var CONFIG_DIR = CLAY_HOME;
70
70
  var CLAYRC_PATH = path.join(os.homedir(), ".clayrc");
71
71
  var CRASH_INFO_PATH = path.join(CONFIG_DIR, "crash.json");
72
72
 
73
+ // Dev mode uses separate daemon files so dev and prod can run simultaneously
74
+ var _devMode = !!process.env.CLAY_DEV;
75
+
73
76
  function configPath() {
74
- return path.join(CONFIG_DIR, "daemon.json");
77
+ return path.join(CONFIG_DIR, _devMode ? "daemon-dev.json" : "daemon.json");
75
78
  }
76
79
 
77
80
  function socketPath() {
78
81
  if (process.platform === "win32") {
79
- var pipeName = "clay-daemon";
82
+ var pipeName = _devMode ? "clay-daemon-dev" : "clay-daemon";
80
83
  return "\\\\.\\pipe\\" + pipeName;
81
84
  }
82
- return path.join(CONFIG_DIR, "daemon.sock");
85
+ return path.join(CONFIG_DIR, _devMode ? "daemon-dev.sock" : "daemon.sock");
83
86
  }
84
87
 
85
88
  function logPath() {
86
- return path.join(CONFIG_DIR, "daemon.log");
89
+ return path.join(CONFIG_DIR, _devMode ? "daemon-dev.log" : "daemon.log");
87
90
  }
88
91
 
89
92
  function ensureConfigDir() {
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
 
@@ -75,6 +76,10 @@ function createProjectContext(opts) {
75
76
  var lanHost = opts.lanHost || null;
76
77
  var getProjectCount = opts.getProjectCount || function () { return 1; };
77
78
  var getProjectList = opts.getProjectList || function () { return []; };
79
+ var getHubSchedules = opts.getHubSchedules || function () { return []; };
80
+ var moveScheduleToProject = opts.moveScheduleToProject || function () { return { ok: false, error: "Not supported" }; };
81
+ var moveAllSchedulesToProject = opts.moveAllSchedulesToProject || function () { return { ok: false, error: "Not supported" }; };
82
+ var getScheduleCount = opts.getScheduleCount || function () { return 0; };
78
83
  var onProcessingChanged = opts.onProcessingChanged || function () {};
79
84
  var latestVersion = null;
80
85
 
@@ -395,17 +400,90 @@ function createProjectContext(opts) {
395
400
  judgeReady: hasJudge,
396
401
  loopJsonReady: hasLoopJson,
397
402
  bothReady: hasPrompt && hasJudge,
403
+ taskId: loopState.loopId,
398
404
  });
399
405
  // Auto-transition to approval phase when both files appear
400
406
  if (hasPrompt && hasJudge && loopState.phase === "crafting") {
401
407
  loopState.phase = "approval";
402
408
  saveLoopState();
409
+
410
+ // Parse recommended title from crafting session conversation
411
+ if (loopState.craftingSessionId && loopState.loopId) {
412
+ var craftSess = sm.sessions.get(loopState.craftingSessionId);
413
+ if (craftSess && craftSess.history) {
414
+ for (var hi = craftSess.history.length - 1; hi >= 0; hi--) {
415
+ var entry = craftSess.history[hi];
416
+ var entryText = entry.text || "";
417
+ var titleMatch = entryText.match(/\[\[LOOP_TITLE:\s*(.+?)\]\]/);
418
+ if (titleMatch) {
419
+ var suggestedTitle = titleMatch[1].trim();
420
+ if (suggestedTitle) {
421
+ loopRegistry.updateRecord(loopState.loopId, { name: suggestedTitle });
422
+ }
423
+ break;
424
+ }
425
+ }
426
+ }
427
+ }
403
428
  }
404
429
  }
405
430
 
406
431
  // Load persisted state on startup
407
432
  loadLoopState();
408
433
 
434
+ // --- Loop Registry (unified one-off + scheduled) ---
435
+ var activeRegistryId = null; // track which registry record triggered current loop
436
+
437
+ var loopRegistry = createLoopRegistry({
438
+ cwd: cwd,
439
+ onTrigger: function (record) {
440
+ // Only trigger if no loop is currently active
441
+ if (loopState.active || loopState.phase === "executing") {
442
+ console.log("[loop-registry] Skipping trigger — loop already active");
443
+ return;
444
+ }
445
+
446
+ // For schedule records, resolve the linked task to get loop files
447
+ var loopId = record.id;
448
+ if (record.source === "schedule") {
449
+ if (!record.linkedTaskId) {
450
+ console.error("[loop-registry] Schedule has no linked task: " + record.name);
451
+ return;
452
+ }
453
+ loopId = record.linkedTaskId;
454
+ console.log("[loop-registry] Schedule triggered: " + record.name + " → linked task " + loopId);
455
+ }
456
+
457
+ // Verify the loop directory and files exist
458
+ var recDir = path.join(cwd, ".claude", "loops", loopId);
459
+ try {
460
+ fs.accessSync(path.join(recDir, "PROMPT.md"));
461
+ fs.accessSync(path.join(recDir, "JUDGE.md"));
462
+ } catch (e) {
463
+ console.error("[loop-registry] Loop files missing for " + loopId);
464
+ return;
465
+ }
466
+ // Set the loopId and start
467
+ loopState.loopId = loopId;
468
+ activeRegistryId = record.id;
469
+ console.log("[loop-registry] Auto-starting loop: " + record.name + " (" + loopId + ")");
470
+ send({ type: "schedule_run_started", recordId: record.id });
471
+ startLoop();
472
+ },
473
+ onChange: function () {
474
+ send({ type: "loop_registry_updated", records: getHubSchedules() });
475
+ },
476
+ });
477
+ loopRegistry.load();
478
+ loopRegistry.startTimer();
479
+
480
+ // Wire loop info resolution for session list broadcasts
481
+ sm.setResolveLoopInfo(function (loopId) {
482
+ var rec = loopRegistry.getById(loopId);
483
+ if (!rec) return null;
484
+ return { name: rec.name || null, source: rec.source || null };
485
+ });
486
+
409
487
  function startLoop(opts) {
410
488
  var loopOpts = opts || {};
411
489
  var dir = loopDir();
@@ -479,9 +557,12 @@ function createProjectContext(opts) {
479
557
  }
480
558
 
481
559
  var session = sm.createSession();
482
- session.loop = { active: true, iteration: loopState.iteration, role: "coder" };
483
560
  var loopName = (loopState.wizardData && loopState.wizardData.name) || "";
484
- session.title = "Ralph" + (loopName ? " " + loopName : "") + " #" + loopState.iteration;
561
+ var loopSource = loopRegistry.getById(loopState.loopId);
562
+ var loopSourceTag = (loopSource && loopSource.source) || null;
563
+ var isRalphLoop = loopSourceTag === "ralph";
564
+ session.loop = { active: true, iteration: loopState.iteration, role: "coder", loopId: loopState.loopId, name: loopName, source: loopSourceTag, startedAt: loopState.startedAt };
565
+ session.title = (isRalphLoop ? "Ralph" : "Task") + (loopName ? " " + loopName : "") + " #" + loopState.iteration;
485
566
  sm.saveSessionFile(session);
486
567
  sm.broadcastSessionList();
487
568
 
@@ -566,9 +647,12 @@ function createProjectContext(opts) {
566
647
  "Do NOT use any tools. Just analyze and respond.";
567
648
 
568
649
  var judgeSession = sm.createSession();
569
- judgeSession.loop = { active: true, iteration: loopState.iteration, role: "judge" };
570
650
  var judgeName = (loopState.wizardData && loopState.wizardData.name) || "";
571
- judgeSession.title = "Ralph" + (judgeName ? " " + judgeName : "") + " Judge #" + loopState.iteration;
651
+ var judgeSource = loopRegistry.getById(loopState.loopId);
652
+ var judgeSourceTag = (judgeSource && judgeSource.source) || null;
653
+ var isRalphJudge = judgeSourceTag === "ralph";
654
+ judgeSession.loop = { active: true, iteration: loopState.iteration, role: "judge", loopId: loopState.loopId, name: judgeName, source: judgeSourceTag, startedAt: loopState.startedAt };
655
+ judgeSession.title = (isRalphJudge ? "Ralph" : "Task") + (judgeName ? " " + judgeName : "") + " Judge #" + loopState.iteration;
572
656
  sm.saveSessionFile(judgeSession);
573
657
  sm.broadcastSessionList();
574
658
  loopState.judgeSessionId = judgeSession.localId;
@@ -654,6 +738,19 @@ function createProjectContext(opts) {
654
738
  results: loopState.results,
655
739
  });
656
740
 
741
+ // Record result in loop registry
742
+ if (loopState.loopId) {
743
+ loopRegistry.recordRun(loopState.loopId, {
744
+ reason: reason,
745
+ startedAt: loopState.startedAt,
746
+ iterations: loopState.iteration,
747
+ });
748
+ }
749
+ if (activeRegistryId) {
750
+ send({ type: "schedule_run_finished", recordId: activeRegistryId, reason: reason, iterations: loopState.iteration });
751
+ activeRegistryId = null;
752
+ }
753
+
657
754
  if (pushModule) {
658
755
  var body = reason === "pass"
659
756
  ? "Task completed after " + loopState.iteration + " iteration(s)"
@@ -773,6 +870,7 @@ function createProjectContext(opts) {
773
870
  sendTo(ws, { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
774
871
  sendTo(ws, { type: "term_list", terminals: tm.list() });
775
872
  sendTo(ws, { type: "notes_list", notes: nm.list() });
873
+ sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules() });
776
874
 
777
875
  // Ralph Loop availability
778
876
  var hasLoopFiles = false;
@@ -809,13 +907,22 @@ function createProjectContext(opts) {
809
907
  promptReady: _hasPrompt,
810
908
  judgeReady: _hasJudge,
811
909
  bothReady: _hasPrompt && _hasJudge,
910
+ taskId: loopState.loopId,
812
911
  });
813
912
  }
814
913
 
815
914
  // Session list
816
915
  sendTo(ws, {
817
916
  type: "session_list",
818
- sessions: [].concat(Array.from(sm.sessions.values())).map(function (s) {
917
+ sessions: [].concat(Array.from(sm.sessions.values())).filter(function (s) { return !s.hidden; }).map(function (s) {
918
+ var loop = s.loop ? Object.assign({}, s.loop) : null;
919
+ if (loop && loop.loopId && loopRegistry) {
920
+ var rec = loopRegistry.getById(loop.loopId);
921
+ if (rec) {
922
+ if (rec.name) loop.name = rec.name;
923
+ if (rec.source) loop.source = rec.source;
924
+ }
925
+ }
819
926
  return {
820
927
  id: s.localId,
821
928
  cliSessionId: s.cliSessionId || null,
@@ -823,6 +930,7 @@ function createProjectContext(opts) {
823
930
  active: s.localId === sm.activeSessionId,
824
931
  isProcessing: s.isProcessing,
825
932
  lastActivity: s.lastActivity || s.createdAt || 0,
933
+ loop: loop,
826
934
  };
827
935
  }),
828
936
  });
@@ -830,7 +938,7 @@ function createProjectContext(opts) {
830
938
  // Restore active session for this client
831
939
  var active = sm.getActiveSession();
832
940
  if (active) {
833
- sendTo(ws, { type: "session_switched", id: active.localId, cliSessionId: active.cliSessionId || null });
941
+ sendTo(ws, { type: "session_switched", id: active.localId, cliSessionId: active.cliSessionId || null, loop: active.loop || null });
834
942
 
835
943
  var total = active.history.length;
836
944
  var fromIndex = 0;
@@ -1475,6 +1583,18 @@ function createProjectContext(opts) {
1475
1583
  return;
1476
1584
  }
1477
1585
 
1586
+ // --- Pre-check: does the project have tasks/schedules? ---
1587
+ if (msg.type === "remove_project_check") {
1588
+ var checkSlug = msg.slug;
1589
+ if (!checkSlug) {
1590
+ sendTo(ws, { type: "remove_project_check_result", slug: checkSlug, name: msg.name || checkSlug, count: 0 });
1591
+ return;
1592
+ }
1593
+ var schedCount = getScheduleCount(checkSlug);
1594
+ sendTo(ws, { type: "remove_project_check_result", slug: checkSlug, name: msg.name || checkSlug, count: schedCount });
1595
+ return;
1596
+ }
1597
+
1478
1598
  // --- Remove project from web UI ---
1479
1599
  if (msg.type === "remove_project") {
1480
1600
  var removeSlug = msg.slug;
@@ -1482,6 +1602,10 @@ function createProjectContext(opts) {
1482
1602
  sendTo(ws, { type: "remove_project_result", ok: false, error: "Missing slug" });
1483
1603
  return;
1484
1604
  }
1605
+ // If client chose to move tasks to another project before removing
1606
+ if (msg.moveTasksTo) {
1607
+ moveAllSchedulesToProject(removeSlug, msg.moveTasksTo);
1608
+ }
1485
1609
  if (typeof opts.onRemoveProject === "function") {
1486
1610
  var removeResult = opts.onRemoveProject(removeSlug);
1487
1611
  sendTo(ws, { type: "remove_project_result", ok: removeResult.ok, slug: removeSlug, error: removeResult.error });
@@ -1491,6 +1615,17 @@ function createProjectContext(opts) {
1491
1615
  return;
1492
1616
  }
1493
1617
 
1618
+ // --- Move a single schedule to another project ---
1619
+ if (msg.type === "schedule_move") {
1620
+ var moveResult = moveScheduleToProject(msg.recordId, msg.fromSlug, msg.toSlug);
1621
+ if (moveResult.ok) {
1622
+ // Re-broadcast updated records to this project's clients
1623
+ send({ type: "loop_registry_updated", records: getHubSchedules() });
1624
+ }
1625
+ sendTo(ws, { type: "schedule_move_result", ok: moveResult.ok, error: moveResult.error });
1626
+ return;
1627
+ }
1628
+
1494
1629
  // --- Reorder projects ---
1495
1630
  if (msg.type === "reorder_projects") {
1496
1631
  var slugs = msg.slugs;
@@ -2019,6 +2154,16 @@ function createProjectContext(opts) {
2019
2154
  }
2020
2155
 
2021
2156
  if (msg.type === "loop_start") {
2157
+ // If this loop has a cron schedule, don't run immediately — just confirm registration
2158
+ if (loopState.wizardData && loopState.wizardData.cron) {
2159
+ loopState.active = false;
2160
+ loopState.phase = "done";
2161
+ saveLoopState();
2162
+ send({ type: "loop_finished", reason: "scheduled", iterations: 0, results: [] });
2163
+ send({ type: "ralph_phase", phase: "idle", wizardData: null });
2164
+ send({ type: "loop_scheduled", recordId: loopState.loopId, cron: loopState.wizardData.cron });
2165
+ return;
2166
+ }
2022
2167
  startLoop();
2023
2168
  return;
2024
2169
  }
@@ -2030,18 +2175,32 @@ function createProjectContext(opts) {
2030
2175
 
2031
2176
  if (msg.type === "ralph_wizard_complete") {
2032
2177
  var wData = msg.data || {};
2033
- var maxIter = wData.maxIterations || 25;
2178
+ var maxIter = wData.maxIterations || 3;
2179
+ var wizardCron = wData.cron || null;
2034
2180
  var newLoopId = generateLoopId();
2035
2181
  loopState.loopId = newLoopId;
2036
2182
  loopState.wizardData = {
2037
- name: (wData.name || "").replace(/[^a-zA-Z0-9_-]/g, "") || "ralph",
2183
+ name: wData.name || wData.task || "Untitled",
2038
2184
  task: wData.task || "",
2039
2185
  maxIterations: maxIter,
2186
+ cron: wizardCron,
2040
2187
  };
2041
2188
  loopState.phase = "crafting";
2042
2189
  loopState.startedAt = Date.now();
2043
2190
  saveLoopState();
2044
2191
 
2192
+ // Register in loop registry
2193
+ var recordSource = wData.source === "task" ? null : "ralph";
2194
+ loopRegistry.register({
2195
+ id: newLoopId,
2196
+ name: loopState.wizardData.name,
2197
+ task: wData.task || "",
2198
+ cron: wizardCron,
2199
+ enabled: wizardCron ? true : false,
2200
+ maxIterations: maxIter,
2201
+ source: recordSource,
2202
+ });
2203
+
2045
2204
  // Create loop directory and write LOOP.json
2046
2205
  var lDir = loopDir();
2047
2206
  try { fs.mkdirSync(lDir, { recursive: true }); } catch (e) {}
@@ -2051,23 +2210,31 @@ function createProjectContext(opts) {
2051
2210
  fs.renameSync(tmpLoopJson, loopJsonPath);
2052
2211
 
2053
2212
  // 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;
2213
+ var craftingPrompt = "Use the /clay-ralph skill to design a Ralph Loop for the following task. " +
2214
+ "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
2215
+ "Your job is to interview me, then create PROMPT.md and JUDGE.md files " +
2216
+ "that a future autonomous session will execute.\n\n" +
2217
+ "## Task\n" + (wData.task || "") +
2218
+ "\n\n## Loop Directory\n" + lDir;
2056
2219
 
2057
2220
  // Create a new session for crafting
2058
2221
  var craftingSession = sm.createSession();
2059
2222
  var craftName = (loopState.wizardData && loopState.wizardData.name) || "";
2060
- craftingSession.title = "Ralph" + (craftName ? " " + craftName : "") + " Crafting";
2223
+ var isRalphCraft = recordSource === "ralph";
2224
+ craftingSession.title = (isRalphCraft ? "Ralph" : "Task") + (craftName ? " " + craftName : "") + " Crafting";
2061
2225
  craftingSession.ralphCraftingMode = true;
2226
+ craftingSession.loop = { active: true, iteration: 0, role: "crafting", loopId: newLoopId, name: craftName, source: recordSource };
2062
2227
  sm.saveSessionFile(craftingSession);
2063
2228
  sm.switchSession(craftingSession.localId);
2064
- sm.broadcastSessionList();
2065
2229
  loopState.craftingSessionId = craftingSession.localId;
2066
2230
 
2231
+ // Store crafting session ID in the registry record
2232
+ loopRegistry.updateRecord(newLoopId, { craftingSessionId: craftingSession.localId });
2233
+
2067
2234
  // Start .claude/ directory watcher
2068
2235
  startClaudeDirWatch();
2069
2236
 
2070
- // Start query
2237
+ // Send crafting prompt and start the conversation with Claude.
2071
2238
  craftingSession.history.push({ type: "user_message", text: craftingPrompt });
2072
2239
  sm.appendToSessionFile(craftingSession, { type: "user_message", text: craftingPrompt });
2073
2240
  send({ type: "user_message", text: craftingPrompt });
@@ -2077,11 +2244,27 @@ function createProjectContext(opts) {
2077
2244
  send({ type: "status", status: "processing" });
2078
2245
  sdk.startQuery(craftingSession, craftingPrompt);
2079
2246
 
2080
- send({ type: "ralph_crafting_started", sessionId: craftingSession.localId });
2247
+ send({ type: "ralph_crafting_started", sessionId: craftingSession.localId, taskId: newLoopId, source: recordSource });
2081
2248
  send({ type: "ralph_phase", phase: "crafting", wizardData: loopState.wizardData, craftingSessionId: craftingSession.localId });
2082
2249
  return;
2083
2250
  }
2084
2251
 
2252
+ if (msg.type === "loop_registry_files") {
2253
+ var recId = msg.id;
2254
+ var lDir = path.join(cwd, ".claude", "loops", recId);
2255
+ var promptContent = "";
2256
+ var judgeContent = "";
2257
+ try { promptContent = fs.readFileSync(path.join(lDir, "PROMPT.md"), "utf8"); } catch (e) {}
2258
+ try { judgeContent = fs.readFileSync(path.join(lDir, "JUDGE.md"), "utf8"); } catch (e) {}
2259
+ send({
2260
+ type: "loop_registry_files_content",
2261
+ id: recId,
2262
+ prompt: promptContent,
2263
+ judge: judgeContent,
2264
+ });
2265
+ return;
2266
+ }
2267
+
2085
2268
  if (msg.type === "ralph_preview_files") {
2086
2269
  var promptContent = "";
2087
2270
  var judgeContent = "";
@@ -2129,6 +2312,122 @@ function createProjectContext(opts) {
2129
2312
  return;
2130
2313
  }
2131
2314
 
2315
+ // --- Schedule create (from calendar click) ---
2316
+ if (msg.type === "schedule_create") {
2317
+ var sData = msg.data || {};
2318
+ var newRec = loopRegistry.register({
2319
+ name: sData.name || "Untitled",
2320
+ task: sData.name || "",
2321
+ description: sData.description || "",
2322
+ date: sData.date || null,
2323
+ time: sData.time || null,
2324
+ allDay: sData.allDay !== undefined ? sData.allDay : true,
2325
+ linkedTaskId: sData.taskId || null,
2326
+ cron: sData.cron || null,
2327
+ enabled: sData.cron ? (sData.enabled !== false) : false,
2328
+ maxIterations: sData.maxIterations || 3,
2329
+ source: "schedule",
2330
+ color: sData.color || null,
2331
+ recurrenceEnd: sData.recurrenceEnd || null,
2332
+ });
2333
+ return;
2334
+ }
2335
+
2336
+ // --- Hub: cross-project schedule aggregation ---
2337
+ if (msg.type === "hub_schedules_list") {
2338
+ sendTo(ws, { type: "hub_schedules", schedules: getHubSchedules() });
2339
+ return;
2340
+ }
2341
+
2342
+ // --- Loop Registry messages ---
2343
+ if (msg.type === "loop_registry_list") {
2344
+ sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules() });
2345
+ return;
2346
+ }
2347
+
2348
+ if (msg.type === "loop_registry_update") {
2349
+ var updatedRec = loopRegistry.update(msg.id, msg.data || {});
2350
+ if (!updatedRec) {
2351
+ sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
2352
+ }
2353
+ return;
2354
+ }
2355
+
2356
+ if (msg.type === "loop_registry_rename") {
2357
+ if (msg.id && msg.name) {
2358
+ loopRegistry.updateRecord(msg.id, { name: String(msg.name).substring(0, 100) });
2359
+ sm.broadcastSessionList();
2360
+ }
2361
+ return;
2362
+ }
2363
+
2364
+ if (msg.type === "loop_registry_remove") {
2365
+ var removedRec = loopRegistry.remove(msg.id);
2366
+ if (!removedRec) {
2367
+ sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
2368
+ }
2369
+ return;
2370
+ }
2371
+
2372
+ if (msg.type === "loop_registry_convert") {
2373
+ // Convert ralph source to regular task (remove source tag)
2374
+ if (msg.id) {
2375
+ loopRegistry.updateRecord(msg.id, { source: null });
2376
+ sm.broadcastSessionList();
2377
+ }
2378
+ return;
2379
+ }
2380
+
2381
+ if (msg.type === "delete_loop_group") {
2382
+ // Delete all sessions belonging to this loopId, then remove registry record
2383
+ var loopIdToDel = msg.loopId;
2384
+ if (!loopIdToDel) return;
2385
+ var sessionIds = [];
2386
+ sm.sessions.forEach(function (s, lid) {
2387
+ if (s.loop && s.loop.loopId === loopIdToDel) sessionIds.push(lid);
2388
+ });
2389
+ for (var di = 0; di < sessionIds.length; di++) {
2390
+ sm.deleteSessionQuiet(sessionIds[di]);
2391
+ }
2392
+ loopRegistry.remove(loopIdToDel);
2393
+ sm.broadcastSessionList();
2394
+ return;
2395
+ }
2396
+
2397
+ if (msg.type === "loop_registry_toggle") {
2398
+ var toggledRec = loopRegistry.toggleEnabled(msg.id);
2399
+ if (!toggledRec) {
2400
+ sendTo(ws, { type: "loop_registry_error", text: "Record not found or not scheduled" });
2401
+ }
2402
+ return;
2403
+ }
2404
+
2405
+ if (msg.type === "loop_registry_rerun") {
2406
+ // Re-run an existing job (one-off from library)
2407
+ if (loopState.active || loopState.phase === "executing") {
2408
+ sendTo(ws, { type: "loop_registry_error", text: "A loop is already running" });
2409
+ return;
2410
+ }
2411
+ var rerunRec = loopRegistry.getById(msg.id);
2412
+ if (!rerunRec) {
2413
+ sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
2414
+ return;
2415
+ }
2416
+ var rerunDir = path.join(cwd, ".claude", "loops", rerunRec.id);
2417
+ try {
2418
+ fs.accessSync(path.join(rerunDir, "PROMPT.md"));
2419
+ fs.accessSync(path.join(rerunDir, "JUDGE.md"));
2420
+ } catch (e) {
2421
+ sendTo(ws, { type: "loop_registry_error", text: "Loop files missing for " + rerunRec.id });
2422
+ return;
2423
+ }
2424
+ loopState.loopId = rerunRec.id;
2425
+ activeRegistryId = null; // not a scheduled trigger
2426
+ send({ type: "loop_rerun_started", recordId: rerunRec.id });
2427
+ startLoop();
2428
+ return;
2429
+ }
2430
+
2132
2431
  if (msg.type !== "message") return;
2133
2432
  if (!msg.text && (!msg.images || msg.images.length === 0) && (!msg.pastes || msg.pastes.length === 0)) return;
2134
2433
 
@@ -2491,6 +2790,7 @@ function createProjectContext(opts) {
2491
2790
 
2492
2791
  // --- Destroy ---
2493
2792
  function destroy() {
2793
+ loopRegistry.stopTimer();
2494
2794
  stopFileWatch();
2495
2795
  stopAllDirWatches();
2496
2796
  // Abort all active sessions
@@ -2558,9 +2858,37 @@ function createProjectContext(opts) {
2558
2858
  handleDisconnection: handleDisconnection,
2559
2859
  handleHTTP: handleHTTP,
2560
2860
  getStatus: getStatus,
2861
+ getSchedules: function () { return loopRegistry.getAll(); },
2862
+ importSchedule: function (data) { return loopRegistry.register(data); },
2863
+ removeSchedule: function (id) { return loopRegistry.remove(id); },
2561
2864
  setTitle: setTitle,
2562
2865
  setIcon: setIcon,
2563
- warmup: function () { sdk.warmup(); },
2866
+ warmup: function () {
2867
+ sdk.warmup();
2868
+ // Auto-install clay-ralph skill globally if not present
2869
+ var clayRalphDir = path.join(os.homedir(), ".claude", "skills", "clay-ralph", "SKILL.md");
2870
+ try {
2871
+ fs.accessSync(clayRalphDir, fs.constants.R_OK);
2872
+ } catch (e) {
2873
+ console.log("[project] Auto-installing clay-ralph skill...");
2874
+ var child = spawn("npx", ["skills", "add", "https://github.com/chadbyte/clay-ralph", "--skill", "clay-ralph"], {
2875
+ cwd: os.homedir(),
2876
+ stdio: "ignore",
2877
+ detached: false,
2878
+ });
2879
+ child.on("close", function (code) {
2880
+ if (code === 0) {
2881
+ console.log("[project] clay-ralph skill installed successfully");
2882
+ send({ type: "skill_installed", skill: "clay-ralph", scope: "global", success: true, error: null });
2883
+ } else {
2884
+ console.log("[project] clay-ralph skill install failed (code " + code + ")");
2885
+ }
2886
+ });
2887
+ child.on("error", function (err) {
2888
+ console.log("[project] clay-ralph skill install error: " + err.message);
2889
+ });
2890
+ }
2891
+ },
2564
2892
  destroy: destroy,
2565
2893
  };
2566
2894
  }