clay-server 2.13.0-beta.3 → 2.13.0-beta.5

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 CHANGED
@@ -32,6 +32,7 @@ if (_isDev || process.argv.includes("--debug")) {
32
32
  console.clear = function() {};
33
33
  }
34
34
 
35
+ var crypto = require("crypto");
35
36
  var { loadConfig, saveConfig, configPath, socketPath, logPath, ensureConfigDir, isDaemonAlive, isDaemonAliveAsync, generateSlug, clearStaleConfig, loadClayrc, saveClayrc, readCrashInfo } = require("../lib/config");
36
37
  var { sendIPCCommand } = require("../lib/ipc");
37
38
  var { generateAuthToken } = require("../lib/server");
@@ -2450,6 +2451,9 @@ function showSettingsMenu(config, ip) {
2450
2451
  } else {
2451
2452
  items.push({ label: "Enable multi-user mode", value: "multi_user" });
2452
2453
  }
2454
+ if (muEnabled && hasAdmin()) {
2455
+ items.push({ label: "Recover admin password", value: "recover_admin" });
2456
+ }
2453
2457
  if (process.platform === "darwin") {
2454
2458
  items.push({ label: isAwake ? "Disable keep awake" : "Enable keep awake", value: "awake" });
2455
2459
  }
@@ -2744,6 +2748,48 @@ function showSettingsMenu(config, ip) {
2744
2748
  });
2745
2749
  break;
2746
2750
 
2751
+ case "recover_admin": {
2752
+ var recoveryUrlPath = crypto.randomBytes(16).toString("hex");
2753
+ var recoveryPassword = crypto.randomBytes(8).toString("base64url");
2754
+ sendIPCCommand(socketPath(), { cmd: "enable_recovery", urlPath: recoveryUrlPath, password: recoveryPassword }).then(function (res) {
2755
+ if (!res.ok) {
2756
+ log(sym.bar + " " + a.red + "Failed to enable recovery mode." + a.reset);
2757
+ log(sym.bar);
2758
+ showSettingsMenu(config, ip);
2759
+ return;
2760
+ }
2761
+ var protocol = config.tls ? "https" : "http";
2762
+ var recoveryUrl = protocol + "://" + ip + ":" + config.port + "/recover/" + recoveryUrlPath;
2763
+ log(sym.bar);
2764
+ log(sym.bar + " " + a.yellow + sym.warn + " Admin Password Recovery" + a.reset);
2765
+ log(sym.bar);
2766
+ log(sym.bar + " " + a.dim + "Recovery URL:" + a.reset);
2767
+ log(sym.bar + " " + a.bold + recoveryUrl + a.reset);
2768
+ log(sym.bar);
2769
+ log(sym.bar + " " + a.dim + "Recovery password:" + a.reset);
2770
+ log(sym.bar + " " + a.bold + recoveryPassword + a.reset);
2771
+ log(sym.bar);
2772
+ log(sym.bar + " " + a.dim + "Open the URL in a browser and enter the password above." + a.reset);
2773
+ log(sym.bar + " " + a.dim + "This link is single-use and will expire when the PIN is reset." + a.reset);
2774
+ log(sym.bar);
2775
+ promptSelect("Done?", [
2776
+ { label: "Disable recovery link", value: "disable" },
2777
+ { label: "Back (keep link active)", value: "back" },
2778
+ ], function (rc) {
2779
+ if (rc === "disable") {
2780
+ sendIPCCommand(socketPath(), { cmd: "disable_recovery" }).then(function () {
2781
+ log(sym.done + " " + a.dim + "Recovery link disabled." + a.reset);
2782
+ log("");
2783
+ showSettingsMenu(config, ip);
2784
+ });
2785
+ } else {
2786
+ showSettingsMenu(config, ip);
2787
+ }
2788
+ });
2789
+ });
2790
+ break;
2791
+ }
2792
+
2747
2793
  case "back":
2748
2794
  showMainMenu(config, ip);
2749
2795
  break;
