clay-server 2.5.1 → 2.7.0
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/claude-relay.js +6 -0
- package/bin/cli.js +77 -11
- package/lib/cli-sessions.js +2 -7
- package/lib/config.js +86 -7
- package/lib/daemon.js +279 -6
- package/lib/ipc.js +12 -0
- package/lib/notes.js +4 -3
- package/lib/project.js +1174 -28
- package/lib/public/app.js +879 -31
- package/lib/public/css/diff.css +15 -4
- package/lib/public/css/filebrowser.css +363 -3
- package/lib/public/css/icon-strip.css +317 -1
- package/lib/public/css/input.css +127 -50
- package/lib/public/css/loop.css +780 -0
- package/lib/public/css/messages.css +1 -1
- package/lib/public/css/mobile-nav.css +6 -2
- package/lib/public/css/rewind.css +23 -0
- package/lib/public/css/server-settings.css +67 -20
- package/lib/public/css/sidebar.css +10 -4
- package/lib/public/css/skills.css +789 -0
- package/lib/public/css/sticky-notes.css +486 -0
- package/lib/public/css/title-bar.css +157 -7
- package/lib/public/index.html +366 -55
- package/lib/public/modules/diff.js +3 -3
- package/lib/public/modules/filebrowser.js +169 -45
- package/lib/public/modules/input.js +123 -56
- package/lib/public/modules/project-settings.js +906 -0
- package/lib/public/modules/qrcode.js +23 -26
- package/lib/public/modules/server-settings.js +449 -53
- package/lib/public/modules/sidebar.js +732 -1
- package/lib/public/modules/skills.js +794 -0
- package/lib/public/modules/sticky-notes.js +617 -52
- package/lib/public/modules/terminal.js +7 -0
- package/lib/public/modules/theme.js +9 -19
- package/lib/public/modules/tools.js +16 -2
- package/lib/public/style.css +2 -0
- package/lib/sdk-bridge.js +46 -7
- package/lib/server.js +305 -1
- package/lib/sessions.js +11 -5
- package/lib/utils.js +18 -0
- package/package.json +3 -2
package/lib/project.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
var fs = require("fs");
|
|
2
2
|
var path = require("path");
|
|
3
|
+
var os = require("os");
|
|
4
|
+
var crypto = require("crypto");
|
|
3
5
|
var { createSessionManager } = require("./sessions");
|
|
4
6
|
var { createSDKBridge } = require("./sdk-bridge");
|
|
5
7
|
var { createTerminalManager } = require("./terminal-manager");
|
|
6
8
|
var { createNotesManager } = require("./notes");
|
|
7
9
|
var { fetchLatestVersion, isNewer } = require("./updater");
|
|
8
|
-
var { execFileSync } = require("child_process");
|
|
10
|
+
var { execFileSync, spawn } = require("child_process");
|
|
11
|
+
|
|
12
|
+
var MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
9
13
|
|
|
10
14
|
// SDK loaded dynamically (ESM module)
|
|
11
15
|
var sdkModule = null;
|
|
@@ -63,6 +67,7 @@ function createProjectContext(opts) {
|
|
|
63
67
|
var slug = opts.slug;
|
|
64
68
|
var project = path.basename(cwd);
|
|
65
69
|
var title = opts.title || null;
|
|
70
|
+
var icon = opts.icon || null;
|
|
66
71
|
var pushModule = opts.pushModule || null;
|
|
67
72
|
var debug = opts.debug || false;
|
|
68
73
|
var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
|
|
@@ -70,6 +75,7 @@ function createProjectContext(opts) {
|
|
|
70
75
|
var lanHost = opts.lanHost || null;
|
|
71
76
|
var getProjectCount = opts.getProjectCount || function () { return 1; };
|
|
72
77
|
var getProjectList = opts.getProjectList || function () { return []; };
|
|
78
|
+
var onProcessingChanged = opts.onProcessingChanged || function () {};
|
|
73
79
|
var latestVersion = null;
|
|
74
80
|
|
|
75
81
|
// --- Per-project clients ---
|
|
@@ -192,8 +198,17 @@ function createProjectContext(opts) {
|
|
|
192
198
|
|
|
193
199
|
// --- Session manager ---
|
|
194
200
|
var sm = createSessionManager({ cwd: cwd, send: send });
|
|
195
|
-
|
|
196
|
-
|
|
201
|
+
var _projMode = typeof opts.onGetProjectDefaultMode === "function" ? opts.onGetProjectDefaultMode(slug) : null;
|
|
202
|
+
var _srvMode = typeof opts.onGetServerDefaultMode === "function" ? opts.onGetServerDefaultMode() : null;
|
|
203
|
+
sm.currentPermissionMode = (_projMode && _projMode.mode) || (_srvMode && _srvMode.mode) || "default";
|
|
204
|
+
|
|
205
|
+
var _projEffort = typeof opts.onGetProjectDefaultEffort === "function" ? opts.onGetProjectDefaultEffort(slug) : null;
|
|
206
|
+
var _srvEffort = typeof opts.onGetServerDefaultEffort === "function" ? opts.onGetServerDefaultEffort() : null;
|
|
207
|
+
sm.currentEffort = (_projEffort && _projEffort.effort) || (_srvEffort && _srvEffort.effort) || "medium";
|
|
208
|
+
|
|
209
|
+
var _projModel = typeof opts.onGetProjectDefaultModel === "function" ? opts.onGetProjectDefaultModel(slug) : null;
|
|
210
|
+
var _srvModel = typeof opts.onGetServerDefaultModel === "function" ? opts.onGetServerDefaultModel() : null;
|
|
211
|
+
sm._savedDefaultModel = (_projModel && _projModel.model) || (_srvModel && _srvModel.model) || null;
|
|
197
212
|
|
|
198
213
|
// --- SDK bridge ---
|
|
199
214
|
var sdk = createSDKBridge({
|
|
@@ -204,8 +219,523 @@ function createProjectContext(opts) {
|
|
|
204
219
|
pushModule: pushModule,
|
|
205
220
|
getSDK: getSDK,
|
|
206
221
|
dangerouslySkipPermissions: dangerouslySkipPermissions,
|
|
222
|
+
onProcessingChanged: onProcessingChanged,
|
|
207
223
|
});
|
|
208
224
|
|
|
225
|
+
// --- Ralph Loop state ---
|
|
226
|
+
var loopState = {
|
|
227
|
+
active: false,
|
|
228
|
+
phase: "idle", // idle | crafting | approval | executing | done
|
|
229
|
+
promptText: "",
|
|
230
|
+
judgeText: "",
|
|
231
|
+
iteration: 0,
|
|
232
|
+
maxIterations: 20,
|
|
233
|
+
baseCommit: null,
|
|
234
|
+
currentSessionId: null,
|
|
235
|
+
judgeSessionId: null,
|
|
236
|
+
results: [],
|
|
237
|
+
stopping: false,
|
|
238
|
+
wizardData: null,
|
|
239
|
+
craftingSessionId: null,
|
|
240
|
+
startedAt: null,
|
|
241
|
+
loopId: null,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
function loopDir() {
|
|
245
|
+
if (!loopState.loopId) return null;
|
|
246
|
+
return path.join(cwd, ".claude", "loops", loopState.loopId);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function generateLoopId() {
|
|
250
|
+
return "loop_" + Date.now() + "_" + crypto.randomBytes(3).toString("hex");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Loop state persistence
|
|
254
|
+
var _loopConfig = require("./config");
|
|
255
|
+
var _loopUtils = require("./utils");
|
|
256
|
+
var _loopEncodedCwd = _loopUtils.encodeCwd(cwd);
|
|
257
|
+
var _loopDir = path.join(_loopConfig.CONFIG_DIR, "loops");
|
|
258
|
+
var _loopStatePath = path.join(_loopDir, _loopEncodedCwd + ".json");
|
|
259
|
+
|
|
260
|
+
function saveLoopState() {
|
|
261
|
+
try {
|
|
262
|
+
fs.mkdirSync(_loopDir, { recursive: true });
|
|
263
|
+
var data = {
|
|
264
|
+
phase: loopState.phase,
|
|
265
|
+
active: loopState.active,
|
|
266
|
+
iteration: loopState.iteration,
|
|
267
|
+
maxIterations: loopState.maxIterations,
|
|
268
|
+
baseCommit: loopState.baseCommit,
|
|
269
|
+
results: loopState.results,
|
|
270
|
+
wizardData: loopState.wizardData,
|
|
271
|
+
startedAt: loopState.startedAt,
|
|
272
|
+
loopId: loopState.loopId,
|
|
273
|
+
};
|
|
274
|
+
var tmpPath = _loopStatePath + ".tmp";
|
|
275
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
276
|
+
fs.renameSync(tmpPath, _loopStatePath);
|
|
277
|
+
} catch (e) {
|
|
278
|
+
console.error("[ralph-loop] Failed to save state:", e.message);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function loadLoopState() {
|
|
283
|
+
try {
|
|
284
|
+
var raw = fs.readFileSync(_loopStatePath, "utf8");
|
|
285
|
+
var data = JSON.parse(raw);
|
|
286
|
+
loopState.phase = data.phase || "idle";
|
|
287
|
+
loopState.active = data.active || false;
|
|
288
|
+
loopState.iteration = data.iteration || 0;
|
|
289
|
+
loopState.maxIterations = data.maxIterations || 20;
|
|
290
|
+
loopState.baseCommit = data.baseCommit || null;
|
|
291
|
+
loopState.results = data.results || [];
|
|
292
|
+
loopState.wizardData = data.wizardData || null;
|
|
293
|
+
loopState.startedAt = data.startedAt || null;
|
|
294
|
+
loopState.loopId = data.loopId || null;
|
|
295
|
+
// SDK sessions cannot survive daemon restart
|
|
296
|
+
loopState.currentSessionId = null;
|
|
297
|
+
loopState.judgeSessionId = null;
|
|
298
|
+
loopState.craftingSessionId = null;
|
|
299
|
+
loopState.stopping = false;
|
|
300
|
+
// If was executing, schedule resume after SDK is ready
|
|
301
|
+
if (loopState.phase === "executing" && loopState.active) {
|
|
302
|
+
loopState._needsResume = true;
|
|
303
|
+
}
|
|
304
|
+
// If was crafting, check if files exist and move to approval
|
|
305
|
+
if (loopState.phase === "crafting") {
|
|
306
|
+
var hasFiles = checkLoopFilesExist();
|
|
307
|
+
if (hasFiles) {
|
|
308
|
+
loopState.phase = "approval";
|
|
309
|
+
saveLoopState();
|
|
310
|
+
} else {
|
|
311
|
+
loopState.phase = "idle";
|
|
312
|
+
saveLoopState();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} catch (e) {
|
|
316
|
+
// No saved state, use defaults
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function clearLoopState() {
|
|
321
|
+
loopState.active = false;
|
|
322
|
+
loopState.phase = "idle";
|
|
323
|
+
loopState.promptText = "";
|
|
324
|
+
loopState.judgeText = "";
|
|
325
|
+
loopState.iteration = 0;
|
|
326
|
+
loopState.maxIterations = 20;
|
|
327
|
+
loopState.baseCommit = null;
|
|
328
|
+
loopState.currentSessionId = null;
|
|
329
|
+
loopState.judgeSessionId = null;
|
|
330
|
+
loopState.results = [];
|
|
331
|
+
loopState.stopping = false;
|
|
332
|
+
loopState.wizardData = null;
|
|
333
|
+
loopState.craftingSessionId = null;
|
|
334
|
+
loopState.startedAt = null;
|
|
335
|
+
loopState.loopId = null;
|
|
336
|
+
saveLoopState();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function checkLoopFilesExist() {
|
|
340
|
+
var dir = loopDir();
|
|
341
|
+
if (!dir) return false;
|
|
342
|
+
var hasPrompt = false;
|
|
343
|
+
var hasJudge = false;
|
|
344
|
+
try { fs.accessSync(path.join(dir, "PROMPT.md")); hasPrompt = true; } catch (e) {}
|
|
345
|
+
try { fs.accessSync(path.join(dir, "JUDGE.md")); hasJudge = true; } catch (e) {}
|
|
346
|
+
return hasPrompt && hasJudge;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// .claude/ directory watcher for PROMPT.md / JUDGE.md
|
|
350
|
+
var claudeDirWatcher = null;
|
|
351
|
+
var claudeDirDebounce = null;
|
|
352
|
+
|
|
353
|
+
function startClaudeDirWatch() {
|
|
354
|
+
if (claudeDirWatcher) return;
|
|
355
|
+
var watchDir = loopDir();
|
|
356
|
+
if (!watchDir) return;
|
|
357
|
+
try { fs.mkdirSync(watchDir, { recursive: true }); } catch (e) {}
|
|
358
|
+
try {
|
|
359
|
+
claudeDirWatcher = fs.watch(watchDir, function () {
|
|
360
|
+
if (claudeDirDebounce) clearTimeout(claudeDirDebounce);
|
|
361
|
+
claudeDirDebounce = setTimeout(function () {
|
|
362
|
+
broadcastLoopFilesStatus();
|
|
363
|
+
}, 300);
|
|
364
|
+
});
|
|
365
|
+
claudeDirWatcher.on("error", function () {});
|
|
366
|
+
} catch (e) {
|
|
367
|
+
console.error("[ralph-loop] Failed to watch .claude/:", e.message);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function stopClaudeDirWatch() {
|
|
372
|
+
if (claudeDirWatcher) {
|
|
373
|
+
claudeDirWatcher.close();
|
|
374
|
+
claudeDirWatcher = null;
|
|
375
|
+
}
|
|
376
|
+
if (claudeDirDebounce) {
|
|
377
|
+
clearTimeout(claudeDirDebounce);
|
|
378
|
+
claudeDirDebounce = null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function broadcastLoopFilesStatus() {
|
|
383
|
+
var dir = loopDir();
|
|
384
|
+
var hasPrompt = false;
|
|
385
|
+
var hasJudge = false;
|
|
386
|
+
var hasLoopJson = false;
|
|
387
|
+
if (dir) {
|
|
388
|
+
try { fs.accessSync(path.join(dir, "PROMPT.md")); hasPrompt = true; } catch (e) {}
|
|
389
|
+
try { fs.accessSync(path.join(dir, "JUDGE.md")); hasJudge = true; } catch (e) {}
|
|
390
|
+
try { fs.accessSync(path.join(dir, "LOOP.json")); hasLoopJson = true; } catch (e) {}
|
|
391
|
+
}
|
|
392
|
+
send({
|
|
393
|
+
type: "ralph_files_status",
|
|
394
|
+
promptReady: hasPrompt,
|
|
395
|
+
judgeReady: hasJudge,
|
|
396
|
+
loopJsonReady: hasLoopJson,
|
|
397
|
+
bothReady: hasPrompt && hasJudge,
|
|
398
|
+
});
|
|
399
|
+
// Auto-transition to approval phase when both files appear
|
|
400
|
+
if (hasPrompt && hasJudge && loopState.phase === "crafting") {
|
|
401
|
+
loopState.phase = "approval";
|
|
402
|
+
saveLoopState();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Load persisted state on startup
|
|
407
|
+
loadLoopState();
|
|
408
|
+
|
|
409
|
+
function startLoop(opts) {
|
|
410
|
+
var loopOpts = opts || {};
|
|
411
|
+
var dir = loopDir();
|
|
412
|
+
if (!dir) {
|
|
413
|
+
send({ type: "loop_error", text: "No loop directory. Run the wizard first." });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
var promptPath = path.join(dir, "PROMPT.md");
|
|
417
|
+
var judgePath = path.join(dir, "JUDGE.md");
|
|
418
|
+
var promptText, judgeText;
|
|
419
|
+
try {
|
|
420
|
+
promptText = fs.readFileSync(promptPath, "utf8");
|
|
421
|
+
} catch (e) {
|
|
422
|
+
send({ type: "loop_error", text: "Missing PROMPT.md in " + dir });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
judgeText = fs.readFileSync(judgePath, "utf8");
|
|
427
|
+
} catch (e) {
|
|
428
|
+
send({ type: "loop_error", text: "Missing JUDGE.md in " + dir });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
var baseCommit;
|
|
433
|
+
try {
|
|
434
|
+
baseCommit = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
435
|
+
cwd: cwd, encoding: "utf8", timeout: 5000,
|
|
436
|
+
}).trim();
|
|
437
|
+
} catch (e) {
|
|
438
|
+
send({ type: "loop_error", text: "Failed to get git HEAD: " + e.message });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Read loop config from LOOP.json in loop directory
|
|
443
|
+
var loopConfig = {};
|
|
444
|
+
try {
|
|
445
|
+
loopConfig = JSON.parse(fs.readFileSync(path.join(dir, "LOOP.json"), "utf8"));
|
|
446
|
+
} catch (e) {}
|
|
447
|
+
|
|
448
|
+
loopState.active = true;
|
|
449
|
+
loopState.phase = "executing";
|
|
450
|
+
loopState.promptText = promptText;
|
|
451
|
+
loopState.judgeText = judgeText;
|
|
452
|
+
loopState.iteration = 0;
|
|
453
|
+
loopState.maxIterations = loopConfig.maxIterations || loopOpts.maxIterations || 20;
|
|
454
|
+
loopState.baseCommit = baseCommit;
|
|
455
|
+
loopState.currentSessionId = null;
|
|
456
|
+
loopState.judgeSessionId = null;
|
|
457
|
+
loopState.results = [];
|
|
458
|
+
loopState.stopping = false;
|
|
459
|
+
loopState.startedAt = Date.now();
|
|
460
|
+
saveLoopState();
|
|
461
|
+
|
|
462
|
+
stopClaudeDirWatch();
|
|
463
|
+
|
|
464
|
+
send({ type: "loop_started", maxIterations: loopState.maxIterations });
|
|
465
|
+
runNextIteration();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function runNextIteration() {
|
|
469
|
+
console.log("[ralph-loop] runNextIteration called, iteration: " + loopState.iteration + ", active: " + loopState.active + ", stopping: " + loopState.stopping);
|
|
470
|
+
if (!loopState.active || loopState.stopping) {
|
|
471
|
+
finishLoop("stopped");
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
loopState.iteration++;
|
|
476
|
+
if (loopState.iteration > loopState.maxIterations) {
|
|
477
|
+
finishLoop("max_iterations");
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
var session = sm.createSession();
|
|
482
|
+
session.loop = { active: true, iteration: loopState.iteration, role: "coder" };
|
|
483
|
+
var loopName = (loopState.wizardData && loopState.wizardData.name) || "";
|
|
484
|
+
session.title = "Ralph" + (loopName ? " " + loopName : "") + " #" + loopState.iteration;
|
|
485
|
+
sm.saveSessionFile(session);
|
|
486
|
+
sm.broadcastSessionList();
|
|
487
|
+
|
|
488
|
+
loopState.currentSessionId = session.localId;
|
|
489
|
+
|
|
490
|
+
send({
|
|
491
|
+
type: "loop_iteration",
|
|
492
|
+
iteration: loopState.iteration,
|
|
493
|
+
maxIterations: loopState.maxIterations,
|
|
494
|
+
sessionId: session.localId,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
session.onQueryComplete = function(completedSession) {
|
|
498
|
+
console.log("[ralph-loop] Coder #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
|
|
499
|
+
if (!loopState.active) { console.log("[ralph-loop] Coder: loopState.active is false, skipping"); return; }
|
|
500
|
+
// Check if session ended with error
|
|
501
|
+
var lastItems = completedSession.history.slice(-3);
|
|
502
|
+
var hadError = false;
|
|
503
|
+
for (var i = 0; i < lastItems.length; i++) {
|
|
504
|
+
if (lastItems[i].type === "error" || (lastItems[i].type === "done" && lastItems[i].code === 1)) {
|
|
505
|
+
hadError = true;
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (hadError) {
|
|
510
|
+
loopState.results.push({
|
|
511
|
+
iteration: loopState.iteration,
|
|
512
|
+
verdict: "error",
|
|
513
|
+
summary: "Iteration ended with error",
|
|
514
|
+
});
|
|
515
|
+
send({
|
|
516
|
+
type: "loop_verdict",
|
|
517
|
+
iteration: loopState.iteration,
|
|
518
|
+
verdict: "error",
|
|
519
|
+
summary: "Iteration ended with error, retrying...",
|
|
520
|
+
});
|
|
521
|
+
setTimeout(function() { runNextIteration(); }, 2000);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
runJudge();
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
var userMsg = { type: "user_message", text: loopState.promptText };
|
|
528
|
+
session.history.push(userMsg);
|
|
529
|
+
sm.appendToSessionFile(session, userMsg);
|
|
530
|
+
|
|
531
|
+
session.isProcessing = true;
|
|
532
|
+
onProcessingChanged();
|
|
533
|
+
session.sentToolResults = {};
|
|
534
|
+
send({ type: "status", status: "processing" });
|
|
535
|
+
session.acceptEditsAfterStart = true;
|
|
536
|
+
session.singleTurn = true;
|
|
537
|
+
sdk.startQuery(session, loopState.promptText);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function runJudge() {
|
|
541
|
+
if (!loopState.active || loopState.stopping) {
|
|
542
|
+
finishLoop("stopped");
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
var diff;
|
|
547
|
+
try {
|
|
548
|
+
diff = execFileSync("git", ["diff", loopState.baseCommit], {
|
|
549
|
+
cwd: cwd, encoding: "utf8", timeout: 30000,
|
|
550
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
551
|
+
});
|
|
552
|
+
} catch (e) {
|
|
553
|
+
send({ type: "loop_error", text: "Failed to generate git diff: " + e.message });
|
|
554
|
+
finishLoop("error");
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
var judgePrompt = "You are a judge evaluating whether a coding task has been completed.\n\n" +
|
|
559
|
+
"## Original Task (PROMPT.md)\n\n" + loopState.promptText + "\n\n" +
|
|
560
|
+
"## Evaluation Criteria (JUDGE.md)\n\n" + loopState.judgeText + "\n\n" +
|
|
561
|
+
"## Changes Made (git diff)\n\n```diff\n" + diff + "\n```\n\n" +
|
|
562
|
+
"Based on the evaluation criteria, has the task been completed successfully?\n\n" +
|
|
563
|
+
"Respond with exactly one of:\n" +
|
|
564
|
+
"- PASS: [brief explanation]\n" +
|
|
565
|
+
"- FAIL: [brief explanation of what is still missing]\n\n" +
|
|
566
|
+
"Do NOT use any tools. Just analyze and respond.";
|
|
567
|
+
|
|
568
|
+
var judgeSession = sm.createSession();
|
|
569
|
+
judgeSession.loop = { active: true, iteration: loopState.iteration, role: "judge" };
|
|
570
|
+
var judgeName = (loopState.wizardData && loopState.wizardData.name) || "";
|
|
571
|
+
judgeSession.title = "Ralph" + (judgeName ? " " + judgeName : "") + " Judge #" + loopState.iteration;
|
|
572
|
+
sm.saveSessionFile(judgeSession);
|
|
573
|
+
sm.broadcastSessionList();
|
|
574
|
+
loopState.judgeSessionId = judgeSession.localId;
|
|
575
|
+
|
|
576
|
+
send({
|
|
577
|
+
type: "loop_judging",
|
|
578
|
+
iteration: loopState.iteration,
|
|
579
|
+
sessionId: judgeSession.localId,
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
judgeSession.onQueryComplete = function(completedSession) {
|
|
583
|
+
console.log("[ralph-loop] Judge #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
|
|
584
|
+
var verdict = parseJudgeVerdict(completedSession);
|
|
585
|
+
console.log("[ralph-loop] Judge verdict: " + (verdict.pass ? "PASS" : "FAIL") + " - " + verdict.explanation);
|
|
586
|
+
|
|
587
|
+
loopState.results.push({
|
|
588
|
+
iteration: loopState.iteration,
|
|
589
|
+
verdict: verdict.pass ? "pass" : "fail",
|
|
590
|
+
summary: verdict.explanation,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
send({
|
|
594
|
+
type: "loop_verdict",
|
|
595
|
+
iteration: loopState.iteration,
|
|
596
|
+
verdict: verdict.pass ? "pass" : "fail",
|
|
597
|
+
summary: verdict.explanation,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
if (verdict.pass) {
|
|
601
|
+
finishLoop("pass");
|
|
602
|
+
} else {
|
|
603
|
+
setTimeout(function() { runNextIteration(); }, 1000);
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
var userMsg = { type: "user_message", text: judgePrompt };
|
|
608
|
+
judgeSession.history.push(userMsg);
|
|
609
|
+
sm.appendToSessionFile(judgeSession, userMsg);
|
|
610
|
+
|
|
611
|
+
judgeSession.isProcessing = true;
|
|
612
|
+
onProcessingChanged();
|
|
613
|
+
judgeSession.sentToolResults = {};
|
|
614
|
+
judgeSession.acceptEditsAfterStart = true;
|
|
615
|
+
judgeSession.singleTurn = true;
|
|
616
|
+
sdk.startQuery(judgeSession, judgePrompt);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function parseJudgeVerdict(session) {
|
|
620
|
+
var text = "";
|
|
621
|
+
for (var i = 0; i < session.history.length; i++) {
|
|
622
|
+
var h = session.history[i];
|
|
623
|
+
if (h.type === "delta" && h.text) text += h.text;
|
|
624
|
+
if (h.type === "text" && h.text) text += h.text;
|
|
625
|
+
}
|
|
626
|
+
console.log("[ralph-loop] Judge raw text (last 500 chars): " + text.slice(-500));
|
|
627
|
+
var upper = text.toUpperCase();
|
|
628
|
+
var passIdx = upper.indexOf("PASS");
|
|
629
|
+
var failIdx = upper.indexOf("FAIL");
|
|
630
|
+
if (passIdx !== -1 && (failIdx === -1 || passIdx < failIdx)) {
|
|
631
|
+
var explanation = text.substring(passIdx + 4).replace(/^[\s:]+/, "").split("\n")[0].trim();
|
|
632
|
+
return { pass: true, explanation: explanation || "Task completed" };
|
|
633
|
+
}
|
|
634
|
+
if (failIdx !== -1) {
|
|
635
|
+
var explanation = text.substring(failIdx + 4).replace(/^[\s:]+/, "").split("\n")[0].trim();
|
|
636
|
+
return { pass: false, explanation: explanation || "Task not yet complete" };
|
|
637
|
+
}
|
|
638
|
+
return { pass: false, explanation: "Could not parse judge verdict" };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function finishLoop(reason) {
|
|
642
|
+
console.log("[ralph-loop] finishLoop called, reason: " + reason + ", iteration: " + loopState.iteration);
|
|
643
|
+
loopState.active = false;
|
|
644
|
+
loopState.phase = "done";
|
|
645
|
+
loopState.stopping = false;
|
|
646
|
+
loopState.currentSessionId = null;
|
|
647
|
+
loopState.judgeSessionId = null;
|
|
648
|
+
saveLoopState();
|
|
649
|
+
|
|
650
|
+
send({
|
|
651
|
+
type: "loop_finished",
|
|
652
|
+
reason: reason,
|
|
653
|
+
iterations: loopState.iteration,
|
|
654
|
+
results: loopState.results,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
if (pushModule) {
|
|
658
|
+
var body = reason === "pass"
|
|
659
|
+
? "Task completed after " + loopState.iteration + " iteration(s)"
|
|
660
|
+
: reason === "max_iterations"
|
|
661
|
+
? "Reached max iterations (" + loopState.maxIterations + ")"
|
|
662
|
+
: reason === "stopped"
|
|
663
|
+
? "Loop stopped by user"
|
|
664
|
+
: "Loop ended due to error";
|
|
665
|
+
pushModule.sendPush({
|
|
666
|
+
type: "done",
|
|
667
|
+
slug: slug,
|
|
668
|
+
title: "Ralph Loop Complete",
|
|
669
|
+
body: body,
|
|
670
|
+
tag: "ralph-loop-done",
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function resumeLoop() {
|
|
676
|
+
var dir = loopDir();
|
|
677
|
+
if (!dir) {
|
|
678
|
+
console.error("[ralph-loop] Cannot resume: no loop directory");
|
|
679
|
+
loopState.active = false;
|
|
680
|
+
loopState.phase = "idle";
|
|
681
|
+
saveLoopState();
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
try {
|
|
685
|
+
loopState.promptText = fs.readFileSync(path.join(dir, "PROMPT.md"), "utf8");
|
|
686
|
+
} catch (e) {
|
|
687
|
+
console.error("[ralph-loop] Cannot resume: missing PROMPT.md");
|
|
688
|
+
loopState.active = false;
|
|
689
|
+
loopState.phase = "idle";
|
|
690
|
+
saveLoopState();
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
loopState.judgeText = fs.readFileSync(path.join(dir, "JUDGE.md"), "utf8");
|
|
695
|
+
} catch (e) {
|
|
696
|
+
console.error("[ralph-loop] Cannot resume: missing JUDGE.md");
|
|
697
|
+
loopState.active = false;
|
|
698
|
+
loopState.phase = "idle";
|
|
699
|
+
saveLoopState();
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
// Retry the interrupted iteration (runNextIteration will increment)
|
|
703
|
+
if (loopState.iteration > 0) {
|
|
704
|
+
loopState.iteration--;
|
|
705
|
+
}
|
|
706
|
+
console.log("[ralph-loop] Resuming loop, next iteration will be " + (loopState.iteration + 1) + "/" + loopState.maxIterations);
|
|
707
|
+
send({ type: "loop_started", maxIterations: loopState.maxIterations });
|
|
708
|
+
runNextIteration();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function stopLoop() {
|
|
712
|
+
if (!loopState.active) return;
|
|
713
|
+
console.log("[ralph-loop] stopLoop called");
|
|
714
|
+
loopState.stopping = true;
|
|
715
|
+
|
|
716
|
+
// Abort all loop-related sessions (coder + judge)
|
|
717
|
+
var sessionIds = [loopState.currentSessionId, loopState.judgeSessionId];
|
|
718
|
+
for (var i = 0; i < sessionIds.length; i++) {
|
|
719
|
+
if (sessionIds[i] == null) continue;
|
|
720
|
+
var s = sm.sessions.get(sessionIds[i]);
|
|
721
|
+
if (!s) continue;
|
|
722
|
+
// End message queue so SDK exits prompt wait
|
|
723
|
+
if (s.messageQueue) { try { s.messageQueue.end(); } catch (e) {} }
|
|
724
|
+
// Abort active API call
|
|
725
|
+
if (s.abortController) { try { s.abortController.abort(); } catch (e) {} }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
send({ type: "loop_stopping" });
|
|
729
|
+
|
|
730
|
+
// Fallback: force finish if onQueryComplete hasn't fired after 5s
|
|
731
|
+
setTimeout(function() {
|
|
732
|
+
if (loopState.active && loopState.stopping) {
|
|
733
|
+
console.log("[ralph-loop] Stop fallback triggered — forcing finishLoop");
|
|
734
|
+
finishLoop("stopped");
|
|
735
|
+
}
|
|
736
|
+
}, 5000);
|
|
737
|
+
}
|
|
738
|
+
|
|
209
739
|
// --- Terminal manager ---
|
|
210
740
|
var tm = createTerminalManager({ cwd: cwd, send: send, sendTo: sendTo });
|
|
211
741
|
var nm = createNotesManager({ cwd: cwd, send: send, sendTo: sendTo });
|
|
@@ -223,6 +753,12 @@ function createProjectContext(opts) {
|
|
|
223
753
|
clients.add(ws);
|
|
224
754
|
broadcastClientCount();
|
|
225
755
|
|
|
756
|
+
// Resume loop if server restarted mid-execution (deferred so client gets initial state first)
|
|
757
|
+
if (loopState._needsResume) {
|
|
758
|
+
delete loopState._needsResume;
|
|
759
|
+
setTimeout(function() { resumeLoop(); }, 500);
|
|
760
|
+
}
|
|
761
|
+
|
|
226
762
|
// Send cached state
|
|
227
763
|
sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, dangerouslySkipPermissions: dangerouslySkipPermissions, lanHost: lanHost, projectCount: getProjectCount(), projects: getProjectList() });
|
|
228
764
|
if (latestVersion) {
|
|
@@ -234,10 +770,48 @@ function createProjectContext(opts) {
|
|
|
234
770
|
if (sm.currentModel) {
|
|
235
771
|
sendTo(ws, { type: "model_info", model: sm.currentModel, models: sm.availableModels || [] });
|
|
236
772
|
}
|
|
237
|
-
sendTo(ws, { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "
|
|
773
|
+
sendTo(ws, { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
|
|
238
774
|
sendTo(ws, { type: "term_list", terminals: tm.list() });
|
|
239
775
|
sendTo(ws, { type: "notes_list", notes: nm.list() });
|
|
240
776
|
|
|
777
|
+
// Ralph Loop availability
|
|
778
|
+
var hasLoopFiles = false;
|
|
779
|
+
try {
|
|
780
|
+
fs.accessSync(path.join(cwd, ".claude", "PROMPT.md"));
|
|
781
|
+
fs.accessSync(path.join(cwd, ".claude", "JUDGE.md"));
|
|
782
|
+
hasLoopFiles = true;
|
|
783
|
+
} catch (e) {}
|
|
784
|
+
sendTo(ws, {
|
|
785
|
+
type: "loop_available",
|
|
786
|
+
available: hasLoopFiles,
|
|
787
|
+
active: loopState.active,
|
|
788
|
+
iteration: loopState.iteration,
|
|
789
|
+
maxIterations: loopState.maxIterations,
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// Ralph phase state
|
|
793
|
+
sendTo(ws, {
|
|
794
|
+
type: "ralph_phase",
|
|
795
|
+
phase: loopState.phase,
|
|
796
|
+
wizardData: loopState.wizardData,
|
|
797
|
+
craftingSessionId: loopState.craftingSessionId || null,
|
|
798
|
+
});
|
|
799
|
+
if (loopState.phase === "crafting" || loopState.phase === "approval") {
|
|
800
|
+
var _hasPrompt = false;
|
|
801
|
+
var _hasJudge = false;
|
|
802
|
+
var _lDir = loopDir();
|
|
803
|
+
if (_lDir) {
|
|
804
|
+
try { fs.accessSync(path.join(_lDir, "PROMPT.md")); _hasPrompt = true; } catch (e) {}
|
|
805
|
+
try { fs.accessSync(path.join(_lDir, "JUDGE.md")); _hasJudge = true; } catch (e) {}
|
|
806
|
+
}
|
|
807
|
+
sendTo(ws, {
|
|
808
|
+
type: "ralph_files_status",
|
|
809
|
+
promptReady: _hasPrompt,
|
|
810
|
+
judgeReady: _hasJudge,
|
|
811
|
+
bothReady: _hasPrompt && _hasJudge,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
241
815
|
// Session list
|
|
242
816
|
sendTo(ws, {
|
|
243
817
|
type: "session_list",
|
|
@@ -489,13 +1063,70 @@ function createProjectContext(opts) {
|
|
|
489
1063
|
return;
|
|
490
1064
|
}
|
|
491
1065
|
|
|
1066
|
+
if (msg.type === "set_server_default_model" && msg.model) {
|
|
1067
|
+
if (typeof opts.onSetServerDefaultModel === "function") {
|
|
1068
|
+
opts.onSetServerDefaultModel(msg.model);
|
|
1069
|
+
}
|
|
1070
|
+
var session = sm.getActiveSession();
|
|
1071
|
+
if (session) {
|
|
1072
|
+
sdk.setModel(session, msg.model);
|
|
1073
|
+
}
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
if (msg.type === "set_project_default_model" && msg.model) {
|
|
1078
|
+
if (typeof opts.onSetProjectDefaultModel === "function") {
|
|
1079
|
+
opts.onSetProjectDefaultModel(slug, msg.model);
|
|
1080
|
+
}
|
|
1081
|
+
var session = sm.getActiveSession();
|
|
1082
|
+
if (session) {
|
|
1083
|
+
sdk.setModel(session, msg.model);
|
|
1084
|
+
}
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
492
1088
|
if (msg.type === "set_permission_mode" && msg.mode) {
|
|
1089
|
+
// When dangerouslySkipPermissions is active, don't allow UI to change mode
|
|
1090
|
+
if (dangerouslySkipPermissions) {
|
|
1091
|
+
send({ type: "config_state", model: sm.currentModel || "", mode: "bypassPermissions", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
493
1094
|
sm.currentPermissionMode = msg.mode;
|
|
494
1095
|
var session = sm.getActiveSession();
|
|
495
1096
|
if (session) {
|
|
496
1097
|
sdk.setPermissionMode(session, msg.mode);
|
|
497
1098
|
}
|
|
498
|
-
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "
|
|
1099
|
+
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (msg.type === "set_server_default_mode" && msg.mode) {
|
|
1104
|
+
if (typeof opts.onSetServerDefaultMode === "function") {
|
|
1105
|
+
opts.onSetServerDefaultMode(msg.mode);
|
|
1106
|
+
}
|
|
1107
|
+
if (!dangerouslySkipPermissions) {
|
|
1108
|
+
sm.currentPermissionMode = msg.mode;
|
|
1109
|
+
var session = sm.getActiveSession();
|
|
1110
|
+
if (session) {
|
|
1111
|
+
sdk.setPermissionMode(session, msg.mode);
|
|
1112
|
+
}
|
|
1113
|
+
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
|
|
1114
|
+
}
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (msg.type === "set_project_default_mode" && msg.mode) {
|
|
1119
|
+
if (typeof opts.onSetProjectDefaultMode === "function") {
|
|
1120
|
+
opts.onSetProjectDefaultMode(slug, msg.mode);
|
|
1121
|
+
}
|
|
1122
|
+
if (!dangerouslySkipPermissions) {
|
|
1123
|
+
sm.currentPermissionMode = msg.mode;
|
|
1124
|
+
var session = sm.getActiveSession();
|
|
1125
|
+
if (session) {
|
|
1126
|
+
sdk.setPermissionMode(session, msg.mode);
|
|
1127
|
+
}
|
|
1128
|
+
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
|
|
1129
|
+
}
|
|
499
1130
|
return;
|
|
500
1131
|
}
|
|
501
1132
|
|
|
@@ -505,9 +1136,27 @@ function createProjectContext(opts) {
|
|
|
505
1136
|
return;
|
|
506
1137
|
}
|
|
507
1138
|
|
|
1139
|
+
if (msg.type === "set_server_default_effort" && msg.effort) {
|
|
1140
|
+
if (typeof opts.onSetServerDefaultEffort === "function") {
|
|
1141
|
+
opts.onSetServerDefaultEffort(msg.effort);
|
|
1142
|
+
}
|
|
1143
|
+
sm.currentEffort = msg.effort;
|
|
1144
|
+
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (msg.type === "set_project_default_effort" && msg.effort) {
|
|
1149
|
+
if (typeof opts.onSetProjectDefaultEffort === "function") {
|
|
1150
|
+
opts.onSetProjectDefaultEffort(slug, msg.effort);
|
|
1151
|
+
}
|
|
1152
|
+
sm.currentEffort = msg.effort;
|
|
1153
|
+
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
508
1157
|
if (msg.type === "set_betas") {
|
|
509
1158
|
sm.currentBetas = msg.betas || [];
|
|
510
|
-
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "
|
|
1159
|
+
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas });
|
|
511
1160
|
return;
|
|
512
1161
|
}
|
|
513
1162
|
|
|
@@ -593,6 +1242,7 @@ function createProjectContext(opts) {
|
|
|
593
1242
|
session.pendingPermissions = {};
|
|
594
1243
|
session.pendingAskUser = {};
|
|
595
1244
|
session.isProcessing = false;
|
|
1245
|
+
onProcessingChanged();
|
|
596
1246
|
|
|
597
1247
|
sm.saveSessionFile(session);
|
|
598
1248
|
sm.switchSession(session.localId);
|
|
@@ -641,7 +1291,7 @@ function createProjectContext(opts) {
|
|
|
641
1291
|
if (decision === "allow_accept_edits") {
|
|
642
1292
|
sdk.setPermissionMode(session, "acceptEdits");
|
|
643
1293
|
sm.currentPermissionMode = "acceptEdits";
|
|
644
|
-
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "
|
|
1294
|
+
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
|
|
645
1295
|
pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
|
|
646
1296
|
sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
|
|
647
1297
|
return;
|
|
@@ -653,14 +1303,24 @@ function createProjectContext(opts) {
|
|
|
653
1303
|
pending.resolve({ behavior: "deny", message: "User chose to clear context and restart" });
|
|
654
1304
|
sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
|
|
655
1305
|
|
|
656
|
-
// Abort the old session's query
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1306
|
+
// Abort the old session's query — but defer to next tick so the SDK's
|
|
1307
|
+
// deny write (scheduled as microtask by pending.resolve) completes first.
|
|
1308
|
+
// Aborting synchronously would kill the subprocess before the write,
|
|
1309
|
+
// causing an "Operation aborted" crash in the SDK.
|
|
660
1310
|
session.isProcessing = false;
|
|
1311
|
+
onProcessingChanged();
|
|
661
1312
|
session.pendingPermissions = {};
|
|
662
1313
|
session.pendingAskUser = {};
|
|
663
1314
|
sm.broadcastSessionList();
|
|
1315
|
+
setImmediate(function () {
|
|
1316
|
+
if (session.abortController) {
|
|
1317
|
+
session.abortController.abort();
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
// Update permission mode for the new session
|
|
1322
|
+
sm.currentPermissionMode = "acceptEdits";
|
|
1323
|
+
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
|
|
664
1324
|
|
|
665
1325
|
// Build prompt from plan content (sent from client) or plan file path
|
|
666
1326
|
var clientPlanContent = msg.planContent || "";
|
|
@@ -672,24 +1332,34 @@ function createProjectContext(opts) {
|
|
|
672
1332
|
planPrompt = "Execute the plan in " + planFilePath + ". Do NOT re-enter plan mode — read the plan file and implement it step by step.";
|
|
673
1333
|
}
|
|
674
1334
|
|
|
675
|
-
// Wait
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1335
|
+
// Wait for old query stream to fully terminate, then create new session + send plan
|
|
1336
|
+
var oldStreamPromise = session.streamPromise || Promise.resolve();
|
|
1337
|
+
Promise.race([
|
|
1338
|
+
oldStreamPromise,
|
|
1339
|
+
new Promise(function (resolve) { setTimeout(resolve, 3000); }),
|
|
1340
|
+
]).then(function () {
|
|
1341
|
+
try {
|
|
1342
|
+
var newSession = sm.createSession();
|
|
1343
|
+
// Send the plan as the first user message (with planContent for UI rendering)
|
|
1344
|
+
var userMsg = { type: "user_message", text: planPrompt, planContent: clientPlanContent || null };
|
|
1345
|
+
newSession.history.push(userMsg);
|
|
1346
|
+
sm.appendToSessionFile(newSession, userMsg);
|
|
1347
|
+
newSession.title = "Plan execution (cleared context)";
|
|
1348
|
+
sm.saveSessionFile(newSession);
|
|
1349
|
+
sm.broadcastSessionList();
|
|
1350
|
+
send(userMsg);
|
|
686
1351
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
1352
|
+
newSession.isProcessing = true;
|
|
1353
|
+
onProcessingChanged();
|
|
1354
|
+
newSession.sentToolResults = {};
|
|
1355
|
+
send({ type: "status", status: "processing" });
|
|
1356
|
+
newSession.acceptEditsAfterStart = true;
|
|
1357
|
+
sdk.startQuery(newSession, planPrompt);
|
|
1358
|
+
} catch (e) {
|
|
1359
|
+
console.error("[project] Error starting plan execution:", e);
|
|
1360
|
+
send({ type: "error", text: "Failed to start plan execution: " + (e.message || e) });
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
693
1363
|
return;
|
|
694
1364
|
}
|
|
695
1365
|
|
|
@@ -709,6 +1379,7 @@ function createProjectContext(opts) {
|
|
|
709
1379
|
|
|
710
1380
|
if (!session.isProcessing) {
|
|
711
1381
|
session.isProcessing = true;
|
|
1382
|
+
onProcessingChanged();
|
|
712
1383
|
session.sentToolResults = {};
|
|
713
1384
|
send({ type: "status", status: "processing" });
|
|
714
1385
|
if (!session.queryInstance) {
|
|
@@ -820,6 +1491,52 @@ function createProjectContext(opts) {
|
|
|
820
1491
|
return;
|
|
821
1492
|
}
|
|
822
1493
|
|
|
1494
|
+
// --- Reorder projects ---
|
|
1495
|
+
if (msg.type === "reorder_projects") {
|
|
1496
|
+
var slugs = msg.slugs;
|
|
1497
|
+
if (!Array.isArray(slugs) || slugs.length === 0) {
|
|
1498
|
+
sendTo(ws, { type: "reorder_projects_result", ok: false, error: "Missing slugs" });
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
if (typeof opts.onReorderProjects === "function") {
|
|
1502
|
+
var reorderResult = opts.onReorderProjects(slugs);
|
|
1503
|
+
sendTo(ws, { type: "reorder_projects_result", ok: reorderResult.ok, error: reorderResult.error });
|
|
1504
|
+
} else {
|
|
1505
|
+
sendTo(ws, { type: "reorder_projects_result", ok: false, error: "Not supported" });
|
|
1506
|
+
}
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// --- Set project title (rename) ---
|
|
1511
|
+
if (msg.type === "set_project_title") {
|
|
1512
|
+
if (!msg.slug) {
|
|
1513
|
+
sendTo(ws, { type: "set_project_title_result", ok: false, error: "Missing slug" });
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
if (typeof opts.onSetProjectTitle === "function") {
|
|
1517
|
+
var titleResult = opts.onSetProjectTitle(msg.slug, msg.title || null);
|
|
1518
|
+
sendTo(ws, { type: "set_project_title_result", ok: titleResult.ok, slug: msg.slug, error: titleResult.error });
|
|
1519
|
+
} else {
|
|
1520
|
+
sendTo(ws, { type: "set_project_title_result", ok: false, error: "Not supported" });
|
|
1521
|
+
}
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// --- Set project icon (emoji) ---
|
|
1526
|
+
if (msg.type === "set_project_icon") {
|
|
1527
|
+
if (!msg.slug) {
|
|
1528
|
+
sendTo(ws, { type: "set_project_icon_result", ok: false, error: "Missing slug" });
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
if (typeof opts.onSetProjectIcon === "function") {
|
|
1532
|
+
var iconResult = opts.onSetProjectIcon(msg.slug, msg.icon || null);
|
|
1533
|
+
sendTo(ws, { type: "set_project_icon_result", ok: iconResult.ok, slug: msg.slug, error: iconResult.error });
|
|
1534
|
+
} else {
|
|
1535
|
+
sendTo(ws, { type: "set_project_icon_result", ok: false, error: "Not supported" });
|
|
1536
|
+
}
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
823
1540
|
// --- Daemon config (server settings) ---
|
|
824
1541
|
if (msg.type === "get_daemon_config") {
|
|
825
1542
|
if (typeof opts.onGetDaemonConfig === "function") {
|
|
@@ -860,6 +1577,20 @@ function createProjectContext(opts) {
|
|
|
860
1577
|
return;
|
|
861
1578
|
}
|
|
862
1579
|
|
|
1580
|
+
if (msg.type === "restart_server") {
|
|
1581
|
+
if (typeof opts.onRestart === "function") {
|
|
1582
|
+
sendTo(ws, { type: "restart_server_result", ok: true });
|
|
1583
|
+
send({ type: "toast", level: "info", message: "Server is restarting..." });
|
|
1584
|
+
// Small delay so the response has time to reach clients
|
|
1585
|
+
setTimeout(function () {
|
|
1586
|
+
opts.onRestart();
|
|
1587
|
+
}, 500);
|
|
1588
|
+
} else {
|
|
1589
|
+
sendTo(ws, { type: "restart_server_result", ok: false, error: "Restart not supported" });
|
|
1590
|
+
}
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
863
1594
|
// --- File browser ---
|
|
864
1595
|
if (msg.type === "fs_list") {
|
|
865
1596
|
var fsDir = safePath(cwd, msg.path || ".");
|
|
@@ -915,6 +1646,98 @@ function createProjectContext(opts) {
|
|
|
915
1646
|
return;
|
|
916
1647
|
}
|
|
917
1648
|
|
|
1649
|
+
// --- File write ---
|
|
1650
|
+
if (msg.type === "fs_write") {
|
|
1651
|
+
var fsWriteFile = safePath(cwd, msg.path);
|
|
1652
|
+
if (!fsWriteFile) {
|
|
1653
|
+
sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: "Access denied" });
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
try {
|
|
1657
|
+
fs.writeFileSync(fsWriteFile, msg.content || "", "utf8");
|
|
1658
|
+
sendTo(ws, { type: "fs_write_result", path: msg.path, ok: true });
|
|
1659
|
+
} catch (e) {
|
|
1660
|
+
sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: e.message });
|
|
1661
|
+
}
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// --- Project environment variables ---
|
|
1666
|
+
if (msg.type === "get_project_env") {
|
|
1667
|
+
var envrc = "";
|
|
1668
|
+
var hasEnvrc = false;
|
|
1669
|
+
if (typeof opts.onGetProjectEnv === "function") {
|
|
1670
|
+
var envResult = opts.onGetProjectEnv(msg.slug);
|
|
1671
|
+
envrc = envResult.envrc || "";
|
|
1672
|
+
}
|
|
1673
|
+
try {
|
|
1674
|
+
var envrcPath = path.join(cwd, ".envrc");
|
|
1675
|
+
hasEnvrc = fs.existsSync(envrcPath);
|
|
1676
|
+
} catch (e) {}
|
|
1677
|
+
sendTo(ws, { type: "project_env_result", slug: msg.slug, envrc: envrc, hasEnvrc: hasEnvrc });
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
if (msg.type === "set_project_env") {
|
|
1682
|
+
if (typeof opts.onSetProjectEnv === "function") {
|
|
1683
|
+
var setResult = opts.onSetProjectEnv(msg.slug, msg.envrc || "");
|
|
1684
|
+
sendTo(ws, { type: "set_project_env_result", ok: setResult.ok, slug: msg.slug, error: setResult.error });
|
|
1685
|
+
} else {
|
|
1686
|
+
sendTo(ws, { type: "set_project_env_result", ok: false, error: "Not supported" });
|
|
1687
|
+
}
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// --- Global CLAUDE.md ---
|
|
1692
|
+
if (msg.type === "read_global_claude_md") {
|
|
1693
|
+
var os = require("os");
|
|
1694
|
+
var globalMdPath = path.join(os.homedir(), ".claude", "CLAUDE.md");
|
|
1695
|
+
try {
|
|
1696
|
+
var globalMdContent = fs.readFileSync(globalMdPath, "utf8");
|
|
1697
|
+
sendTo(ws, { type: "global_claude_md_result", content: globalMdContent });
|
|
1698
|
+
} catch (e) {
|
|
1699
|
+
sendTo(ws, { type: "global_claude_md_result", error: e.message });
|
|
1700
|
+
}
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
if (msg.type === "write_global_claude_md") {
|
|
1705
|
+
var os2 = require("os");
|
|
1706
|
+
var globalMdDir = path.join(os2.homedir(), ".claude");
|
|
1707
|
+
var globalMdWritePath = path.join(globalMdDir, "CLAUDE.md");
|
|
1708
|
+
try {
|
|
1709
|
+
if (!fs.existsSync(globalMdDir)) {
|
|
1710
|
+
fs.mkdirSync(globalMdDir, { recursive: true });
|
|
1711
|
+
}
|
|
1712
|
+
fs.writeFileSync(globalMdWritePath, msg.content || "", "utf8");
|
|
1713
|
+
sendTo(ws, { type: "write_global_claude_md_result", ok: true });
|
|
1714
|
+
} catch (e) {
|
|
1715
|
+
sendTo(ws, { type: "write_global_claude_md_result", ok: false, error: e.message });
|
|
1716
|
+
}
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// --- Shared environment variables ---
|
|
1721
|
+
if (msg.type === "get_shared_env") {
|
|
1722
|
+
var sharedEnvrc = "";
|
|
1723
|
+
if (typeof opts.onGetSharedEnv === "function") {
|
|
1724
|
+
var sharedResult = opts.onGetSharedEnv();
|
|
1725
|
+
sharedEnvrc = sharedResult.envrc || "";
|
|
1726
|
+
}
|
|
1727
|
+
sendTo(ws, { type: "shared_env_result", envrc: sharedEnvrc });
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
if (msg.type === "set_shared_env") {
|
|
1732
|
+
if (typeof opts.onSetSharedEnv === "function") {
|
|
1733
|
+
var sharedSetResult = opts.onSetSharedEnv(msg.envrc || "");
|
|
1734
|
+
sendTo(ws, { type: "set_shared_env_result", ok: sharedSetResult.ok, error: sharedSetResult.error });
|
|
1735
|
+
} else {
|
|
1736
|
+
sendTo(ws, { type: "set_shared_env_result", ok: false, error: "Not supported" });
|
|
1737
|
+
}
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
918
1741
|
// --- File watcher ---
|
|
919
1742
|
if (msg.type === "fs_watch") {
|
|
920
1743
|
if (msg.path) startFileWatch(msg.path);
|
|
@@ -1195,6 +2018,117 @@ function createProjectContext(opts) {
|
|
|
1195
2018
|
return;
|
|
1196
2019
|
}
|
|
1197
2020
|
|
|
2021
|
+
if (msg.type === "loop_start") {
|
|
2022
|
+
startLoop();
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
if (msg.type === "loop_stop") {
|
|
2027
|
+
stopLoop();
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
if (msg.type === "ralph_wizard_complete") {
|
|
2032
|
+
var wData = msg.data || {};
|
|
2033
|
+
var maxIter = wData.maxIterations || 25;
|
|
2034
|
+
var newLoopId = generateLoopId();
|
|
2035
|
+
loopState.loopId = newLoopId;
|
|
2036
|
+
loopState.wizardData = {
|
|
2037
|
+
name: (wData.name || "").replace(/[^a-zA-Z0-9_-]/g, "") || "ralph",
|
|
2038
|
+
task: wData.task || "",
|
|
2039
|
+
maxIterations: maxIter,
|
|
2040
|
+
};
|
|
2041
|
+
loopState.phase = "crafting";
|
|
2042
|
+
loopState.startedAt = Date.now();
|
|
2043
|
+
saveLoopState();
|
|
2044
|
+
|
|
2045
|
+
// Create loop directory and write LOOP.json
|
|
2046
|
+
var lDir = loopDir();
|
|
2047
|
+
try { fs.mkdirSync(lDir, { recursive: true }); } catch (e) {}
|
|
2048
|
+
var loopJsonPath = path.join(lDir, "LOOP.json");
|
|
2049
|
+
var tmpLoopJson = loopJsonPath + ".tmp";
|
|
2050
|
+
fs.writeFileSync(tmpLoopJson, JSON.stringify({ maxIterations: maxIter }, null, 2));
|
|
2051
|
+
fs.renameSync(tmpLoopJson, loopJsonPath);
|
|
2052
|
+
|
|
2053
|
+
// 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;
|
|
2056
|
+
|
|
2057
|
+
// Create a new session for crafting
|
|
2058
|
+
var craftingSession = sm.createSession();
|
|
2059
|
+
var craftName = (loopState.wizardData && loopState.wizardData.name) || "";
|
|
2060
|
+
craftingSession.title = "Ralph" + (craftName ? " " + craftName : "") + " Crafting";
|
|
2061
|
+
craftingSession.ralphCraftingMode = true;
|
|
2062
|
+
sm.saveSessionFile(craftingSession);
|
|
2063
|
+
sm.switchSession(craftingSession.localId);
|
|
2064
|
+
sm.broadcastSessionList();
|
|
2065
|
+
loopState.craftingSessionId = craftingSession.localId;
|
|
2066
|
+
|
|
2067
|
+
// Start .claude/ directory watcher
|
|
2068
|
+
startClaudeDirWatch();
|
|
2069
|
+
|
|
2070
|
+
// Start query
|
|
2071
|
+
craftingSession.history.push({ type: "user_message", text: craftingPrompt });
|
|
2072
|
+
sm.appendToSessionFile(craftingSession, { type: "user_message", text: craftingPrompt });
|
|
2073
|
+
send({ type: "user_message", text: craftingPrompt });
|
|
2074
|
+
craftingSession.isProcessing = true;
|
|
2075
|
+
onProcessingChanged();
|
|
2076
|
+
craftingSession.sentToolResults = {};
|
|
2077
|
+
send({ type: "status", status: "processing" });
|
|
2078
|
+
sdk.startQuery(craftingSession, craftingPrompt);
|
|
2079
|
+
|
|
2080
|
+
send({ type: "ralph_crafting_started", sessionId: craftingSession.localId });
|
|
2081
|
+
send({ type: "ralph_phase", phase: "crafting", wizardData: loopState.wizardData, craftingSessionId: craftingSession.localId });
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
if (msg.type === "ralph_preview_files") {
|
|
2086
|
+
var promptContent = "";
|
|
2087
|
+
var judgeContent = "";
|
|
2088
|
+
var previewDir = loopDir();
|
|
2089
|
+
if (previewDir) {
|
|
2090
|
+
try { promptContent = fs.readFileSync(path.join(previewDir, "PROMPT.md"), "utf8"); } catch (e) {}
|
|
2091
|
+
try { judgeContent = fs.readFileSync(path.join(previewDir, "JUDGE.md"), "utf8"); } catch (e) {}
|
|
2092
|
+
}
|
|
2093
|
+
sendTo(ws, {
|
|
2094
|
+
type: "ralph_files_content",
|
|
2095
|
+
prompt: promptContent,
|
|
2096
|
+
judge: judgeContent,
|
|
2097
|
+
});
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
if (msg.type === "ralph_wizard_cancel") {
|
|
2102
|
+
stopClaudeDirWatch();
|
|
2103
|
+
// Clean up loop directory
|
|
2104
|
+
var cancelDir = loopDir();
|
|
2105
|
+
if (cancelDir) {
|
|
2106
|
+
try { fs.rmSync(cancelDir, { recursive: true, force: true }); } catch (e) {}
|
|
2107
|
+
}
|
|
2108
|
+
clearLoopState();
|
|
2109
|
+
send({ type: "ralph_phase", phase: "idle", wizardData: null });
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
if (msg.type === "ralph_cancel_crafting") {
|
|
2114
|
+
// Abort the crafting session if running
|
|
2115
|
+
if (loopState.craftingSessionId != null) {
|
|
2116
|
+
var craftSession = sm.sessions.get(loopState.craftingSessionId) || null;
|
|
2117
|
+
if (craftSession && craftSession.abortController) {
|
|
2118
|
+
craftSession.abortController.abort();
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
stopClaudeDirWatch();
|
|
2122
|
+
// Clean up loop directory
|
|
2123
|
+
var craftCancelDir = loopDir();
|
|
2124
|
+
if (craftCancelDir) {
|
|
2125
|
+
try { fs.rmSync(craftCancelDir, { recursive: true, force: true }); } catch (e) {}
|
|
2126
|
+
}
|
|
2127
|
+
clearLoopState();
|
|
2128
|
+
send({ type: "ralph_phase", phase: "idle", wizardData: null });
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
1198
2132
|
if (msg.type !== "message") return;
|
|
1199
2133
|
if (!msg.text && (!msg.images || msg.images.length === 0) && (!msg.pastes || msg.pastes.length === 0)) return;
|
|
1200
2134
|
|
|
@@ -1228,6 +2162,7 @@ function createProjectContext(opts) {
|
|
|
1228
2162
|
|
|
1229
2163
|
if (!session.isProcessing) {
|
|
1230
2164
|
session.isProcessing = true;
|
|
2165
|
+
onProcessingChanged();
|
|
1231
2166
|
session.sentToolResults = {};
|
|
1232
2167
|
send({ type: "status", status: "processing" });
|
|
1233
2168
|
if (!session.queryInstance) {
|
|
@@ -1254,6 +2189,54 @@ function createProjectContext(opts) {
|
|
|
1254
2189
|
|
|
1255
2190
|
// --- Handle project-scoped HTTP requests ---
|
|
1256
2191
|
function handleHTTP(req, res, urlPath) {
|
|
2192
|
+
// File upload
|
|
2193
|
+
if (req.method === "POST" && urlPath === "/api/upload") {
|
|
2194
|
+
parseJsonBody(req).then(function (body) {
|
|
2195
|
+
var fileName = body.name;
|
|
2196
|
+
var fileData = body.data; // base64
|
|
2197
|
+
if (!fileName || !fileData) {
|
|
2198
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2199
|
+
res.end('{"error":"missing name or data"}');
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
// Sanitize filename — strip path separators
|
|
2203
|
+
var safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._\-\(\)\[\] ]/g, "_");
|
|
2204
|
+
if (!safeName) safeName = "upload";
|
|
2205
|
+
|
|
2206
|
+
// Check size
|
|
2207
|
+
var estimatedBytes = fileData.length * 0.75;
|
|
2208
|
+
if (estimatedBytes > MAX_UPLOAD_BYTES) {
|
|
2209
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
2210
|
+
res.end('{"error":"file too large (max 50MB)"}');
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
// Create tmp dir: os.tmpdir()/clay-{hash}/
|
|
2215
|
+
var cwdHash = crypto.createHash("sha256").update(cwd).digest("hex").substring(0, 12);
|
|
2216
|
+
var tmpDir = path.join(os.tmpdir(), "clay-" + cwdHash);
|
|
2217
|
+
try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (e) {}
|
|
2218
|
+
|
|
2219
|
+
// Add timestamp prefix to avoid collisions
|
|
2220
|
+
var ts = Date.now();
|
|
2221
|
+
var destName = ts + "-" + safeName;
|
|
2222
|
+
var destPath = path.join(tmpDir, destName);
|
|
2223
|
+
|
|
2224
|
+
try {
|
|
2225
|
+
var buf = Buffer.from(fileData, "base64");
|
|
2226
|
+
fs.writeFileSync(destPath, buf);
|
|
2227
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2228
|
+
res.end(JSON.stringify({ path: destPath, name: safeName }));
|
|
2229
|
+
} catch (e) {
|
|
2230
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2231
|
+
res.end(JSON.stringify({ error: "failed to save: " + (e.message || e) }));
|
|
2232
|
+
}
|
|
2233
|
+
}).catch(function () {
|
|
2234
|
+
res.writeHead(400);
|
|
2235
|
+
res.end("Bad request");
|
|
2236
|
+
});
|
|
2237
|
+
return true;
|
|
2238
|
+
}
|
|
2239
|
+
|
|
1257
2240
|
// Push subscribe
|
|
1258
2241
|
if (req.method === "POST" && urlPath === "/api/push-subscribe") {
|
|
1259
2242
|
parseJsonBody(req).then(function (body) {
|
|
@@ -1342,6 +2325,157 @@ function createProjectContext(opts) {
|
|
|
1342
2325
|
return true;
|
|
1343
2326
|
}
|
|
1344
2327
|
|
|
2328
|
+
// Install a skill (background spawn)
|
|
2329
|
+
if (req.method === "POST" && urlPath === "/api/install-skill") {
|
|
2330
|
+
parseJsonBody(req).then(function (body) {
|
|
2331
|
+
var url = body.url;
|
|
2332
|
+
var skill = body.skill;
|
|
2333
|
+
var scope = body.scope; // "global" or "project"
|
|
2334
|
+
if (!url || !skill || !scope) {
|
|
2335
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2336
|
+
res.end('{"error":"missing url, skill, or scope"}');
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
var spawnCwd = scope === "global" ? os.homedir() : cwd;
|
|
2340
|
+
var child = spawn("npx", ["skills", "add", url, "--skill", skill], {
|
|
2341
|
+
cwd: spawnCwd,
|
|
2342
|
+
stdio: "ignore",
|
|
2343
|
+
detached: false,
|
|
2344
|
+
});
|
|
2345
|
+
child.on("close", function (code) {
|
|
2346
|
+
var success = code === 0;
|
|
2347
|
+
send({
|
|
2348
|
+
type: "skill_installed",
|
|
2349
|
+
skill: skill,
|
|
2350
|
+
scope: scope,
|
|
2351
|
+
success: success,
|
|
2352
|
+
error: success ? null : "Process exited with code " + code,
|
|
2353
|
+
});
|
|
2354
|
+
});
|
|
2355
|
+
child.on("error", function (err) {
|
|
2356
|
+
send({
|
|
2357
|
+
type: "skill_installed",
|
|
2358
|
+
skill: skill,
|
|
2359
|
+
scope: scope,
|
|
2360
|
+
success: false,
|
|
2361
|
+
error: err.message,
|
|
2362
|
+
});
|
|
2363
|
+
});
|
|
2364
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2365
|
+
res.end('{"ok":true}');
|
|
2366
|
+
}).catch(function () {
|
|
2367
|
+
res.writeHead(400);
|
|
2368
|
+
res.end("Bad request");
|
|
2369
|
+
});
|
|
2370
|
+
return true;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
// Uninstall a skill (remove directory)
|
|
2374
|
+
if (req.method === "POST" && urlPath === "/api/uninstall-skill") {
|
|
2375
|
+
parseJsonBody(req).then(function (body) {
|
|
2376
|
+
var skill = body.skill;
|
|
2377
|
+
var scope = body.scope; // "global" or "project"
|
|
2378
|
+
if (!skill || !scope) {
|
|
2379
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2380
|
+
res.end('{"error":"missing skill or scope"}');
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
var baseDir = scope === "global" ? os.homedir() : cwd;
|
|
2384
|
+
var skillDir = path.join(baseDir, ".claude", "skills", skill);
|
|
2385
|
+
// Safety: ensure skillDir is inside the expected .claude/skills directory
|
|
2386
|
+
var expectedParent = path.join(baseDir, ".claude", "skills");
|
|
2387
|
+
var resolved = path.resolve(skillDir);
|
|
2388
|
+
if (!resolved.startsWith(expectedParent + path.sep)) {
|
|
2389
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2390
|
+
res.end('{"error":"invalid skill path"}');
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
try {
|
|
2394
|
+
fs.rmSync(resolved, { recursive: true, force: true });
|
|
2395
|
+
send({
|
|
2396
|
+
type: "skill_uninstalled",
|
|
2397
|
+
skill: skill,
|
|
2398
|
+
scope: scope,
|
|
2399
|
+
success: true,
|
|
2400
|
+
});
|
|
2401
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2402
|
+
res.end('{"ok":true}');
|
|
2403
|
+
} catch (err) {
|
|
2404
|
+
send({
|
|
2405
|
+
type: "skill_uninstalled",
|
|
2406
|
+
skill: skill,
|
|
2407
|
+
scope: scope,
|
|
2408
|
+
success: false,
|
|
2409
|
+
error: err.message,
|
|
2410
|
+
});
|
|
2411
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2412
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2413
|
+
}
|
|
2414
|
+
}).catch(function () {
|
|
2415
|
+
res.writeHead(400);
|
|
2416
|
+
res.end("Bad request");
|
|
2417
|
+
});
|
|
2418
|
+
return true;
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
// Installed skills (global + project)
|
|
2422
|
+
if (req.method === "GET" && urlPath === "/api/installed-skills") {
|
|
2423
|
+
var installed = {};
|
|
2424
|
+
var globalDir = path.join(os.homedir(), ".claude", "skills");
|
|
2425
|
+
var projectDir = path.join(cwd, ".claude", "skills");
|
|
2426
|
+
var scanDirs = [
|
|
2427
|
+
{ dir: globalDir, scope: "global" },
|
|
2428
|
+
{ dir: projectDir, scope: "project" },
|
|
2429
|
+
];
|
|
2430
|
+
for (var sd = 0; sd < scanDirs.length; sd++) {
|
|
2431
|
+
var entries;
|
|
2432
|
+
try { entries = fs.readdirSync(scanDirs[sd].dir, { withFileTypes: true }); } catch (e) { continue; }
|
|
2433
|
+
for (var si = 0; si < entries.length; si++) {
|
|
2434
|
+
var ent = entries[si];
|
|
2435
|
+
if (!ent.isDirectory() && !ent.isSymbolicLink()) continue;
|
|
2436
|
+
var mdPath = path.join(scanDirs[sd].dir, ent.name, "SKILL.md");
|
|
2437
|
+
try {
|
|
2438
|
+
var mdContent = fs.readFileSync(mdPath, "utf8");
|
|
2439
|
+
var desc = "";
|
|
2440
|
+
// Parse YAML frontmatter for description
|
|
2441
|
+
if (mdContent.startsWith("---")) {
|
|
2442
|
+
var endIdx = mdContent.indexOf("---", 3);
|
|
2443
|
+
if (endIdx !== -1) {
|
|
2444
|
+
var frontmatter = mdContent.substring(3, endIdx);
|
|
2445
|
+
var descMatch = frontmatter.match(/^description:\s*(.+)/m);
|
|
2446
|
+
if (descMatch) desc = descMatch[1].trim();
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
if (!installed[ent.name]) {
|
|
2450
|
+
installed[ent.name] = { scope: scanDirs[sd].scope, description: desc, path: path.join(scanDirs[sd].dir, ent.name) };
|
|
2451
|
+
} else {
|
|
2452
|
+
// project-level adds to existing global entry
|
|
2453
|
+
installed[ent.name].scope = "both";
|
|
2454
|
+
if (desc && !installed[ent.name].description) installed[ent.name].description = desc;
|
|
2455
|
+
}
|
|
2456
|
+
} catch (e) {}
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2460
|
+
res.end(JSON.stringify({ installed: installed }));
|
|
2461
|
+
return true;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
// Git dirty check
|
|
2465
|
+
if (req.method === "GET" && urlPath === "/api/git-dirty") {
|
|
2466
|
+
var execSync = require("child_process").execSync;
|
|
2467
|
+
try {
|
|
2468
|
+
var out = execSync("git status --porcelain", { cwd: cwd, encoding: "utf8", timeout: 5000 });
|
|
2469
|
+
var dirty = out.trim().length > 0;
|
|
2470
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2471
|
+
res.end(JSON.stringify({ dirty: dirty }));
|
|
2472
|
+
} catch (e) {
|
|
2473
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2474
|
+
res.end(JSON.stringify({ dirty: false }));
|
|
2475
|
+
}
|
|
2476
|
+
return true;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
1345
2479
|
// Info endpoint
|
|
1346
2480
|
if (req.method === "GET" && urlPath === "/info") {
|
|
1347
2481
|
res.writeHead(200, {
|
|
@@ -1374,6 +2508,12 @@ function createProjectContext(opts) {
|
|
|
1374
2508
|
try { ws.close(); } catch (e) {}
|
|
1375
2509
|
}
|
|
1376
2510
|
clients.clear();
|
|
2511
|
+
// Cleanup tmp upload directory
|
|
2512
|
+
try {
|
|
2513
|
+
var cwdHash = crypto.createHash("sha256").update(cwd).digest("hex").substring(0, 12);
|
|
2514
|
+
var tmpDir = path.join(os.tmpdir(), "clay-" + cwdHash);
|
|
2515
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2516
|
+
} catch (e) {}
|
|
1377
2517
|
}
|
|
1378
2518
|
|
|
1379
2519
|
// --- Status info ---
|
|
@@ -1388,6 +2528,7 @@ function createProjectContext(opts) {
|
|
|
1388
2528
|
path: cwd,
|
|
1389
2529
|
project: project,
|
|
1390
2530
|
title: title,
|
|
2531
|
+
icon: icon,
|
|
1391
2532
|
clients: clients.size,
|
|
1392
2533
|
sessions: sessionCount,
|
|
1393
2534
|
isProcessing: hasProcessing,
|
|
@@ -1399,6 +2540,10 @@ function createProjectContext(opts) {
|
|
|
1399
2540
|
send({ type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, lanHost: lanHost, projectCount: getProjectCount(), projects: getProjectList() });
|
|
1400
2541
|
}
|
|
1401
2542
|
|
|
2543
|
+
function setIcon(newIcon) {
|
|
2544
|
+
icon = newIcon || null;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
1402
2547
|
return {
|
|
1403
2548
|
cwd: cwd,
|
|
1404
2549
|
slug: slug,
|
|
@@ -1414,6 +2559,7 @@ function createProjectContext(opts) {
|
|
|
1414
2559
|
handleHTTP: handleHTTP,
|
|
1415
2560
|
getStatus: getStatus,
|
|
1416
2561
|
setTitle: setTitle,
|
|
2562
|
+
setIcon: setIcon,
|
|
1417
2563
|
warmup: function () { sdk.warmup(); },
|
|
1418
2564
|
destroy: destroy,
|
|
1419
2565
|
};
|