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 +46 -0
- package/lib/daemon.js +16 -3
- package/lib/notes.js +1 -1
- package/lib/project.js +275 -7
- package/lib/public/app.js +250 -34
- package/lib/public/css/admin.css +71 -0
- package/lib/public/css/command-palette.css +319 -0
- package/lib/public/css/icon-strip.css +19 -3
- package/lib/public/css/input.css +2 -1
- package/lib/public/css/loop.css +26 -0
- package/lib/public/css/mates.css +73 -0
- package/lib/public/css/overlays.css +3 -0
- package/lib/public/css/scheduler.css +29 -5
- package/lib/public/css/sidebar.css +28 -6
- package/lib/public/css/sticky-notes.css +61 -12
- package/lib/public/css/title-bar.css +24 -30
- package/lib/public/index.html +48 -13
- package/lib/public/modules/admin.js +109 -1
- package/lib/public/modules/command-palette.js +549 -0
- package/lib/public/modules/input.js +10 -2
- package/lib/public/modules/mate-wizard.js +17 -6
- package/lib/public/modules/scheduler.js +56 -64
- package/lib/public/modules/session-search.js +6 -2
- package/lib/public/modules/sidebar.js +128 -72
- package/lib/public/modules/sticky-notes.js +37 -0
- package/lib/public/style.css +1 -0
- package/lib/scheduler.js +4 -0
- package/lib/sdk-bridge.js +10 -8
- package/lib/server.js +334 -7
- package/lib/sessions.js +5 -0
- package/lib/users.js +64 -0
- package/lib/worktree.js +2 -2
- package/package.json +1 -1
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 + "--" +
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|