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

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
@@ -747,7 +747,11 @@ function createProjectContext(opts) {
747
747
  sessionId: session.localId,
748
748
  });
749
749
 
750
+ var coderCompleted = false;
750
751
  session.onQueryComplete = function(completedSession) {
752
+ if (coderCompleted) return;
753
+ coderCompleted = true;
754
+ if (coderWatchdog) { clearTimeout(coderWatchdog); coderWatchdog = null; }
751
755
  console.log("[ralph-loop] Coder #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
752
756
  if (!loopState.active) { console.log("[ralph-loop] Coder: loopState.active is false, skipping"); return; }
753
757
  // Check if session ended with error
@@ -777,6 +781,26 @@ function createProjectContext(opts) {
777
781
  runJudge();
778
782
  };
779
783
 
784
+ // Watchdog: if onQueryComplete hasn't fired after 10 minutes, force error and retry
785
+ var coderWatchdog = setTimeout(function() {
786
+ if (!coderCompleted && loopState.active && !loopState.stopping) {
787
+ console.error("[ralph-loop] Coder #" + loopState.iteration + " watchdog triggered — onQueryComplete never fired");
788
+ coderCompleted = true;
789
+ loopState.results.push({
790
+ iteration: loopState.iteration,
791
+ verdict: "error",
792
+ summary: "Coder session timed out (no completion signal)",
793
+ });
794
+ send({
795
+ type: "loop_verdict",
796
+ iteration: loopState.iteration,
797
+ verdict: "error",
798
+ summary: "Coder session timed out, retrying...",
799
+ });
800
+ setTimeout(function() { runNextIteration(); }, 2000);
801
+ }
802
+ }, 10 * 60 * 1000);
803
+
780
804
  var userMsg = { type: "user_message", text: loopState.promptText };
781
805
  session.history.push(userMsg);
782
806
  sm.appendToSessionFile(session, userMsg);
@@ -835,7 +859,11 @@ function createProjectContext(opts) {
835
859
  sessionId: judgeSession.localId,
836
860
  });
837
861
 
862
+ var judgeCompleted = false;
838
863
  judgeSession.onQueryComplete = function(completedSession) {
864
+ if (judgeCompleted) return;
865
+ judgeCompleted = true;
866
+ if (judgeWatchdog) { clearTimeout(judgeWatchdog); judgeWatchdog = null; }
839
867
  console.log("[ralph-loop] Judge #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
840
868
  var verdict = parseJudgeVerdict(completedSession);
841
869
  console.log("[ralph-loop] Judge verdict: " + (verdict.pass ? "PASS" : "FAIL") + " - " + verdict.explanation);
@@ -860,6 +888,26 @@ function createProjectContext(opts) {
860
888
  }
861
889
  };
862
890
 
891
+ // Watchdog: judge should complete quickly (no tool use), 3 minutes is generous
892
+ var judgeWatchdog = setTimeout(function() {
893
+ if (!judgeCompleted && loopState.active && !loopState.stopping) {
894
+ console.error("[ralph-loop] Judge #" + loopState.iteration + " watchdog triggered — onQueryComplete never fired");
895
+ judgeCompleted = true;
896
+ loopState.results.push({
897
+ iteration: loopState.iteration,
898
+ verdict: "error",
899
+ summary: "Judge session timed out (no completion signal)",
900
+ });
901
+ send({
902
+ type: "loop_verdict",
903
+ iteration: loopState.iteration,
904
+ verdict: "error",
905
+ summary: "Judge session timed out, retrying...",
906
+ });
907
+ setTimeout(function() { runNextIteration(); }, 2000);
908
+ }
909
+ }, 3 * 60 * 1000);
910
+
863
911
  var userMsg = { type: "user_message", text: judgePrompt };
864
912
  judgeSession.history.push(userMsg);
865
913
  sm.appendToSessionFile(judgeSession, userMsg);
@@ -1461,6 +1509,13 @@ function createProjectContext(opts) {
1461
1509
  }
1462
1510
 
1463
1511
  if (msg.type === "delete_session") {
1512
+ if (ws._clayUser) {
1513
+ var sdPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
1514
+ if (!sdPerms.sessionDelete) {
1515
+ sendTo(ws, { type: "error", text: "You do not have permission to delete sessions" });
1516
+ return;
1517
+ }
1518
+ }
1464
1519
  if (msg.id && sm.sessions.has(msg.id)) {
1465
1520
  sm.deleteSession(msg.id, ws);
1466
1521
  }
@@ -1781,7 +1836,8 @@ function createProjectContext(opts) {
1781
1836
  session.messageUUIDs = session.messageUUIDs.slice(0, targetIdx);
1782
1837
  }
1783
1838
 
1784
- session.lastRewindUuid = msg.uuid;
1839
+ var kept = session.messageUUIDs;
1840
+ session.lastRewindUuid = kept.length > 0 ? kept[kept.length - 1].uuid : null;
1785
1841
  }
1786
1842
 
1787
1843
  if (session.abortController) {
@@ -1893,6 +1949,7 @@ function createProjectContext(opts) {
1893
1949
  var pending = session.pendingPermissions[requestId];
1894
1950
  if (!pending) return;
1895
1951
  delete session.pendingPermissions[requestId];
1952
+ onProcessingChanged(); // update cross-project permission badge
1896
1953
 
1897
1954
  // --- Plan approval: "allow_accept_edits" — approve + switch to acceptEdits mode ---
1898
1955
  if (decision === "allow_accept_edits") {
@@ -2103,6 +2160,15 @@ function createProjectContext(opts) {
2103
2160
  }
2104
2161
 
2105
2162
  // --- Create new empty project ---
2163
+ if (msg.type === "create_project" || msg.type === "clone_project") {
2164
+ if (ws._clayUser) {
2165
+ var cpPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2166
+ if (!cpPerms.createProject) {
2167
+ sendTo(ws, { type: "add_project_result", ok: false, error: "You do not have permission to create projects" });
2168
+ return;
2169
+ }
2170
+ }
2171
+ }
2106
2172
  if (msg.type === "create_project") {
2107
2173
  var createName = (msg.name || "").trim();
2108
2174
  if (!createName || !/^[a-zA-Z0-9_-]+$/.test(createName)) {
@@ -2139,13 +2205,14 @@ function createProjectContext(opts) {
2139
2205
  // --- Create worktree from web UI ---
2140
2206
  if (msg.type === "create_worktree") {
2141
2207
  var wtBranch = (msg.branch || "").trim();
2208
+ var wtDirName = (msg.dirName || "").trim() || wtBranch.replace(/\//g, "-");
2142
2209
  var wtBase = (msg.baseBranch || "").trim() || null;
2143
2210
  if (!wtBranch || !/^[a-zA-Z0-9_\/.@-]+$/.test(wtBranch)) {
2144
2211
  sendTo(ws, { type: "create_worktree_result", ok: false, error: "Invalid branch name" });
2145
2212
  return;
2146
2213
  }
2147
2214
  if (typeof onCreateWorktree === "function") {
2148
- var wtResult = onCreateWorktree(slug, wtBranch, wtBase);
2215
+ var wtResult = onCreateWorktree(slug, wtBranch, wtDirName, wtBase);
2149
2216
  sendTo(ws, { type: "create_worktree_result", ok: wtResult.ok, slug: wtResult.slug, error: wtResult.error });
2150
2217
  } else {
2151
2218
  sendTo(ws, { type: "create_worktree_result", ok: false, error: "Not supported" });
@@ -2167,6 +2234,13 @@ function createProjectContext(opts) {
2167
2234
 
2168
2235
  // --- Remove project from web UI ---
2169
2236
  if (msg.type === "remove_project") {
2237
+ if (ws._clayUser) {
2238
+ var dpPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2239
+ if (!dpPerms.deleteProject) {
2240
+ sendTo(ws, { type: "remove_project_result", ok: false, error: "You do not have permission to delete projects" });
2241
+ return;
2242
+ }
2243
+ }
2170
2244
  var removeSlug = msg.slug;
2171
2245
  if (!removeSlug) {
2172
2246
  sendTo(ws, { type: "remove_project_result", ok: false, error: "Missing slug" });
@@ -2310,6 +2384,15 @@ function createProjectContext(opts) {
2310
2384
  }
2311
2385
 
2312
2386
  // --- File browser ---
2387
+ 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") {
2388
+ if (ws._clayUser) {
2389
+ var fbPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2390
+ if (!fbPerms.fileBrowser) {
2391
+ sendTo(ws, { type: msg.type + "_result", error: "File browser access is not permitted" });
2392
+ return;
2393
+ }
2394
+ }
2395
+ }
2313
2396
  if (msg.type === "fs_list") {
2314
2397
  var fsDir = safePath(cwd, msg.path || ".");
2315
2398
  if (!fsDir) {
@@ -2418,6 +2501,20 @@ function createProjectContext(opts) {
2418
2501
  return;
2419
2502
  }
2420
2503
 
2504
+ // --- Project settings permission gate ---
2505
+ if (msg.type === "get_project_env" || msg.type === "set_project_env" ||
2506
+ msg.type === "read_global_claude_md" || msg.type === "write_global_claude_md" ||
2507
+ msg.type === "get_shared_env" || msg.type === "set_shared_env" ||
2508
+ msg.type === "transfer_project_owner") {
2509
+ if (ws._clayUser) {
2510
+ var psPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2511
+ if (!psPerms.projectSettings) {
2512
+ sendTo(ws, { type: "error", text: "Project settings access is not permitted" });
2513
+ return;
2514
+ }
2515
+ }
2516
+ }
2517
+
2421
2518
  // --- Project environment variables ---
2422
2519
  if (msg.type === "get_project_env") {
2423
2520
  var envrc = "";
@@ -2735,6 +2832,13 @@ function createProjectContext(opts) {
2735
2832
 
2736
2833
  // --- Web terminal ---
2737
2834
  if (msg.type === "term_create") {
2835
+ if (ws._clayUser) {
2836
+ var termPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2837
+ if (!termPerms.terminal) {
2838
+ sendTo(ws, { type: "term_error", error: "Terminal access is not permitted" });
2839
+ return;
2840
+ }
2841
+ }
2738
2842
  var t = tm.create(msg.cols || 80, msg.rows || 24, getOsUserInfoForWs(ws));
2739
2843
  if (!t) {
2740
2844
  sendTo(ws, { type: "term_error", error: "Cannot create terminal (node-pty not available or limit reached)" });
@@ -2784,6 +2888,20 @@ function createProjectContext(opts) {
2784
2888
  return;
2785
2889
  }
2786
2890
 
2891
+ // --- Scheduled tasks permission gate ---
2892
+ if (msg.type === "loop_start" || msg.type === "loop_stop" || msg.type === "loop_registry_files" ||
2893
+ msg.type === "loop_registry_list" || msg.type === "loop_registry_update" || msg.type === "loop_registry_rename" ||
2894
+ msg.type === "loop_registry_remove" || msg.type === "loop_registry_convert" || msg.type === "loop_registry_toggle" ||
2895
+ msg.type === "loop_registry_rerun" || msg.type === "schedule_create" || msg.type === "schedule_move") {
2896
+ if (ws._clayUser) {
2897
+ var schPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
2898
+ if (!schPerms.scheduledTasks) {
2899
+ sendTo(ws, { type: "error", text: "Scheduled tasks access is not permitted" });
2900
+ return;
2901
+ }
2902
+ }
2903
+ }
2904
+
2787
2905
  if (msg.type === "loop_start") {
2788
2906
  // If this loop has a cron schedule, don't run immediately — just confirm registration
2789
2907
  if (loopState.wizardData && loopState.wizardData.cron) {
@@ -2840,7 +2958,34 @@ function createProjectContext(opts) {
2840
2958
  fs.writeFileSync(tmpLoopJson, JSON.stringify({ maxIterations: maxIter }, null, 2));
2841
2959
  fs.renameSync(tmpLoopJson, loopJsonPath);
2842
2960
 
2843
- // Assemble prompt for clay-ralph skill (include loop dir path so skill knows where to write)
2961
+ var craftName = (loopState.wizardData && loopState.wizardData.name) || "";
2962
+ var isRalphCraft = recordSource === "ralph";
2963
+
2964
+ // User provided their own PROMPT.md (and optionally JUDGE.md)
2965
+ if (wData.mode === "own" && wData.promptText) {
2966
+ // Write PROMPT.md
2967
+ var promptPath = path.join(lDir, "PROMPT.md");
2968
+ var tmpPrompt = promptPath + ".tmp";
2969
+ fs.writeFileSync(tmpPrompt, wData.promptText);
2970
+ fs.renameSync(tmpPrompt, promptPath);
2971
+
2972
+ if (wData.judgeText) {
2973
+ // Both provided: write JUDGE.md too
2974
+ var judgePath = path.join(lDir, "JUDGE.md");
2975
+ var tmpJudge = judgePath + ".tmp";
2976
+ fs.writeFileSync(tmpJudge, wData.judgeText);
2977
+ fs.renameSync(tmpJudge, judgePath);
2978
+ }
2979
+
2980
+ // Go straight to approval (no crafting needed)
2981
+ loopState.phase = "approval";
2982
+ saveLoopState();
2983
+ send({ type: "ralph_phase", phase: "approval", wizardData: loopState.wizardData });
2984
+ send({ type: "ralph_files_status", promptReady: true, judgeReady: !!wData.judgeText, bothReady: !!wData.judgeText });
2985
+ return;
2986
+ }
2987
+
2988
+ // Default: "draft" mode — Clay crafts both PROMPT.md and JUDGE.md
2844
2989
  var craftingPrompt = "Use the /clay-ralph skill to design a Ralph Loop for the following task. " +
2845
2990
  "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
2846
2991
  "Your job is to interview me, then create PROMPT.md and JUDGE.md files " +
@@ -2850,11 +2995,9 @@ function createProjectContext(opts) {
2850
2995
 
2851
2996
  // Create a new session for crafting
2852
2997
  var craftingSession = sm.createSession();
2853
- var craftName = (loopState.wizardData && loopState.wizardData.name) || "";
2854
- var isRalphCraft = recordSource === "ralph";
2855
2998
  craftingSession.title = (isRalphCraft ? "Ralph" : "Task") + (craftName ? " " + craftName : "") + " Crafting";
2856
2999
  craftingSession.ralphCraftingMode = true;
2857
- craftingSession.loop = { active: true, iteration: 0, role: "crafting", loopId: newLoopId, name: craftName, source: recordSource };
3000
+ craftingSession.loop = { active: true, iteration: 0, role: "crafting", loopId: newLoopId, name: craftName, source: recordSource, startedAt: loopState.startedAt };
2858
3001
  sm.saveSessionFile(craftingSession);
2859
3002
  sm.switchSession(craftingSession.localId);
2860
3003
  loopState.craftingSessionId = craftingSession.localId;
@@ -3304,6 +3447,18 @@ function createProjectContext(opts) {
3304
3447
  return true;
3305
3448
  }
3306
3449
 
3450
+ // Skills permission gate
3451
+ if (urlPath === "/api/install-skill" || urlPath === "/api/uninstall-skill" || urlPath === "/api/installed-skills") {
3452
+ if (req._clayUser) {
3453
+ var skPerms = usersModule.getEffectivePermissions(req._clayUser, osUsers);
3454
+ if (!skPerms.skills) {
3455
+ res.writeHead(403, { "Content-Type": "application/json" });
3456
+ res.end('{"error":"Skills access is not permitted"}');
3457
+ return true;
3458
+ }
3459
+ }
3460
+ }
3461
+
3307
3462
  // Install a skill (background spawn)
3308
3463
  if (req.method === "POST" && urlPath === "/api/install-skill") {
3309
3464
  parseJsonBody(req).then(function (body) {
@@ -3453,20 +3608,24 @@ function createProjectContext(opts) {
3453
3608
  var mdContent = fs.readFileSync(mdPath, "utf8");
3454
3609
  var desc = "";
3455
3610
  // Parse YAML frontmatter for description
3611
+ var version = "";
3456
3612
  if (mdContent.startsWith("---")) {
3457
3613
  var endIdx = mdContent.indexOf("---", 3);
3458
3614
  if (endIdx !== -1) {
3459
3615
  var frontmatter = mdContent.substring(3, endIdx);
3460
3616
  var descMatch = frontmatter.match(/^description:\s*(.+)/m);
3461
3617
  if (descMatch) desc = descMatch[1].trim();
3618
+ var verMatch = frontmatter.match(/version:\s*"?([^"\n]+)"?/m);
3619
+ if (verMatch) version = verMatch[1].trim();
3462
3620
  }
3463
3621
  }
3464
3622
  if (!installed[ent.name]) {
3465
- installed[ent.name] = { scope: scanDirs[sd].scope, description: desc, path: path.join(scanDirs[sd].dir, ent.name) };
3623
+ installed[ent.name] = { scope: scanDirs[sd].scope, description: desc, version: version, path: path.join(scanDirs[sd].dir, ent.name) };
3466
3624
  } else {
3467
3625
  // project-level adds to existing global entry
3468
3626
  installed[ent.name].scope = "both";
3469
3627
  if (desc && !installed[ent.name].description) installed[ent.name].description = desc;
3628
+ if (version && !installed[ent.name].version) installed[ent.name].version = version;
3470
3629
  }
3471
3630
  } catch (e) {}
3472
3631
  }
@@ -3476,6 +3635,110 @@ function createProjectContext(opts) {
3476
3635
  return true;
3477
3636
  }
3478
3637
 
3638
+ // Check skill updates (compare installed vs remote versions)
3639
+ if (req.method === "POST" && urlPath === "/api/check-skill-updates") {
3640
+ parseJsonBody(req).then(function (body) {
3641
+ var skills = body.skills; // [{ name, url, scope }]
3642
+ if (!Array.isArray(skills) || skills.length === 0) {
3643
+ res.writeHead(400, { "Content-Type": "application/json" });
3644
+ res.end('{"error":"missing skills array"}');
3645
+ return;
3646
+ }
3647
+ // Read installed versions
3648
+ var globalSkillsDir = path.join(os.homedir(), ".claude", "skills");
3649
+ var projectSkillsDir = path.join(cwd, ".claude", "skills");
3650
+ var results = [];
3651
+ var pending = skills.length;
3652
+
3653
+ function parseVersionFromSkillMd(content) {
3654
+ if (!content || !content.startsWith("---")) return "";
3655
+ var endIdx = content.indexOf("---", 3);
3656
+ if (endIdx === -1) return "";
3657
+ var fm = content.substring(3, endIdx);
3658
+ var m = fm.match(/version:\s*"?([^"\n]+)"?/m);
3659
+ return m ? m[1].trim() : "";
3660
+ }
3661
+
3662
+ function getInstalledVersion(name) {
3663
+ var dirs = [path.join(globalSkillsDir, name, "SKILL.md"), path.join(projectSkillsDir, name, "SKILL.md")];
3664
+ for (var d = 0; d < dirs.length; d++) {
3665
+ try {
3666
+ var c = fs.readFileSync(dirs[d], "utf8");
3667
+ var v = parseVersionFromSkillMd(c);
3668
+ if (v) return v;
3669
+ } catch (e) {}
3670
+ }
3671
+ return "";
3672
+ }
3673
+
3674
+ function compareVersions(a, b) {
3675
+ // returns -1 if a < b, 0 if equal, 1 if a > b
3676
+ if (!a && !b) return 0;
3677
+ if (!a) return -1;
3678
+ if (!b) return 1;
3679
+ var pa = a.split(".").map(Number);
3680
+ var pb = b.split(".").map(Number);
3681
+ for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
3682
+ var va = pa[i] || 0;
3683
+ var vb = pb[i] || 0;
3684
+ if (va < vb) return -1;
3685
+ if (va > vb) return 1;
3686
+ }
3687
+ return 0;
3688
+ }
3689
+
3690
+ function finishOne() {
3691
+ pending--;
3692
+ if (pending === 0) {
3693
+ res.writeHead(200, { "Content-Type": "application/json" });
3694
+ res.end(JSON.stringify({ results: results }));
3695
+ }
3696
+ }
3697
+
3698
+ for (var si = 0; si < skills.length; si++) {
3699
+ (function (skill) {
3700
+ var installedVer = getInstalledVersion(skill.name);
3701
+ var installed = !!installedVer;
3702
+ // Convert GitHub repo URL to raw SKILL.md URL
3703
+ var rawUrl = "";
3704
+ var ghMatch = skill.url.match(/github\.com\/([^/]+)\/([^/]+)/);
3705
+ if (ghMatch) {
3706
+ rawUrl = "https://raw.githubusercontent.com/" + ghMatch[1] + "/" + ghMatch[2] + "/main/SKILL.md";
3707
+ }
3708
+ if (!rawUrl) {
3709
+ results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "missing" });
3710
+ finishOne();
3711
+ return;
3712
+ }
3713
+ // Fetch remote SKILL.md
3714
+ var https = require("https");
3715
+ https.get(rawUrl, function (resp) {
3716
+ var data = "";
3717
+ resp.on("data", function (chunk) { data += chunk; });
3718
+ resp.on("end", function () {
3719
+ var remoteVer = parseVersionFromSkillMd(data);
3720
+ var status = "ok";
3721
+ if (!installed) {
3722
+ status = "missing";
3723
+ } else if (remoteVer && compareVersions(installedVer, remoteVer) < 0) {
3724
+ status = "outdated";
3725
+ }
3726
+ results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: remoteVer, status: status });
3727
+ finishOne();
3728
+ });
3729
+ }).on("error", function () {
3730
+ results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "missing" });
3731
+ finishOne();
3732
+ });
3733
+ })(skills[si]);
3734
+ }
3735
+ }).catch(function () {
3736
+ res.writeHead(400);
3737
+ res.end("Bad request");
3738
+ });
3739
+ return true;
3740
+ }
3741
+
3479
3742
  // Git dirty check
3480
3743
  if (req.method === "GET" && urlPath === "/api/git-dirty") {
3481
3744
  var execSync = require("child_process").execSync;
@@ -3560,8 +3823,12 @@ function createProjectContext(opts) {
3560
3823
  function getStatus() {
3561
3824
  var sessionCount = sm.sessions.size;
3562
3825
  var hasProcessing = false;
3826
+ var pendingPermCount = 0;
3563
3827
  sm.sessions.forEach(function (s) {
3564
3828
  if (s.isProcessing) hasProcessing = true;
3829
+ if (s.pendingPermissions) {
3830
+ pendingPermCount += Object.keys(s.pendingPermissions).length;
3831
+ }
3565
3832
  });
3566
3833
  var status = {
3567
3834
  slug: slug,
@@ -3572,6 +3839,7 @@ function createProjectContext(opts) {
3572
3839
  clients: clients.size,
3573
3840
  sessions: sessionCount,
3574
3841
  isProcessing: hasProcessing,
3842
+ pendingPermissions: pendingPermCount,
3575
3843
  projectOwnerId: projectOwnerId,
3576
3844
  };
3577
3845
  if (isMate) {