package/lib/daemon.js CHANGED
@@ -793,7 +793,7 @@ var relay = createServer({
793
793
  if (!config.osUsers || !linuxUser) return;
794
794
  deactivateLinuxUser(linuxUser);
795
795
  },
796
- onCreateWorktree: function (parentSlug, branchName, baseBranch) {
796
+ onCreateWorktree: function (parentSlug, branchName, dirName, baseBranch) {
797
797
  // Find the parent project
798
798
  var parent = null;
799
799
  for (var j = 0; j < config.projects.length; j++) {
@@ -801,10 +801,10 @@ var relay = createServer({
801
801
  }
802
802
  if (!parent) return { ok: false, error: "Parent project not found" };
803
803
  if (isWorktree(parent.path)) return { ok: false, error: "Cannot create worktrees from a worktree project" };
804
- var result = createWorktree(parent.path, branchName, baseBranch);
804
+ var result = createWorktree(parent.path, branchName, dirName, baseBranch);
805
805
  if (!result.ok) return result;
806
806
  // Register the new worktree as ephemeral project
807
- var wtSlug = parentSlug + "--" + branchName;
807
+ var wtSlug = parentSlug + "--" + dirName;
808
808
  var wtMeta = { parentSlug: parentSlug, branch: branchName, accessible: true };
809
809
  relay.addProject(result.path, wtSlug, branchName, parent.icon, parent.ownerId, wtMeta);
810
810
  if (!worktreeRegistry[parentSlug]) worktreeRegistry[parentSlug] = [];
@@ -1124,6 +1124,19 @@ var ipc = createIPCServer(socketPath(), function (msg) {
1124
1124
  return { ok: true };
1125
1125
  }
1126
1126
 
1127
+ case "enable_recovery": {
1128
+ if (!msg.urlPath || !msg.password) return { ok: false, error: "missing urlPath or password" };
1129
+ relay.setRecovery(msg.urlPath, msg.password);
1130
+ console.log("[daemon] Admin recovery mode enabled");
1131
+ return { ok: true };
1132
+ }
1133
+
1134
+ case "disable_recovery": {
1135
+ relay.clearRecovery();
1136
+ console.log("[daemon] Admin recovery mode disabled");
1137
+ return { ok: true };
1138
+ }
1139
+
1127
1140
  case "shutdown":
1128
1141
  console.log("[daemon] Shutdown requested via IPC");
1129
1142
  gracefulShutdown();
package/lib/notes.js CHANGED
@@ -67,7 +67,7 @@ function createNotesManager(opts) {
67
67
  function update(id, changes) {
68
68
  for (var i = 0; i < notes.length; i++) {
69
69
  if (notes[i].id === id) {
70
- var allowed = ["text", "x", "y", "w", "h", "color", "minimized", "hidden", "zIndex"];
70
+ var allowed = ["text", "x", "y", "w", "h", "color", "minimized", "hidden", "zIndex", "opacity"];
71
71
  for (var j = 0; j < allowed.length; j++) {
72
72
  var key = allowed[j];
73
73
  if (changes[key] !== undefined) {
package/lib/project.js CHANGED
@@ -626,13 +626,12 @@ function createProjectContext(opts) {
626
626
  console.log("[loop-registry] Schedule triggered: " + record.name + " → linked task " + loopId);
627
627
  }
628
628
 
629
- // Verify the loop directory and files exist
629
+ // Verify the loop directory and PROMPT.md exist
630
630
  var recDir = path.join(cwd, ".claude", "loops", loopId);
631
631
  try {
632
632
  fs.accessSync(path.join(recDir, "PROMPT.md"));
633
- fs.accessSync(path.join(recDir, "JUDGE.md"));
634
633
  } catch (e) {
635
- console.error("[loop-registry] Loop files missing for " + loopId);
634
+ console.error("[loop-registry] PROMPT.md missing for " + loopId);
636
635
  return;
637
636
  }
638
637
  // Set the loopId and start
@@ -675,8 +674,7 @@ function createProjectContext(opts) {
675
674
  try {
676
675
  judgeText = fs.readFileSync(judgePath, "utf8");
677
676
  } catch (e) {
678
- send({ type: "loop_error", text: "Missing JUDGE.md in " + dir });
679
- return;
677
+ judgeText = null;
680
678
  }
681
679
 
682
680
  var baseCommit;
@@ -700,7 +698,7 @@ function createProjectContext(opts) {
700
698
  loopState.promptText = promptText;
701
699
  loopState.judgeText = judgeText;
702
700
  loopState.iteration = 0;
703
- loopState.maxIterations = loopConfig.maxIterations || loopOpts.maxIterations || 20;
701
+ loopState.maxIterations = judgeText ? (loopConfig.maxIterations || loopOpts.maxIterations || 20) : 1;
704
702
  loopState.baseCommit = baseCommit;
705
703
  loopState.currentSessionId = null;
706
704
  loopState.judgeSessionId = null;
@@ -747,7 +745,11 @@ function createProjectContext(opts) {
747
745
  sessionId: session.localId,
748
746
  });
749
747
 
748
+ var coderCompleted = false;
750
749
  session.onQueryComplete = function(completedSession) {
750
+ if (coderCompleted) return;
751
+ coderCompleted = true;
752
+ if (coderWatchdog) { clearTimeout(coderWatchdog); coderWatchdog = null; }
751
753
  console.log("[ralph-loop] Coder #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
752
754
  if (!loopState.active) { console.log("[ralph-loop] Coder: loopState.active is false, skipping"); return; }
753
755
  // Check if session ended with error
@@ -774,9 +776,33 @@ function createProjectContext(opts) {
774
776
  setTimeout(function() { runNextIteration(); }, 2000);
775
777
  return;
776
778
  }
777
- runJudge();
779
+ if (loopState.judgeText) {
780
+ runJudge();
781
+ } else {
782
+ finishLoop("pass");
783
+ }
778
784
  };
779
785
 
786
+ // Watchdog: if onQueryComplete hasn't fired after 10 minutes, force error and retry
787
+ var coderWatchdog = setTimeout(function() {
788
+ if (!coderCompleted && loopState.active && !loopState.stopping) {
789
+ console.error("[ralph-loop] Coder #" + loopState.iteration + " watchdog triggered — onQueryComplete never fired");
790
+ coderCompleted = true;
791
+ loopState.results.push({
792
+ iteration: loopState.iteration,
793
+ verdict: "error",
794
+ summary: "Coder session timed out (no completion signal)",
795
+ });
796
+ send({
797
+ type: "loop_verdict",
798
+ iteration: loopState.iteration,
799
+ verdict: "error",
800
+ summary: "Coder session timed out, retrying...",
801
+ });
802
+ setTimeout(function() { runNextIteration(); }, 2000);
803
+ }
804
+ }, 10 * 60 * 1000);
805
+
780
806
  var userMsg = { type: "user_message", text: loopState.promptText };
781
807
  session.history.push(userMsg);
782
808
  sm.appendToSessionFile(session, userMsg);
@@ -835,7 +861,11 @@ function createProjectContext(opts) {
835
861
  sessionId: judgeSession.localId,
836
862
  });
837
863
 
864
+ var judgeCompleted = false;
838
865
  judgeSession.onQueryComplete = function(completedSession) {
866
+ if (judgeCompleted) return;
867
+ judgeCompleted = true;
868
+ if (judgeWatchdog) { clearTimeout(judgeWatchdog); judgeWatchdog = null; }
839
869
  console.log("[ralph-loop] Judge #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
840
870
  var verdict = parseJudgeVerdict(completedSession);
841
871
  console.log("[ralph-loop] Judge verdict: " + (verdict.pass ? "PASS" : "FAIL") + " - " + verdict.explanation);
@@ -860,6 +890,26 @@ function createProjectContext(opts) {
860
890
  }
861
891
  };
862
892
 
893
+ // Watchdog: judge should complete quickly (no tool use), 3 minutes is generous
894
+ var judgeWatchdog = setTimeout(function() {
895
+ if (!judgeCompleted && loopState.active && !loopState.stopping) {
896
+ console.error("[ralph-loop] Judge #" + loopState.iteration + " watchdog triggered — onQueryComplete never fired");
897
+ judgeCompleted = true;
898
+ loopState.results.push({
899
+ iteration: loopState.iteration,
900
+ verdict: "error",
901
+ summary: "Judge session timed out (no completion signal)",
902
+ });
903
+ send({
904
+ type: "loop_verdict",
905
+ iteration: loopState.iteration,
906
+ verdict: "error",
907
+ summary: "Judge session timed out, retrying...",
908
+ });
909
+ setTimeout(function() { runNextIteration(); }, 2000);
910
+ }
911
+ }, 3 * 60 * 1000);
912
+
863
913
  var userMsg = { type: "user_message", text: judgePrompt };
864
914
  judgeSession.history.push(userMsg);
865
915
  sm.appendToSessionFile(judgeSession, userMsg);
@@ -1461,6 +1511,13 @@ function createProjectContext(opts) {
1461
1511
  }
1462
1512
 
1463
1513
  if (msg.type === "delete_session") {
1514
+ if (ws._clayUser) {
1515
+ var sdPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
1516
+ if (!sdPerms.sessionDelete) {
1517
+ sendTo(ws, { type: "error", text: "You do not have permission to delete sessions" });
1518
+ return;
1519
+ }
1520
+ }
1464
1521
  if (msg.id && sm.sessions.has(msg.id)) {
1465
1522
  sm.deleteSession(msg.id, ws);
1466
1523
  }
@@ -1781,7 +1838,8 @@ function createProjectContext(opts) {
1781
1838
  session.messageUUIDs = session.messageUUIDs.slice(0, targetIdx);
1782
1839
  }
1783
1840
 
1784
- session.lastRewindUuid = msg.uuid;
1841
+ var kept = session.messageUUIDs;
1842
+ session.lastRewindUuid = kept.length > 0 ? kept[kept.length - 1].uuid : null;
1785
1843
  }
1786
1844
 
1787
1845
  if (session.abortController) {
@@ -1893,6 +1951,7 @@ function createProjectContext(opts) {
1893
1951
  var pending = session.pendingPermissions[requestId];
1894
1952
  if (!pending) return;
1895
1953
  delete session.pendingPermissions[requestId];
1954
+ onProcessingChanged(); // update cross-project permission badge
1896
1955
 
1897
1956
  // --- Plan approval: "allow_accept_edits" — approve + switch to acceptEdits mode ---
1898
1957
  if (decision === "allow_accept_edits") {
@@ -2103,6 +2162,15 @@ function createProjectContext(opts) {
2103
2162
  }
2104
2163
 
2105
2164
  // --- Create new empty project ---
2165
+ if (msg.type === "create_project" || msg.type === "clone_project") {
2166
+ if (ws._clayUser) {
2167
+ var cpPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2168
+ if (!cpPerms.createProject) {
2169
+ sendTo(ws, { type: "add_project_result", ok: false, error: "You do not have permission to create projects" });
2170
+ return;
2171
+ }
2172
+ }
2173
+ }
2106
2174
  if (msg.type === "create_project") {
2107
2175
  var createName = (msg.name || "").trim();
2108
2176
  if (!createName || !/^[a-zA-Z0-9_-]+$/.test(createName)) {
@@ -2139,13 +2207,14 @@ function createProjectContext(opts) {
2139
2207
  // --- Create worktree from web UI ---
2140
2208
  if (msg.type === "create_worktree") {
2141
2209
  var wtBranch = (msg.branch || "").trim();
2210
+ var wtDirName = (msg.dirName || "").trim() || wtBranch.replace(/\//g, "-");
2142
2211
  var wtBase = (msg.baseBranch || "").trim() || null;
2143
2212
  if (!wtBranch || !/^[a-zA-Z0-9_\/.@-]+$/.test(wtBranch)) {
2144
2213
  sendTo(ws, { type: "create_worktree_result", ok: false, error: "Invalid branch name" });
2145
2214
  return;
2146
2215
  }
2147
2216
  if (typeof onCreateWorktree === "function") {
2148
- var wtResult = onCreateWorktree(slug, wtBranch, wtBase);
2217
+ var wtResult = onCreateWorktree(slug, wtBranch, wtDirName, wtBase);
2149
2218
  sendTo(ws, { type: "create_worktree_result", ok: wtResult.ok, slug: wtResult.slug, error: wtResult.error });
2150
2219
  } else {
2151
2220
  sendTo(ws, { type: "create_worktree_result", ok: false, error: "Not supported" });
@@ -2167,6 +2236,13 @@ function createProjectContext(opts) {
2167
2236
 
2168
2237
  // --- Remove project from web UI ---
2169
2238
  if (msg.type === "remove_project") {
2239
+ if (ws._clayUser) {
2240
+ var dpPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2241
+ if (!dpPerms.deleteProject) {
2242
+ sendTo(ws, { type: "remove_project_result", ok: false, error: "You do not have permission to delete projects" });
2243
+ return;
2244
+ }
2245
+ }
2170
2246
  var removeSlug = msg.slug;
2171
2247
  if (!removeSlug) {
2172
2248
  sendTo(ws, { type: "remove_project_result", ok: false, error: "Missing slug" });
@@ -2310,6 +2386,15 @@ function createProjectContext(opts) {
2310
2386
  }
2311
2387
 
2312
2388
  // --- File browser ---
2389
+ if (msg.type === "fs_list" || msg.type === "fs_read" || msg.type === "fs_write" || msg.type === "fs_delete" || msg.type === "fs_rename" || msg.type === "fs_mkdir" || msg.type === "fs_upload") {
2390
+ if (ws._clayUser) {
2391
+ var fbPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2392
+ if (!fbPerms.fileBrowser) {
2393
+ sendTo(ws, { type: msg.type + "_result", error: "File browser access is not permitted" });
2394
+ return;
2395
+ }
2396
+ }
2397
+ }
2313
2398
  if (msg.type === "fs_list") {
2314
2399
  var fsDir = safePath(cwd, msg.path || ".");
2315
2400
  if (!fsDir) {
@@ -2418,6 +2503,20 @@ function createProjectContext(opts) {
2418
2503
  return;
2419
2504
  }
2420
2505
 
2506
+ // --- Project settings permission gate ---
2507
+ if (msg.type === "get_project_env" || msg.type === "set_project_env" ||
2508
+ msg.type === "read_global_claude_md" || msg.type === "write_global_claude_md" ||
2509
+ msg.type === "get_shared_env" || msg.type === "set_shared_env" ||
2510
+ msg.type === "transfer_project_owner") {
2511
+ if (ws._clayUser) {
2512
+ var psPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2513
+ if (!psPerms.projectSettings) {
2514
+ sendTo(ws, { type: "error", text: "Project settings access is not permitted" });
2515
+ return;
2516
+ }
2517
+ }
2518
+ }
2519
+
2421
2520
  // --- Project environment variables ---
2422
2521
  if (msg.type === "get_project_env") {
2423
2522
  var envrc = "";
@@ -2735,6 +2834,13 @@ function createProjectContext(opts) {
2735
2834
 
2736
2835
  // --- Web terminal ---
2737
2836
  if (msg.type === "term_create") {
2837
+ if (ws._clayUser) {
2838
+ var termPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2839
+ if (!termPerms.terminal) {
2840
+ sendTo(ws, { type: "term_error", error: "Terminal access is not permitted" });
2841
+ return;
2842
+ }
2843
+ }
2738
2844
  var t = tm.create(msg.cols || 80, msg.rows || 24, getOsUserInfoForWs(ws));
2739
2845
  if (!t) {
2740
2846
  sendTo(ws, { type: "term_error", error: "Cannot create terminal (node-pty not available or limit reached)" });
@@ -2784,6 +2890,20 @@ function createProjectContext(opts) {
2784
2890
  return;
2785
2891
  }
2786
2892
 
2893
+ // --- Scheduled tasks permission gate ---
2894
+ if (msg.type === "loop_start" || msg.type === "loop_stop" || msg.type === "loop_registry_files" ||
2895
+ msg.type === "loop_registry_list" || msg.type === "loop_registry_update" || msg.type === "loop_registry_rename" ||
2896
+ msg.type === "loop_registry_remove" || msg.type === "loop_registry_convert" || msg.type === "loop_registry_toggle" ||
2897
+ msg.type === "loop_registry_rerun" || msg.type === "schedule_create" || msg.type === "schedule_move") {
2898
+ if (ws._clayUser) {
2899
+ var schPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2900
+ if (!schPerms.scheduledTasks) {
2901
+ sendTo(ws, { type: "error", text: "Scheduled tasks access is not permitted" });
2902
+ return;
2903
+ }
2904
+ }
2905
+ }
2906
+
2787
2907
  if (msg.type === "loop_start") {
2788
2908
  // If this loop has a cron schedule, don't run immediately — just confirm registration
2789
2909
  if (loopState.wizardData && loopState.wizardData.cron) {
@@ -2840,7 +2960,39 @@ function createProjectContext(opts) {
2840
2960
  fs.writeFileSync(tmpLoopJson, JSON.stringify({ maxIterations: maxIter }, null, 2));
2841
2961
  fs.renameSync(tmpLoopJson, loopJsonPath);
2842
2962
 
2843
- // Assemble prompt for clay-ralph skill (include loop dir path so skill knows where to write)
2963
+ var craftName = (loopState.wizardData && loopState.wizardData.name) || "";
2964
+ var isRalphCraft = recordSource === "ralph";
2965
+
2966
+ // User provided their own PROMPT.md (and optionally JUDGE.md)
2967
+ if (wData.mode === "own" && wData.promptText) {
2968
+ // Write PROMPT.md
2969
+ var promptPath = path.join(lDir, "PROMPT.md");
2970
+ var tmpPrompt = promptPath + ".tmp";
2971
+ fs.writeFileSync(tmpPrompt, wData.promptText);
2972
+ fs.renameSync(tmpPrompt, promptPath);
2973
+
2974
+ if (wData.judgeText) {
2975
+ // Both provided: write JUDGE.md too
2976
+ var judgePath = path.join(lDir, "JUDGE.md");
2977
+ var tmpJudge = judgePath + ".tmp";
2978
+ fs.writeFileSync(tmpJudge, wData.judgeText);
2979
+ fs.renameSync(tmpJudge, judgePath);
2980
+ } else {
2981
+ // No judge: force single iteration
2982
+ var singleJson = loopJsonPath + ".tmp2";
2983
+ fs.writeFileSync(singleJson, JSON.stringify({ maxIterations: 1 }, null, 2));
2984
+ fs.renameSync(singleJson, loopJsonPath);
2985
+ }
2986
+
2987
+ // Go straight to approval (no crafting needed)
2988
+ loopState.phase = "approval";
2989
+ saveLoopState();
2990
+ send({ type: "ralph_phase", phase: "approval", wizardData: loopState.wizardData });
2991
+ send({ type: "ralph_files_status", promptReady: true, judgeReady: !!wData.judgeText, bothReady: !!wData.judgeText });
2992
+ return;
2993
+ }
2994
+
2995
+ // Default: "draft" mode — Clay crafts both PROMPT.md and JUDGE.md
2844
2996
  var craftingPrompt = "Use the /clay-ralph skill to design a Ralph Loop for the following task. " +
2845
2997
  "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
2846
2998
  "Your job is to interview me, then create PROMPT.md and JUDGE.md files " +
@@ -2850,11 +3002,9 @@ function createProjectContext(opts) {
2850
3002
 
2851
3003
  // Create a new session for crafting
2852
3004
  var craftingSession = sm.createSession();
2853
- var craftName = (loopState.wizardData && loopState.wizardData.name) || "";
2854
- var isRalphCraft = recordSource === "ralph";
2855
3005
  craftingSession.title = (isRalphCraft ? "Ralph" : "Task") + (craftName ? " " + craftName : "") + " Crafting";
2856
3006
  craftingSession.ralphCraftingMode = true;
2857
- craftingSession.loop = { active: true, iteration: 0, role: "crafting", loopId: newLoopId, name: craftName, source: recordSource };
3007
+ craftingSession.loop = { active: true, iteration: 0, role: "crafting", loopId: newLoopId, name: craftName, source: recordSource, startedAt: loopState.startedAt };
2858
3008
  sm.saveSessionFile(craftingSession);
2859
3009
  sm.switchSession(craftingSession.localId);
2860
3010
  loopState.craftingSessionId = craftingSession.localId;
@@ -3047,9 +3197,8 @@ function createProjectContext(opts) {
3047
3197
  var rerunDir = path.join(cwd, ".claude", "loops", rerunRec.id);
3048
3198
  try {
3049
3199
  fs.accessSync(path.join(rerunDir, "PROMPT.md"));
3050
- fs.accessSync(path.join(rerunDir, "JUDGE.md"));
3051
3200
  } catch (e) {
3052
- sendTo(ws, { type: "loop_registry_error", text: "Loop files missing for " + rerunRec.id });
3201
+ sendTo(ws, { type: "loop_registry_error", text: "PROMPT.md missing for " + rerunRec.id });
3053
3202
  return;
3054
3203
  }
3055
3204
  loopState.loopId = rerunRec.id;
@@ -3304,6 +3453,18 @@ function createProjectContext(opts) {
3304
3453
  return true;
3305
3454
  }
3306
3455
 
3456
+ // Skills permission gate
3457
+ if (urlPath === "/api/install-skill" || urlPath === "/api/uninstall-skill" || urlPath === "/api/installed-skills") {
3458
+ if (req._clayUser) {
3459
+ var skPerms = usersModule.getEffectivePermissions(req._clayUser, osUsers);
3460
+ if (!skPerms.skills) {
3461
+ res.writeHead(403, { "Content-Type": "application/json" });
3462
+ res.end('{"error":"Skills access is not permitted"}');
3463
+ return true;
3464
+ }
3465
+ }
3466
+ }
3467
+
3307
3468
  // Install a skill (background spawn)
3308
3469
  if (req.method === "POST" && urlPath === "/api/install-skill") {
3309
3470
  parseJsonBody(req).then(function (body) {
@@ -3453,20 +3614,24 @@ function createProjectContext(opts) {
3453
3614
  var mdContent = fs.readFileSync(mdPath, "utf8");
3454
3615
  var desc = "";
3455
3616
  // Parse YAML frontmatter for description
3617
+ var version = "";
3456
3618
  if (mdContent.startsWith("---")) {
3457
3619
  var endIdx = mdContent.indexOf("---", 3);
3458
3620
  if (endIdx !== -1) {
3459
3621
  var frontmatter = mdContent.substring(3, endIdx);
3460
3622
  var descMatch = frontmatter.match(/^description:\s*(.+)/m);
3461
3623
  if (descMatch) desc = descMatch[1].trim();
3624
+ var verMatch = frontmatter.match(/version:\s*"?([^"\n]+)"?/m);
3625
+ if (verMatch) version = verMatch[1].trim();
3462
3626
  }
3463
3627
  }
3464
3628
  if (!installed[ent.name]) {
3465
- installed[ent.name] = { scope: scanDirs[sd].scope, description: desc, path: path.join(scanDirs[sd].dir, ent.name) };
3629
+ installed[ent.name] = { scope: scanDirs[sd].scope, description: desc, version: version, path: path.join(scanDirs[sd].dir, ent.name) };
3466
3630
  } else {
3467
3631
  // project-level adds to existing global entry
3468
3632
  installed[ent.name].scope = "both";
3469
3633
  if (desc && !installed[ent.name].description) installed[ent.name].description = desc;
3634
+ if (version && !installed[ent.name].version) installed[ent.name].version = version;
3470
3635
  }
3471
3636
  } catch (e) {}
3472
3637
  }
@@ -3476,6 +3641,110 @@ function createProjectContext(opts) {
3476
3641
  return true;
3477
3642
  }
3478
3643
 
3644
+ // Check skill updates (compare installed vs remote versions)
3645
+ if (req.method === "POST" && urlPath === "/api/check-skill-updates") {
3646
+ parseJsonBody(req).then(function (body) {
3647
+ var skills = body.skills; // [{ name, url, scope }]
3648
+ if (!Array.isArray(skills) || skills.length === 0) {
3649
+ res.writeHead(400, { "Content-Type": "application/json" });
3650
+ res.end('{"error":"missing skills array"}');
3651
+ return;
3652
+ }
3653
+ // Read installed versions
3654
+ var globalSkillsDir = path.join(os.homedir(), ".claude", "skills");
3655
+ var projectSkillsDir = path.join(cwd, ".claude", "skills");
3656
+ var results = [];
3657
+ var pending = skills.length;
3658
+
3659
+ function parseVersionFromSkillMd(content) {
3660
+ if (!content || !content.startsWith("---")) return "";
3661
+ var endIdx = content.indexOf("---", 3);
3662
+ if (endIdx === -1) return "";
3663
+ var fm = content.substring(3, endIdx);
3664
+ var m = fm.match(/version:\s*"?([^"\n]+)"?/m);
3665
+ return m ? m[1].trim() : "";
3666
+ }
3667
+
3668
+ function getInstalledVersion(name) {
3669
+ var dirs = [path.join(globalSkillsDir, name, "SKILL.md"), path.join(projectSkillsDir, name, "SKILL.md")];
3670
+ for (var d = 0; d < dirs.length; d++) {
3671
+ try {
3672
+ var c = fs.readFileSync(dirs[d], "utf8");
3673
+ var v = parseVersionFromSkillMd(c);
3674
+ if (v) return v;
3675
+ } catch (e) {}
3676
+ }
3677
+ return "";
3678
+ }
3679
+
3680
+ function compareVersions(a, b) {
3681
+ // returns -1 if a < b, 0 if equal, 1 if a > b
3682
+ if (!a && !b) return 0;
3683
+ if (!a) return -1;
3684
+ if (!b) return 1;
3685
+ var pa = a.split(".").map(Number);
3686
+ var pb = b.split(".").map(Number);
3687
+ for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
3688
+ var va = pa[i] || 0;
3689
+ var vb = pb[i] || 0;
3690
+ if (va < vb) return -1;
3691
+ if (va > vb) return 1;
3692
+ }
3693
+ return 0;
3694
+ }
3695
+
3696
+ function finishOne() {
3697
+ pending--;
3698
+ if (pending === 0) {
3699
+ res.writeHead(200, { "Content-Type": "application/json" });
3700
+ res.end(JSON.stringify({ results: results }));
3701
+ }
3702
+ }
3703
+
3704
+ for (var si = 0; si < skills.length; si++) {
3705
+ (function (skill) {
3706
+ var installedVer = getInstalledVersion(skill.name);
3707
+ var installed = !!installedVer;
3708
+ // Convert GitHub repo URL to raw SKILL.md URL
3709
+ var rawUrl = "";
3710
+ var ghMatch = skill.url.match(/github\.com\/([^/]+)\/([^/]+)/);
3711
+ if (ghMatch) {
3712
+ rawUrl = "https://raw.githubusercontent.com/" + ghMatch[1] + "/" + ghMatch[2] + "/main/SKILL.md";
3713
+ }
3714
+ if (!rawUrl) {
3715
+ results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "missing" });
3716
+ finishOne();
3717
+ return;
3718
+ }
3719
+ // Fetch remote SKILL.md
3720
+ var https = require("https");
3721
+ https.get(rawUrl, function (resp) {
3722
+ var data = "";
3723
+ resp.on("data", function (chunk) { data += chunk; });
3724
+ resp.on("end", function () {
3725
+ var remoteVer = parseVersionFromSkillMd(data);
3726
+ var status = "ok";
3727
+ if (!installed) {
3728
+ status = "missing";
3729
+ } else if (remoteVer && compareVersions(installedVer, remoteVer) < 0) {
3730
+ status = "outdated";
3731
+ }
3732
+ results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: remoteVer, status: status });
3733
+ finishOne();
3734
+ });
3735
+ }).on("error", function () {
3736
+ results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "missing" });
3737
+ finishOne();
3738
+ });
3739
+ })(skills[si]);
3740
+ }
3741
+ }).catch(function () {
3742
+ res.writeHead(400);
3743
+ res.end("Bad request");
3744
+ });
3745
+ return true;
3746
+ }
3747
+
3479
3748
  // Git dirty check
3480
3749
  if (req.method === "GET" && urlPath === "/api/git-dirty") {
3481
3750
  var execSync = require("child_process").execSync;
@@ -3560,8 +3829,12 @@ function createProjectContext(opts) {
3560
3829
  function getStatus() {
3561
3830
  var sessionCount = sm.sessions.size;
3562
3831
  var hasProcessing = false;
3832
+ var pendingPermCount = 0;
3563
3833
  sm.sessions.forEach(function (s) {
3564
3834
  if (s.isProcessing) hasProcessing = true;
3835
+ if (s.pendingPermissions) {
3836
+ pendingPermCount += Object.keys(s.pendingPermissions).length;
3837
+ }
3565
3838
  });
3566
3839
  var status = {
3567
3840
  slug: slug,
@@ -3572,6 +3845,7 @@ function createProjectContext(opts) {
3572
3845
  clients: clients.size,
3573
3846
  sessions: sessionCount,
3574
3847
  isProcessing: hasProcessing,
3848
+ pendingPermissions: pendingPermCount,
3575
3849
  projectOwnerId: projectOwnerId,
3576
3850
  };
3577
3851
  if (isMate) {