clay-server 2.26.0 → 2.27.0-beta.1
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/lib/project-connection.js +259 -0
- package/lib/project-file-watch.js +120 -0
- package/lib/project-filesystem.js +482 -0
- package/lib/project-http.js +685 -0
- package/lib/project-image.js +94 -0
- package/lib/project-knowledge.js +161 -0
- package/lib/project-loop.js +1160 -0
- package/lib/project-sessions.js +1152 -0
- package/lib/project-user-message.js +631 -0
- package/lib/project.js +356 -4438
- package/lib/public/app.js +79 -52
- package/lib/server.js +30 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1160 @@
|
|
|
1
|
+
var fs = require("fs");
|
|
2
|
+
var path = require("path");
|
|
3
|
+
var crypto = require("crypto");
|
|
4
|
+
var { execFileSync } = require("child_process");
|
|
5
|
+
var { createLoopRegistry } = require("./scheduler");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Attach loop engine to a project context.
|
|
9
|
+
*
|
|
10
|
+
* ctx fields:
|
|
11
|
+
* cwd, slug, sm, sdk, send, sendTo, sendToSession, pushModule,
|
|
12
|
+
* getHubSchedules, getLinuxUserForSession, onProcessingChanged,
|
|
13
|
+
* hydrateImageRefs
|
|
14
|
+
*/
|
|
15
|
+
function attachLoop(ctx) {
|
|
16
|
+
var cwd = ctx.cwd;
|
|
17
|
+
var slug = ctx.slug;
|
|
18
|
+
var sm = ctx.sm;
|
|
19
|
+
var sdk = ctx.sdk;
|
|
20
|
+
var send = ctx.send;
|
|
21
|
+
var sendTo = ctx.sendTo;
|
|
22
|
+
var sendToSession = ctx.sendToSession;
|
|
23
|
+
var pushModule = ctx.pushModule;
|
|
24
|
+
var getHubSchedules = ctx.getHubSchedules;
|
|
25
|
+
var getLinuxUserForSession = ctx.getLinuxUserForSession;
|
|
26
|
+
var onProcessingChanged = ctx.onProcessingChanged;
|
|
27
|
+
var hydrateImageRefs = ctx.hydrateImageRefs;
|
|
28
|
+
|
|
29
|
+
// --- Ralph Loop state ---
|
|
30
|
+
var loopState = {
|
|
31
|
+
active: false,
|
|
32
|
+
phase: "idle", // idle | crafting | approval | executing | done
|
|
33
|
+
promptText: "",
|
|
34
|
+
judgeText: "",
|
|
35
|
+
iteration: 0,
|
|
36
|
+
maxIterations: 20,
|
|
37
|
+
baseCommit: null,
|
|
38
|
+
currentSessionId: null,
|
|
39
|
+
judgeSessionId: null,
|
|
40
|
+
results: [],
|
|
41
|
+
stopping: false,
|
|
42
|
+
wizardData: null,
|
|
43
|
+
craftingSessionId: null,
|
|
44
|
+
startedAt: null,
|
|
45
|
+
loopId: null,
|
|
46
|
+
loopFilesId: null,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function loopDir() {
|
|
50
|
+
var id = loopState.loopFilesId || loopState.loopId;
|
|
51
|
+
if (!id) return null;
|
|
52
|
+
return path.join(cwd, ".claude", "loops", id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function generateLoopId() {
|
|
56
|
+
return "loop_" + Date.now() + "_" + crypto.randomBytes(3).toString("hex");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Loop state persistence
|
|
60
|
+
var _loopConfig = require("./config");
|
|
61
|
+
var _loopUtils = require("./utils");
|
|
62
|
+
var _loopDir = path.join(_loopConfig.CONFIG_DIR, "loops");
|
|
63
|
+
var _loopEncodedCwd = _loopUtils.resolveEncodedFile(_loopDir, cwd, ".json");
|
|
64
|
+
var _loopStatePath = path.join(_loopDir, _loopEncodedCwd + ".json");
|
|
65
|
+
|
|
66
|
+
function saveLoopState() {
|
|
67
|
+
try {
|
|
68
|
+
fs.mkdirSync(_loopDir, { recursive: true });
|
|
69
|
+
var data = {
|
|
70
|
+
phase: loopState.phase,
|
|
71
|
+
active: loopState.active,
|
|
72
|
+
iteration: loopState.iteration,
|
|
73
|
+
maxIterations: loopState.maxIterations,
|
|
74
|
+
baseCommit: loopState.baseCommit,
|
|
75
|
+
results: loopState.results,
|
|
76
|
+
wizardData: loopState.wizardData,
|
|
77
|
+
startedAt: loopState.startedAt,
|
|
78
|
+
loopId: loopState.loopId,
|
|
79
|
+
loopFilesId: loopState.loopFilesId || null,
|
|
80
|
+
};
|
|
81
|
+
var tmpPath = _loopStatePath + ".tmp";
|
|
82
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
83
|
+
fs.renameSync(tmpPath, _loopStatePath);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.error("[ralph-loop] Failed to save state:", e.message);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function loadLoopState() {
|
|
90
|
+
try {
|
|
91
|
+
var raw = fs.readFileSync(_loopStatePath, "utf8");
|
|
92
|
+
var data = JSON.parse(raw);
|
|
93
|
+
loopState.phase = data.phase || "idle";
|
|
94
|
+
loopState.active = data.active || false;
|
|
95
|
+
loopState.iteration = data.iteration || 0;
|
|
96
|
+
loopState.maxIterations = data.maxIterations || 20;
|
|
97
|
+
loopState.baseCommit = data.baseCommit || null;
|
|
98
|
+
loopState.results = data.results || [];
|
|
99
|
+
loopState.wizardData = data.wizardData || null;
|
|
100
|
+
loopState.startedAt = data.startedAt || null;
|
|
101
|
+
loopState.loopId = data.loopId || null;
|
|
102
|
+
loopState.loopFilesId = data.loopFilesId || null;
|
|
103
|
+
// SDK sessions cannot survive daemon restart
|
|
104
|
+
loopState.currentSessionId = null;
|
|
105
|
+
loopState.judgeSessionId = null;
|
|
106
|
+
loopState.craftingSessionId = null;
|
|
107
|
+
loopState.stopping = false;
|
|
108
|
+
// If was executing, schedule resume after SDK is ready
|
|
109
|
+
if (loopState.phase === "executing" && loopState.active) {
|
|
110
|
+
loopState._needsResume = true;
|
|
111
|
+
}
|
|
112
|
+
// If was crafting, check if files exist and move to approval
|
|
113
|
+
if (loopState.phase === "crafting") {
|
|
114
|
+
var hasFiles = checkLoopFilesExist();
|
|
115
|
+
if (hasFiles) {
|
|
116
|
+
loopState.phase = "approval";
|
|
117
|
+
saveLoopState();
|
|
118
|
+
} else {
|
|
119
|
+
loopState.phase = "idle";
|
|
120
|
+
saveLoopState();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// No saved state, use defaults
|
|
125
|
+
}
|
|
126
|
+
// Recover orphaned loops: if idle but completed loop files exist in .claude/loops/
|
|
127
|
+
if (loopState.phase === "idle") {
|
|
128
|
+
var _loopsBase = path.join(cwd, ".claude", "loops");
|
|
129
|
+
try {
|
|
130
|
+
var _loopDirs = fs.readdirSync(_loopsBase).filter(function (d) {
|
|
131
|
+
return d.indexOf("loop_") === 0;
|
|
132
|
+
});
|
|
133
|
+
for (var _li = 0; _li < _loopDirs.length; _li++) {
|
|
134
|
+
var _ld = path.join(_loopsBase, _loopDirs[_li]);
|
|
135
|
+
try {
|
|
136
|
+
fs.accessSync(path.join(_ld, "PROMPT.md"));
|
|
137
|
+
fs.accessSync(path.join(_ld, "JUDGE.md"));
|
|
138
|
+
fs.accessSync(path.join(_ld, "LOOP.json"));
|
|
139
|
+
// Found a completed loop — recover to approval phase
|
|
140
|
+
loopState.loopId = _loopDirs[_li];
|
|
141
|
+
loopState.phase = "approval";
|
|
142
|
+
var _loopCfg = JSON.parse(fs.readFileSync(path.join(_ld, "LOOP.json"), "utf8"));
|
|
143
|
+
loopState.maxIterations = _loopCfg.maxIterations || 20;
|
|
144
|
+
saveLoopState();
|
|
145
|
+
console.log("[ralph-loop] Recovered orphaned loop: " + _loopDirs[_li]);
|
|
146
|
+
break;
|
|
147
|
+
} catch (e) {}
|
|
148
|
+
}
|
|
149
|
+
} catch (e) {}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function clearLoopState() {
|
|
154
|
+
loopState.active = false;
|
|
155
|
+
loopState.phase = "idle";
|
|
156
|
+
loopState.promptText = "";
|
|
157
|
+
loopState.judgeText = "";
|
|
158
|
+
loopState.iteration = 0;
|
|
159
|
+
loopState.maxIterations = 20;
|
|
160
|
+
loopState.baseCommit = null;
|
|
161
|
+
loopState.currentSessionId = null;
|
|
162
|
+
loopState.judgeSessionId = null;
|
|
163
|
+
loopState.results = [];
|
|
164
|
+
loopState.stopping = false;
|
|
165
|
+
loopState.wizardData = null;
|
|
166
|
+
loopState.craftingSessionId = null;
|
|
167
|
+
loopState.startedAt = null;
|
|
168
|
+
loopState.loopId = null;
|
|
169
|
+
loopState.loopFilesId = null;
|
|
170
|
+
saveLoopState();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function checkLoopFilesExist() {
|
|
174
|
+
var dir = loopDir();
|
|
175
|
+
if (!dir) return false;
|
|
176
|
+
var hasPrompt = false;
|
|
177
|
+
var hasJudge = false;
|
|
178
|
+
try { fs.accessSync(path.join(dir, "PROMPT.md")); hasPrompt = true; } catch (e) {}
|
|
179
|
+
try { fs.accessSync(path.join(dir, "JUDGE.md")); hasJudge = true; } catch (e) {}
|
|
180
|
+
return hasPrompt && hasJudge;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// .claude/ directory watcher for PROMPT.md / JUDGE.md
|
|
184
|
+
var claudeDirWatcher = null;
|
|
185
|
+
var claudeDirDebounce = null;
|
|
186
|
+
|
|
187
|
+
function startClaudeDirWatch() {
|
|
188
|
+
if (claudeDirWatcher) return;
|
|
189
|
+
var watchDir = loopDir();
|
|
190
|
+
if (!watchDir) return;
|
|
191
|
+
try { fs.mkdirSync(watchDir, { recursive: true }); } catch (e) {}
|
|
192
|
+
try {
|
|
193
|
+
claudeDirWatcher = fs.watch(watchDir, function () {
|
|
194
|
+
if (claudeDirDebounce) clearTimeout(claudeDirDebounce);
|
|
195
|
+
claudeDirDebounce = setTimeout(function () {
|
|
196
|
+
broadcastLoopFilesStatus();
|
|
197
|
+
}, 300);
|
|
198
|
+
});
|
|
199
|
+
claudeDirWatcher.on("error", function () {});
|
|
200
|
+
} catch (e) {
|
|
201
|
+
console.error("[ralph-loop] Failed to watch .claude/:", e.message);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function stopClaudeDirWatch() {
|
|
206
|
+
if (claudeDirWatcher) {
|
|
207
|
+
claudeDirWatcher.close();
|
|
208
|
+
claudeDirWatcher = null;
|
|
209
|
+
}
|
|
210
|
+
if (claudeDirDebounce) {
|
|
211
|
+
clearTimeout(claudeDirDebounce);
|
|
212
|
+
claudeDirDebounce = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function broadcastLoopFilesStatus() {
|
|
217
|
+
var dir = loopDir();
|
|
218
|
+
var hasPrompt = false;
|
|
219
|
+
var hasJudge = false;
|
|
220
|
+
var hasLoopJson = false;
|
|
221
|
+
if (dir) {
|
|
222
|
+
try { fs.accessSync(path.join(dir, "PROMPT.md")); hasPrompt = true; } catch (e) {}
|
|
223
|
+
try { fs.accessSync(path.join(dir, "JUDGE.md")); hasJudge = true; } catch (e) {}
|
|
224
|
+
try { fs.accessSync(path.join(dir, "LOOP.json")); hasLoopJson = true; } catch (e) {}
|
|
225
|
+
}
|
|
226
|
+
send({
|
|
227
|
+
type: "ralph_files_status",
|
|
228
|
+
promptReady: hasPrompt,
|
|
229
|
+
judgeReady: hasJudge,
|
|
230
|
+
loopJsonReady: hasLoopJson,
|
|
231
|
+
bothReady: hasPrompt && hasJudge,
|
|
232
|
+
taskId: loopState.loopId,
|
|
233
|
+
});
|
|
234
|
+
// Auto-transition to approval phase when both files appear
|
|
235
|
+
if (hasPrompt && hasJudge && loopState.phase === "crafting") {
|
|
236
|
+
loopState.phase = "approval";
|
|
237
|
+
saveLoopState();
|
|
238
|
+
|
|
239
|
+
// Parse recommended title from crafting session conversation
|
|
240
|
+
if (loopState.craftingSessionId && loopState.loopId) {
|
|
241
|
+
var craftSess = sm.sessions.get(loopState.craftingSessionId);
|
|
242
|
+
if (craftSess && craftSess.history) {
|
|
243
|
+
for (var hi = craftSess.history.length - 1; hi >= 0; hi--) {
|
|
244
|
+
var entry = craftSess.history[hi];
|
|
245
|
+
var entryText = entry.text || "";
|
|
246
|
+
var titleMatch = entryText.match(/\[\[LOOP_TITLE:\s*(.+?)\]\]/);
|
|
247
|
+
if (titleMatch) {
|
|
248
|
+
var suggestedTitle = titleMatch[1].trim();
|
|
249
|
+
if (suggestedTitle) {
|
|
250
|
+
loopRegistry.updateRecord(loopState.loopId, { name: suggestedTitle });
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Load persisted state on startup
|
|
261
|
+
loadLoopState();
|
|
262
|
+
|
|
263
|
+
// --- Loop Registry (unified one-off + scheduled) ---
|
|
264
|
+
var activeRegistryId = null; // track which registry record triggered current loop
|
|
265
|
+
|
|
266
|
+
var loopRegistry = createLoopRegistry({
|
|
267
|
+
cwd: cwd,
|
|
268
|
+
onTrigger: function (record) {
|
|
269
|
+
// Skip trigger if a loop is already active and skipIfRunning is enabled
|
|
270
|
+
if (loopState.active || loopState.phase === "executing") {
|
|
271
|
+
if (record.skipIfRunning !== false) {
|
|
272
|
+
console.log("[loop-registry] Skipping trigger for " + record.name + " — loop already active (skipIfRunning)");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
console.log("[loop-registry] Loop active but skipIfRunning disabled for " + record.name + "; deferring");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// For schedule records, resolve the linked task to get loop files
|
|
280
|
+
var loopFilesId = record.id;
|
|
281
|
+
if (record.source === "schedule") {
|
|
282
|
+
if (!record.linkedTaskId) {
|
|
283
|
+
console.error("[loop-registry] Schedule has no linked task: " + record.name);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
loopFilesId = record.linkedTaskId;
|
|
287
|
+
console.log("[loop-registry] Schedule triggered: " + record.name + " → linked task " + loopFilesId);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Verify the loop directory and PROMPT.md exist
|
|
291
|
+
var recDir = path.join(cwd, ".claude", "loops", loopFilesId);
|
|
292
|
+
try {
|
|
293
|
+
fs.accessSync(path.join(recDir, "PROMPT.md"));
|
|
294
|
+
} catch (e) {
|
|
295
|
+
console.error("[loop-registry] PROMPT.md missing for " + loopFilesId);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
// Set the loopId to the schedule's own id (not the linked task) so sidebar groups correctly
|
|
299
|
+
loopState.loopId = record.id;
|
|
300
|
+
loopState.loopFilesId = loopFilesId;
|
|
301
|
+
loopState.wizardData = null;
|
|
302
|
+
activeRegistryId = record.id;
|
|
303
|
+
console.log("[loop-registry] Auto-starting loop: " + record.name + " (" + loopState.loopId + ")");
|
|
304
|
+
send({ type: "schedule_run_started", recordId: record.id });
|
|
305
|
+
startLoop({ maxIterations: record.maxIterations, name: record.name });
|
|
306
|
+
},
|
|
307
|
+
onChange: function () {
|
|
308
|
+
send({ type: "loop_registry_updated", records: getHubSchedules() });
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
loopRegistry.load();
|
|
312
|
+
loopRegistry.startTimer();
|
|
313
|
+
|
|
314
|
+
// Wire loop info resolution for session list broadcasts
|
|
315
|
+
sm.setResolveLoopInfo(function (loopId) {
|
|
316
|
+
var rec = loopRegistry.getById(loopId);
|
|
317
|
+
if (!rec) return null;
|
|
318
|
+
return { name: rec.name || null, source: rec.source || null };
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
function startLoop(opts) {
|
|
322
|
+
var loopOpts = opts || {};
|
|
323
|
+
var dir = loopDir();
|
|
324
|
+
if (!dir) {
|
|
325
|
+
send({ type: "loop_error", text: "No loop directory. Run the wizard first." });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
var promptPath = path.join(dir, "PROMPT.md");
|
|
329
|
+
var judgePath = path.join(dir, "JUDGE.md");
|
|
330
|
+
var promptText, judgeText;
|
|
331
|
+
try {
|
|
332
|
+
promptText = fs.readFileSync(promptPath, "utf8");
|
|
333
|
+
} catch (e) {
|
|
334
|
+
send({ type: "loop_error", text: "Missing PROMPT.md in " + dir });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
judgeText = fs.readFileSync(judgePath, "utf8");
|
|
339
|
+
} catch (e) {
|
|
340
|
+
judgeText = null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
var baseCommit;
|
|
344
|
+
try {
|
|
345
|
+
baseCommit = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
346
|
+
cwd: cwd, encoding: "utf8", timeout: 5000,
|
|
347
|
+
}).trim();
|
|
348
|
+
} catch (e) {
|
|
349
|
+
send({ type: "loop_error", text: "Failed to get git HEAD: " + e.message });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Read loop config from LOOP.json in loop directory
|
|
354
|
+
var loopConfig = {};
|
|
355
|
+
try {
|
|
356
|
+
loopConfig = JSON.parse(fs.readFileSync(path.join(dir, "LOOP.json"), "utf8"));
|
|
357
|
+
} catch (e) {}
|
|
358
|
+
|
|
359
|
+
loopState.active = true;
|
|
360
|
+
loopState.phase = "executing";
|
|
361
|
+
loopState.promptText = promptText;
|
|
362
|
+
loopState.judgeText = judgeText;
|
|
363
|
+
loopState.iteration = 0;
|
|
364
|
+
loopState.maxIterations = judgeText ? ((loopOpts.maxIterations >= 1 ? loopOpts.maxIterations : null) || loopConfig.maxIterations || 20) : 1;
|
|
365
|
+
loopState.baseCommit = baseCommit;
|
|
366
|
+
loopState.currentSessionId = null;
|
|
367
|
+
loopState.judgeSessionId = null;
|
|
368
|
+
loopState.results = [];
|
|
369
|
+
loopState.stopping = false;
|
|
370
|
+
loopState.name = loopOpts.name || null;
|
|
371
|
+
loopState.startedAt = Date.now();
|
|
372
|
+
saveLoopState();
|
|
373
|
+
|
|
374
|
+
stopClaudeDirWatch();
|
|
375
|
+
|
|
376
|
+
send({ type: "loop_started", maxIterations: loopState.maxIterations, name: loopState.name });
|
|
377
|
+
runNextIteration();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function runNextIteration() {
|
|
381
|
+
console.log("[ralph-loop] runNextIteration called, iteration: " + loopState.iteration + ", active: " + loopState.active + ", stopping: " + loopState.stopping);
|
|
382
|
+
if (!loopState.active || loopState.stopping) {
|
|
383
|
+
finishLoop("stopped");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
loopState.iteration++;
|
|
388
|
+
if (loopState.iteration > loopState.maxIterations) {
|
|
389
|
+
finishLoop("max_iterations");
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
var session = sm.createSession();
|
|
394
|
+
var loopSource = loopRegistry.getById(loopState.loopId);
|
|
395
|
+
var loopName = (loopState.wizardData && loopState.wizardData.name) || (loopSource && loopSource.name) || "";
|
|
396
|
+
var loopSourceTag = (loopSource && loopSource.source) || null;
|
|
397
|
+
var isRalphLoop = loopSourceTag === "ralph";
|
|
398
|
+
session.loop = { active: true, iteration: loopState.iteration, role: "coder", loopId: loopState.loopId, name: loopName, source: loopSourceTag, startedAt: loopState.startedAt };
|
|
399
|
+
session.title = (isRalphLoop ? "Ralph" : "Task") + (loopName ? " " + loopName : "") + " #" + loopState.iteration;
|
|
400
|
+
sm.saveSessionFile(session);
|
|
401
|
+
sm.broadcastSessionList();
|
|
402
|
+
|
|
403
|
+
loopState.currentSessionId = session.localId;
|
|
404
|
+
|
|
405
|
+
send({
|
|
406
|
+
type: "loop_iteration",
|
|
407
|
+
iteration: loopState.iteration,
|
|
408
|
+
maxIterations: loopState.maxIterations,
|
|
409
|
+
sessionId: session.localId,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
var coderCompleted = false;
|
|
413
|
+
session.onQueryComplete = function(completedSession) {
|
|
414
|
+
if (coderCompleted) return;
|
|
415
|
+
coderCompleted = true;
|
|
416
|
+
if (coderWatchdog) { clearTimeout(coderWatchdog); coderWatchdog = null; }
|
|
417
|
+
console.log("[ralph-loop] Coder #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
|
|
418
|
+
if (!loopState.active) { console.log("[ralph-loop] Coder: loopState.active is false, skipping"); return; }
|
|
419
|
+
// Check if session ended with error
|
|
420
|
+
var lastItems = completedSession.history.slice(-3);
|
|
421
|
+
var hadError = false;
|
|
422
|
+
for (var i = 0; i < lastItems.length; i++) {
|
|
423
|
+
if (lastItems[i].type === "error" || (lastItems[i].type === "done" && lastItems[i].code === 1)) {
|
|
424
|
+
hadError = true;
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (hadError) {
|
|
429
|
+
loopState.results.push({
|
|
430
|
+
iteration: loopState.iteration,
|
|
431
|
+
verdict: "error",
|
|
432
|
+
summary: "Iteration ended with error",
|
|
433
|
+
});
|
|
434
|
+
send({
|
|
435
|
+
type: "loop_verdict",
|
|
436
|
+
iteration: loopState.iteration,
|
|
437
|
+
verdict: "error",
|
|
438
|
+
summary: "Iteration ended with error, retrying...",
|
|
439
|
+
});
|
|
440
|
+
setTimeout(function() { runNextIteration(); }, 2000);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (loopState.judgeText && loopState.maxIterations > 1) {
|
|
444
|
+
runJudge();
|
|
445
|
+
} else {
|
|
446
|
+
finishLoop("pass");
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// Watchdog: if onQueryComplete hasn't fired after 10 minutes, force error and retry
|
|
451
|
+
var coderWatchdog = setTimeout(function() {
|
|
452
|
+
if (!coderCompleted && loopState.active && !loopState.stopping) {
|
|
453
|
+
console.error("[ralph-loop] Coder #" + loopState.iteration + " watchdog triggered — onQueryComplete never fired");
|
|
454
|
+
coderCompleted = true;
|
|
455
|
+
loopState.results.push({
|
|
456
|
+
iteration: loopState.iteration,
|
|
457
|
+
verdict: "error",
|
|
458
|
+
summary: "Coder session timed out (no completion signal)",
|
|
459
|
+
});
|
|
460
|
+
send({
|
|
461
|
+
type: "loop_verdict",
|
|
462
|
+
iteration: loopState.iteration,
|
|
463
|
+
verdict: "error",
|
|
464
|
+
summary: "Coder session timed out, retrying...",
|
|
465
|
+
});
|
|
466
|
+
setTimeout(function() { runNextIteration(); }, 2000);
|
|
467
|
+
}
|
|
468
|
+
}, 10 * 60 * 1000);
|
|
469
|
+
|
|
470
|
+
var userMsg = { type: "user_message", text: loopState.promptText };
|
|
471
|
+
session.history.push(userMsg);
|
|
472
|
+
sm.appendToSessionFile(session, userMsg);
|
|
473
|
+
|
|
474
|
+
session.isProcessing = true;
|
|
475
|
+
onProcessingChanged();
|
|
476
|
+
session.sentToolResults = {};
|
|
477
|
+
sendToSession(session.localId, { type: "status", status: "processing" });
|
|
478
|
+
session.acceptEditsAfterStart = true;
|
|
479
|
+
session.singleTurn = true;
|
|
480
|
+
sdk.startQuery(session, loopState.promptText, undefined, getLinuxUserForSession(session));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function runJudge() {
|
|
484
|
+
if (!loopState.active || loopState.stopping) {
|
|
485
|
+
finishLoop("stopped");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
var diff;
|
|
490
|
+
try {
|
|
491
|
+
diff = execFileSync("git", ["diff", loopState.baseCommit], {
|
|
492
|
+
cwd: cwd, encoding: "utf8", timeout: 30000,
|
|
493
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
494
|
+
});
|
|
495
|
+
} catch (e) {
|
|
496
|
+
send({ type: "loop_error", text: "Failed to generate git diff: " + e.message });
|
|
497
|
+
finishLoop("error");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
var gitLog = "";
|
|
502
|
+
try {
|
|
503
|
+
gitLog = execFileSync("git", ["log", "--oneline", loopState.baseCommit + "..HEAD"], {
|
|
504
|
+
cwd: cwd, encoding: "utf8", timeout: 10000,
|
|
505
|
+
}).trim();
|
|
506
|
+
} catch (e) {}
|
|
507
|
+
|
|
508
|
+
var judgePrompt = "You are a judge evaluating whether a coding task has been completed.\n\n" +
|
|
509
|
+
"## Original Task (PROMPT.md)\n\n" + loopState.promptText + "\n\n" +
|
|
510
|
+
"## Evaluation Criteria (JUDGE.md)\n\n" + loopState.judgeText + "\n\n" +
|
|
511
|
+
"## Commit History\n\n```\n" + (gitLog || "(no commits yet)") + "\n```\n\n" +
|
|
512
|
+
"## Changes Made (git diff)\n\n```diff\n" + diff + "\n```\n\n" +
|
|
513
|
+
"Based on the evaluation criteria, has the task been completed successfully?\n\n" +
|
|
514
|
+
"IMPORTANT: The git diff above may not show everything. If criteria involve checking whether " +
|
|
515
|
+
"specific files, classes, or features exist, use tools (Read, Glob, Grep, Bash) to verify " +
|
|
516
|
+
"directly in the codebase. Do NOT assume something is missing just because it is not in the diff.\n\n" +
|
|
517
|
+
"After your evaluation, respond with exactly one of:\n" +
|
|
518
|
+
"- PASS: [brief explanation]\n" +
|
|
519
|
+
"- FAIL: [brief explanation of what is still missing]";
|
|
520
|
+
|
|
521
|
+
var judgeSession = sm.createSession();
|
|
522
|
+
var judgeSource = loopRegistry.getById(loopState.loopId);
|
|
523
|
+
var judgeName = (loopState.wizardData && loopState.wizardData.name) || (judgeSource && judgeSource.name) || "";
|
|
524
|
+
var judgeSourceTag = (judgeSource && judgeSource.source) || null;
|
|
525
|
+
var isRalphJudge = judgeSourceTag === "ralph";
|
|
526
|
+
judgeSession.loop = { active: true, iteration: loopState.iteration, role: "judge", loopId: loopState.loopId, name: judgeName, source: judgeSourceTag, startedAt: loopState.startedAt };
|
|
527
|
+
judgeSession.title = (isRalphJudge ? "Ralph" : "Task") + (judgeName ? " " + judgeName : "") + " Judge #" + loopState.iteration;
|
|
528
|
+
sm.saveSessionFile(judgeSession);
|
|
529
|
+
sm.broadcastSessionList();
|
|
530
|
+
loopState.judgeSessionId = judgeSession.localId;
|
|
531
|
+
|
|
532
|
+
send({
|
|
533
|
+
type: "loop_judging",
|
|
534
|
+
iteration: loopState.iteration,
|
|
535
|
+
sessionId: judgeSession.localId,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
var judgeCompleted = false;
|
|
539
|
+
judgeSession.onQueryComplete = function(completedSession) {
|
|
540
|
+
if (judgeCompleted) return;
|
|
541
|
+
judgeCompleted = true;
|
|
542
|
+
if (judgeWatchdog) { clearTimeout(judgeWatchdog); judgeWatchdog = null; }
|
|
543
|
+
console.log("[ralph-loop] Judge #" + loopState.iteration + " onQueryComplete fired, history length: " + completedSession.history.length);
|
|
544
|
+
var verdict = parseJudgeVerdict(completedSession);
|
|
545
|
+
console.log("[ralph-loop] Judge verdict: " + (verdict.pass ? "PASS" : "FAIL") + " - " + verdict.explanation);
|
|
546
|
+
|
|
547
|
+
loopState.results.push({
|
|
548
|
+
iteration: loopState.iteration,
|
|
549
|
+
verdict: verdict.pass ? "pass" : "fail",
|
|
550
|
+
summary: verdict.explanation,
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
send({
|
|
554
|
+
type: "loop_verdict",
|
|
555
|
+
iteration: loopState.iteration,
|
|
556
|
+
verdict: verdict.pass ? "pass" : "fail",
|
|
557
|
+
summary: verdict.explanation,
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
if (verdict.pass) {
|
|
561
|
+
finishLoop("pass");
|
|
562
|
+
} else {
|
|
563
|
+
setTimeout(function() { runNextIteration(); }, 1000);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
// Watchdog: judge may use tools to verify, so allow more time
|
|
568
|
+
var judgeWatchdog = setTimeout(function() {
|
|
569
|
+
if (!judgeCompleted && loopState.active && !loopState.stopping) {
|
|
570
|
+
console.error("[ralph-loop] Judge #" + loopState.iteration + " watchdog triggered — onQueryComplete never fired");
|
|
571
|
+
judgeCompleted = true;
|
|
572
|
+
loopState.results.push({
|
|
573
|
+
iteration: loopState.iteration,
|
|
574
|
+
verdict: "error",
|
|
575
|
+
summary: "Judge session timed out (no completion signal)",
|
|
576
|
+
});
|
|
577
|
+
send({
|
|
578
|
+
type: "loop_verdict",
|
|
579
|
+
iteration: loopState.iteration,
|
|
580
|
+
verdict: "error",
|
|
581
|
+
summary: "Judge session timed out, retrying...",
|
|
582
|
+
});
|
|
583
|
+
setTimeout(function() { runNextIteration(); }, 2000);
|
|
584
|
+
}
|
|
585
|
+
}, 10 * 60 * 1000);
|
|
586
|
+
|
|
587
|
+
var userMsg = { type: "user_message", text: judgePrompt };
|
|
588
|
+
judgeSession.history.push(userMsg);
|
|
589
|
+
sm.appendToSessionFile(judgeSession, userMsg);
|
|
590
|
+
|
|
591
|
+
judgeSession.isProcessing = true;
|
|
592
|
+
onProcessingChanged();
|
|
593
|
+
judgeSession.sentToolResults = {};
|
|
594
|
+
judgeSession.acceptEditsAfterStart = true;
|
|
595
|
+
judgeSession.singleTurn = true;
|
|
596
|
+
sdk.startQuery(judgeSession, judgePrompt, undefined, getLinuxUserForSession(judgeSession));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function parseJudgeVerdict(session) {
|
|
600
|
+
var text = "";
|
|
601
|
+
for (var i = 0; i < session.history.length; i++) {
|
|
602
|
+
var h = session.history[i];
|
|
603
|
+
if (h.type === "delta" && h.text) text += h.text;
|
|
604
|
+
if (h.type === "text" && h.text) text += h.text;
|
|
605
|
+
}
|
|
606
|
+
console.log("[ralph-loop] Judge raw text (last 500 chars): " + text.slice(-500));
|
|
607
|
+
var upper = text.toUpperCase();
|
|
608
|
+
var passIdx = upper.indexOf("PASS");
|
|
609
|
+
var failIdx = upper.indexOf("FAIL");
|
|
610
|
+
if (passIdx !== -1 && (failIdx === -1 || passIdx < failIdx)) {
|
|
611
|
+
var explanation = text.substring(passIdx + 4).replace(/^[\s:]+/, "").split("\n")[0].trim();
|
|
612
|
+
return { pass: true, explanation: explanation || "Task completed" };
|
|
613
|
+
}
|
|
614
|
+
if (failIdx !== -1) {
|
|
615
|
+
var explanation = text.substring(failIdx + 4).replace(/^[\s:]+/, "").split("\n")[0].trim();
|
|
616
|
+
return { pass: false, explanation: explanation || "Task not yet complete" };
|
|
617
|
+
}
|
|
618
|
+
return { pass: false, explanation: "Could not parse judge verdict" };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function finishLoop(reason) {
|
|
622
|
+
console.log("[ralph-loop] finishLoop called, reason: " + reason + ", iteration: " + loopState.iteration);
|
|
623
|
+
loopState.active = false;
|
|
624
|
+
loopState.phase = "done";
|
|
625
|
+
loopState.stopping = false;
|
|
626
|
+
loopState.currentSessionId = null;
|
|
627
|
+
loopState.judgeSessionId = null;
|
|
628
|
+
saveLoopState();
|
|
629
|
+
|
|
630
|
+
send({
|
|
631
|
+
type: "loop_finished",
|
|
632
|
+
reason: reason,
|
|
633
|
+
iterations: loopState.iteration,
|
|
634
|
+
results: loopState.results,
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// Record result in loop registry
|
|
638
|
+
if (loopState.loopId) {
|
|
639
|
+
loopRegistry.recordRun(loopState.loopId, {
|
|
640
|
+
reason: reason,
|
|
641
|
+
startedAt: loopState.startedAt,
|
|
642
|
+
iterations: loopState.iteration,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
if (activeRegistryId) {
|
|
646
|
+
send({ type: "schedule_run_finished", recordId: activeRegistryId, reason: reason, iterations: loopState.iteration });
|
|
647
|
+
activeRegistryId = null;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (pushModule) {
|
|
651
|
+
var body = reason === "pass"
|
|
652
|
+
? "Task completed after " + loopState.iteration + " iteration(s)"
|
|
653
|
+
: reason === "max_iterations"
|
|
654
|
+
? "Reached max iterations (" + loopState.maxIterations + ")"
|
|
655
|
+
: reason === "stopped"
|
|
656
|
+
? "Loop stopped by user"
|
|
657
|
+
: "Loop ended due to error";
|
|
658
|
+
pushModule.sendPush({
|
|
659
|
+
type: "done",
|
|
660
|
+
slug: slug,
|
|
661
|
+
title: "Ralph Loop Complete",
|
|
662
|
+
body: body,
|
|
663
|
+
tag: "ralph-loop-done",
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function resumeLoop() {
|
|
669
|
+
var dir = loopDir();
|
|
670
|
+
if (!dir) {
|
|
671
|
+
console.error("[ralph-loop] Cannot resume: no loop directory");
|
|
672
|
+
loopState.active = false;
|
|
673
|
+
loopState.phase = "idle";
|
|
674
|
+
saveLoopState();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
loopState.promptText = fs.readFileSync(path.join(dir, "PROMPT.md"), "utf8");
|
|
679
|
+
} catch (e) {
|
|
680
|
+
console.error("[ralph-loop] Cannot resume: missing PROMPT.md");
|
|
681
|
+
loopState.active = false;
|
|
682
|
+
loopState.phase = "idle";
|
|
683
|
+
saveLoopState();
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
try {
|
|
687
|
+
loopState.judgeText = fs.readFileSync(path.join(dir, "JUDGE.md"), "utf8");
|
|
688
|
+
} catch (e) {
|
|
689
|
+
console.error("[ralph-loop] Cannot resume: missing JUDGE.md");
|
|
690
|
+
loopState.active = false;
|
|
691
|
+
loopState.phase = "idle";
|
|
692
|
+
saveLoopState();
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
// Retry the interrupted iteration (runNextIteration will increment)
|
|
696
|
+
if (loopState.iteration > 0) {
|
|
697
|
+
loopState.iteration--;
|
|
698
|
+
}
|
|
699
|
+
console.log("[ralph-loop] Resuming loop, next iteration will be " + (loopState.iteration + 1) + "/" + loopState.maxIterations);
|
|
700
|
+
send({ type: "loop_started", maxIterations: loopState.maxIterations });
|
|
701
|
+
runNextIteration();
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function stopLoop() {
|
|
705
|
+
if (!loopState.active) return;
|
|
706
|
+
console.log("[ralph-loop] stopLoop called");
|
|
707
|
+
loopState.stopping = true;
|
|
708
|
+
|
|
709
|
+
// Abort all loop-related sessions (coder + judge)
|
|
710
|
+
var sessionIds = [loopState.currentSessionId, loopState.judgeSessionId];
|
|
711
|
+
for (var i = 0; i < sessionIds.length; i++) {
|
|
712
|
+
if (sessionIds[i] == null) continue;
|
|
713
|
+
var s = sm.sessions.get(sessionIds[i]);
|
|
714
|
+
if (!s) continue;
|
|
715
|
+
// End message queue so SDK exits prompt wait
|
|
716
|
+
if (s.messageQueue) { try { s.messageQueue.end(); } catch (e) {} }
|
|
717
|
+
// Abort active API call
|
|
718
|
+
if (s.abortController) { try { s.abortController.abort(); } catch (e) {} }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
send({ type: "loop_stopping" });
|
|
722
|
+
|
|
723
|
+
// Fallback: force finish if onQueryComplete hasn't fired after 5s
|
|
724
|
+
setTimeout(function() {
|
|
725
|
+
if (loopState.active && loopState.stopping) {
|
|
726
|
+
console.log("[ralph-loop] Stop fallback triggered — forcing finishLoop");
|
|
727
|
+
finishLoop("stopped");
|
|
728
|
+
}
|
|
729
|
+
}, 5000);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// --- Message handler for loop-related messages ---
|
|
733
|
+
function handleLoopMessage(ws, msg) {
|
|
734
|
+
if (msg.type === "loop_start") {
|
|
735
|
+
// If this loop has a cron schedule, don't run immediately
|
|
736
|
+
if (loopState.wizardData && loopState.wizardData.cron) {
|
|
737
|
+
loopState.active = false;
|
|
738
|
+
loopState.phase = "done";
|
|
739
|
+
saveLoopState();
|
|
740
|
+
send({ type: "loop_finished", reason: "scheduled", iterations: 0, results: [] });
|
|
741
|
+
send({ type: "ralph_phase", phase: "idle", wizardData: null });
|
|
742
|
+
send({ type: "loop_scheduled", recordId: loopState.loopId, cron: loopState.wizardData.cron });
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
startLoop();
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (msg.type === "loop_stop") {
|
|
750
|
+
stopLoop();
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (msg.type === "ralph_wizard_complete") {
|
|
755
|
+
var wData = msg.data || {};
|
|
756
|
+
var maxIter = wData.maxIterations || 3;
|
|
757
|
+
var wizardCron = wData.cron || null;
|
|
758
|
+
var newLoopId = generateLoopId();
|
|
759
|
+
loopState.loopId = newLoopId;
|
|
760
|
+
loopState.wizardData = {
|
|
761
|
+
name: wData.name || wData.task || "Untitled",
|
|
762
|
+
task: wData.task || "",
|
|
763
|
+
maxIterations: maxIter,
|
|
764
|
+
cron: wizardCron,
|
|
765
|
+
};
|
|
766
|
+
loopState.phase = "crafting";
|
|
767
|
+
loopState.startedAt = Date.now();
|
|
768
|
+
saveLoopState();
|
|
769
|
+
|
|
770
|
+
// Register in loop registry
|
|
771
|
+
var recordSource = wData.source === "task" ? null : "ralph";
|
|
772
|
+
loopRegistry.register({
|
|
773
|
+
id: newLoopId,
|
|
774
|
+
name: loopState.wizardData.name,
|
|
775
|
+
task: wData.task || "",
|
|
776
|
+
cron: wizardCron,
|
|
777
|
+
enabled: wizardCron ? true : false,
|
|
778
|
+
maxIterations: maxIter,
|
|
779
|
+
source: recordSource,
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// Create loop directory and write LOOP.json
|
|
783
|
+
var lDir = loopDir();
|
|
784
|
+
try { fs.mkdirSync(lDir, { recursive: true }); } catch (e) {}
|
|
785
|
+
var loopJsonPath = path.join(lDir, "LOOP.json");
|
|
786
|
+
var tmpLoopJson = loopJsonPath + ".tmp";
|
|
787
|
+
fs.writeFileSync(tmpLoopJson, JSON.stringify({ maxIterations: maxIter }, null, 2));
|
|
788
|
+
fs.renameSync(tmpLoopJson, loopJsonPath);
|
|
789
|
+
|
|
790
|
+
var craftName = (loopState.wizardData && loopState.wizardData.name) || "";
|
|
791
|
+
var isRalphCraft = recordSource === "ralph";
|
|
792
|
+
|
|
793
|
+
// User provided their own PROMPT.md (and optionally JUDGE.md)
|
|
794
|
+
if (wData.mode === "own" && wData.promptText) {
|
|
795
|
+
// Write PROMPT.md
|
|
796
|
+
var promptPath = path.join(lDir, "PROMPT.md");
|
|
797
|
+
var tmpPrompt = promptPath + ".tmp";
|
|
798
|
+
fs.writeFileSync(tmpPrompt, wData.promptText);
|
|
799
|
+
fs.renameSync(tmpPrompt, promptPath);
|
|
800
|
+
|
|
801
|
+
if (wData.judgeText) {
|
|
802
|
+
// Both provided: write JUDGE.md too
|
|
803
|
+
var judgePath = path.join(lDir, "JUDGE.md");
|
|
804
|
+
var tmpJudge = judgePath + ".tmp";
|
|
805
|
+
fs.writeFileSync(tmpJudge, wData.judgeText);
|
|
806
|
+
fs.renameSync(tmpJudge, judgePath);
|
|
807
|
+
} else if (!recordSource) {
|
|
808
|
+
// Scheduled task with no judge: force single iteration and go to approval
|
|
809
|
+
var singleJson = loopJsonPath + ".tmp2";
|
|
810
|
+
fs.writeFileSync(singleJson, JSON.stringify({ maxIterations: 1 }, null, 2));
|
|
811
|
+
fs.renameSync(singleJson, loopJsonPath);
|
|
812
|
+
|
|
813
|
+
loopState.phase = "approval";
|
|
814
|
+
saveLoopState();
|
|
815
|
+
send({ type: "ralph_phase", phase: "approval", source: recordSource, wizardData: loopState.wizardData });
|
|
816
|
+
send({ type: "ralph_files_status", promptReady: true, judgeReady: false, bothReady: true });
|
|
817
|
+
return true;
|
|
818
|
+
} else {
|
|
819
|
+
// Ralph with no judge: start a crafting session to create JUDGE.md
|
|
820
|
+
loopState.phase = "crafting";
|
|
821
|
+
saveLoopState();
|
|
822
|
+
|
|
823
|
+
var judgeCraftPrompt = "Use the /clay-ralph skill to design ONLY a JUDGE.md for an existing Ralph Loop. " +
|
|
824
|
+
"The user has already provided PROMPT.md, so do NOT create or modify PROMPT.md. " +
|
|
825
|
+
"You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
|
|
826
|
+
"Your job is to read the existing PROMPT.md and create a JUDGE.md " +
|
|
827
|
+
"that will evaluate whether the coder session completed the task successfully.\n\n" +
|
|
828
|
+
"## Task\n" + (wData.task || "") +
|
|
829
|
+
"\n\n## Loop Directory\n" + lDir;
|
|
830
|
+
|
|
831
|
+
var judgeCraftSession = sm.createSession();
|
|
832
|
+
judgeCraftSession.title = (isRalphCraft ? "Ralph" : "Task") + (craftName ? " " + craftName : "") + " Crafting";
|
|
833
|
+
judgeCraftSession.ralphCraftingMode = true;
|
|
834
|
+
judgeCraftSession.loop = { active: true, iteration: 0, role: "crafting", loopId: newLoopId, name: craftName, source: recordSource, startedAt: loopState.startedAt };
|
|
835
|
+
sm.saveSessionFile(judgeCraftSession);
|
|
836
|
+
sm.switchSession(judgeCraftSession.localId, null, hydrateImageRefs);
|
|
837
|
+
loopState.craftingSessionId = judgeCraftSession.localId;
|
|
838
|
+
|
|
839
|
+
loopRegistry.updateRecord(newLoopId, { craftingSessionId: judgeCraftSession.localId });
|
|
840
|
+
|
|
841
|
+
startClaudeDirWatch();
|
|
842
|
+
|
|
843
|
+
judgeCraftSession.history.push({ type: "user_message", text: judgeCraftPrompt });
|
|
844
|
+
sm.appendToSessionFile(judgeCraftSession, { type: "user_message", text: judgeCraftPrompt });
|
|
845
|
+
sendToSession(judgeCraftSession.localId, { type: "user_message", text: judgeCraftPrompt });
|
|
846
|
+
judgeCraftSession.isProcessing = true;
|
|
847
|
+
onProcessingChanged();
|
|
848
|
+
judgeCraftSession.sentToolResults = {};
|
|
849
|
+
sendToSession(judgeCraftSession.localId, { type: "status", status: "processing" });
|
|
850
|
+
sdk.startQuery(judgeCraftSession, judgeCraftPrompt, undefined, getLinuxUserForSession(judgeCraftSession));
|
|
851
|
+
|
|
852
|
+
send({ type: "ralph_crafting_started", sessionId: judgeCraftSession.localId, taskId: newLoopId, source: recordSource });
|
|
853
|
+
send({ type: "ralph_phase", phase: "crafting", wizardData: loopState.wizardData, craftingSessionId: judgeCraftSession.localId });
|
|
854
|
+
send({ type: "ralph_files_status", promptReady: true, judgeReady: false, bothReady: false });
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Both prompt and judge provided: go straight to approval
|
|
859
|
+
loopState.phase = "approval";
|
|
860
|
+
saveLoopState();
|
|
861
|
+
send({ type: "ralph_phase", phase: "approval", source: recordSource, wizardData: loopState.wizardData });
|
|
862
|
+
send({ type: "ralph_files_status", promptReady: true, judgeReady: true, bothReady: true });
|
|
863
|
+
return true;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Default: "draft" mode — Clay crafts both PROMPT.md and JUDGE.md
|
|
867
|
+
var craftingPrompt = "Use the /clay-ralph skill to design a Ralph Loop for the following task. " +
|
|
868
|
+
"You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
|
|
869
|
+
"Your job is to interview me, then create PROMPT.md and JUDGE.md files " +
|
|
870
|
+
"that a future autonomous session will execute.\n\n" +
|
|
871
|
+
"## Task\n" + (wData.task || "") +
|
|
872
|
+
"\n\n## Loop Directory\n" + lDir;
|
|
873
|
+
|
|
874
|
+
// Create a new session for crafting
|
|
875
|
+
var craftingSession = sm.createSession();
|
|
876
|
+
craftingSession.title = (isRalphCraft ? "Ralph" : "Task") + (craftName ? " " + craftName : "") + " Crafting";
|
|
877
|
+
craftingSession.ralphCraftingMode = true;
|
|
878
|
+
craftingSession.loop = { active: true, iteration: 0, role: "crafting", loopId: newLoopId, name: craftName, source: recordSource, startedAt: loopState.startedAt };
|
|
879
|
+
sm.saveSessionFile(craftingSession);
|
|
880
|
+
sm.switchSession(craftingSession.localId, null, hydrateImageRefs);
|
|
881
|
+
loopState.craftingSessionId = craftingSession.localId;
|
|
882
|
+
|
|
883
|
+
// Store crafting session ID in the registry record
|
|
884
|
+
loopRegistry.updateRecord(newLoopId, { craftingSessionId: craftingSession.localId });
|
|
885
|
+
|
|
886
|
+
// Start .claude/ directory watcher
|
|
887
|
+
startClaudeDirWatch();
|
|
888
|
+
|
|
889
|
+
// Send crafting prompt and start the conversation with Claude.
|
|
890
|
+
craftingSession.history.push({ type: "user_message", text: craftingPrompt });
|
|
891
|
+
sm.appendToSessionFile(craftingSession, { type: "user_message", text: craftingPrompt });
|
|
892
|
+
sendToSession(craftingSession.localId, { type: "user_message", text: craftingPrompt });
|
|
893
|
+
craftingSession.isProcessing = true;
|
|
894
|
+
onProcessingChanged();
|
|
895
|
+
craftingSession.sentToolResults = {};
|
|
896
|
+
sendToSession(craftingSession.localId, { type: "status", status: "processing" });
|
|
897
|
+
sdk.startQuery(craftingSession, craftingPrompt, undefined, getLinuxUserForSession(craftingSession));
|
|
898
|
+
|
|
899
|
+
send({ type: "ralph_crafting_started", sessionId: craftingSession.localId, taskId: newLoopId, source: recordSource });
|
|
900
|
+
send({ type: "ralph_phase", phase: "crafting", wizardData: loopState.wizardData, craftingSessionId: craftingSession.localId });
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (msg.type === "loop_registry_files") {
|
|
905
|
+
var recId = msg.id;
|
|
906
|
+
var lDir = path.join(cwd, ".claude", "loops", recId);
|
|
907
|
+
var promptContent = "";
|
|
908
|
+
var judgeContent = "";
|
|
909
|
+
try { promptContent = fs.readFileSync(path.join(lDir, "PROMPT.md"), "utf8"); } catch (e) {}
|
|
910
|
+
try { judgeContent = fs.readFileSync(path.join(lDir, "JUDGE.md"), "utf8"); } catch (e) {}
|
|
911
|
+
send({
|
|
912
|
+
type: "loop_registry_files_content",
|
|
913
|
+
id: recId,
|
|
914
|
+
prompt: promptContent,
|
|
915
|
+
judge: judgeContent,
|
|
916
|
+
});
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (msg.type === "ralph_preview_files") {
|
|
921
|
+
var promptContent = "";
|
|
922
|
+
var judgeContent = "";
|
|
923
|
+
var previewDir = loopDir();
|
|
924
|
+
if (previewDir) {
|
|
925
|
+
try { promptContent = fs.readFileSync(path.join(previewDir, "PROMPT.md"), "utf8"); } catch (e) {}
|
|
926
|
+
try { judgeContent = fs.readFileSync(path.join(previewDir, "JUDGE.md"), "utf8"); } catch (e) {}
|
|
927
|
+
}
|
|
928
|
+
sendTo(ws, {
|
|
929
|
+
type: "ralph_files_content",
|
|
930
|
+
prompt: promptContent,
|
|
931
|
+
judge: judgeContent,
|
|
932
|
+
});
|
|
933
|
+
return true;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (msg.type === "ralph_wizard_cancel") {
|
|
937
|
+
stopClaudeDirWatch();
|
|
938
|
+
// Clean up loop directory
|
|
939
|
+
var cancelDir = loopDir();
|
|
940
|
+
if (cancelDir) {
|
|
941
|
+
try { fs.rmSync(cancelDir, { recursive: true, force: true }); } catch (e) {}
|
|
942
|
+
}
|
|
943
|
+
clearLoopState();
|
|
944
|
+
send({ type: "ralph_phase", phase: "idle", wizardData: null });
|
|
945
|
+
return true;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if (msg.type === "ralph_cancel_crafting") {
|
|
949
|
+
// Abort the crafting session if running
|
|
950
|
+
if (loopState.craftingSessionId != null) {
|
|
951
|
+
var craftSession = sm.sessions.get(loopState.craftingSessionId) || null;
|
|
952
|
+
if (craftSession && craftSession.abortController) {
|
|
953
|
+
craftSession.abortController.abort();
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
stopClaudeDirWatch();
|
|
957
|
+
// Clean up loop directory
|
|
958
|
+
var craftCancelDir = loopDir();
|
|
959
|
+
if (craftCancelDir) {
|
|
960
|
+
try { fs.rmSync(craftCancelDir, { recursive: true, force: true }); } catch (e) {}
|
|
961
|
+
}
|
|
962
|
+
clearLoopState();
|
|
963
|
+
send({ type: "ralph_phase", phase: "idle", wizardData: null });
|
|
964
|
+
return true;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// --- Schedule create (from calendar click) ---
|
|
968
|
+
if (msg.type === "schedule_create") {
|
|
969
|
+
var sData = msg.data || {};
|
|
970
|
+
loopRegistry.register({
|
|
971
|
+
name: sData.name || "Untitled",
|
|
972
|
+
task: sData.name || "",
|
|
973
|
+
description: sData.description || "",
|
|
974
|
+
date: sData.date || null,
|
|
975
|
+
time: sData.time || null,
|
|
976
|
+
allDay: sData.allDay !== undefined ? sData.allDay : true,
|
|
977
|
+
linkedTaskId: sData.taskId || null,
|
|
978
|
+
cron: sData.cron || null,
|
|
979
|
+
enabled: sData.cron ? (sData.enabled !== false) : false,
|
|
980
|
+
maxIterations: sData.maxIterations || 3,
|
|
981
|
+
source: "schedule",
|
|
982
|
+
color: sData.color || null,
|
|
983
|
+
recurrenceEnd: sData.recurrenceEnd || null,
|
|
984
|
+
skipIfRunning: sData.skipIfRunning !== undefined ? sData.skipIfRunning : true,
|
|
985
|
+
intervalEnd: sData.intervalEnd || null,
|
|
986
|
+
});
|
|
987
|
+
return true;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// --- Hub: cross-project schedule aggregation ---
|
|
991
|
+
if (msg.type === "hub_schedules_list") {
|
|
992
|
+
sendTo(ws, { type: "hub_schedules", schedules: getHubSchedules() });
|
|
993
|
+
return true;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// --- Loop Registry messages ---
|
|
997
|
+
if (msg.type === "loop_registry_list") {
|
|
998
|
+
sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules() });
|
|
999
|
+
return true;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (msg.type === "loop_registry_update") {
|
|
1003
|
+
var updatedRec = loopRegistry.update(msg.id, msg.data || {});
|
|
1004
|
+
if (!updatedRec) {
|
|
1005
|
+
sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
|
|
1006
|
+
}
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (msg.type === "loop_registry_rename") {
|
|
1011
|
+
if (msg.id && msg.name) {
|
|
1012
|
+
loopRegistry.updateRecord(msg.id, { name: String(msg.name).substring(0, 100) });
|
|
1013
|
+
sm.broadcastSessionList();
|
|
1014
|
+
}
|
|
1015
|
+
return true;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (msg.type === "loop_registry_remove") {
|
|
1019
|
+
var removedRec = loopRegistry.remove(msg.id);
|
|
1020
|
+
if (!removedRec) {
|
|
1021
|
+
sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
|
|
1022
|
+
}
|
|
1023
|
+
return true;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (msg.type === "loop_registry_convert") {
|
|
1027
|
+
// Convert ralph source to regular task (remove source tag)
|
|
1028
|
+
if (msg.id) {
|
|
1029
|
+
loopRegistry.updateRecord(msg.id, { source: null });
|
|
1030
|
+
sm.broadcastSessionList();
|
|
1031
|
+
}
|
|
1032
|
+
return true;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (msg.type === "delete_loop_group") {
|
|
1036
|
+
// Delete all sessions belonging to this loopId, then remove registry record
|
|
1037
|
+
var loopIdToDel = msg.loopId;
|
|
1038
|
+
if (!loopIdToDel) return true;
|
|
1039
|
+
var sessionIds = [];
|
|
1040
|
+
sm.sessions.forEach(function (s, lid) {
|
|
1041
|
+
if (s.loop && s.loop.loopId === loopIdToDel) sessionIds.push(lid);
|
|
1042
|
+
});
|
|
1043
|
+
for (var di = 0; di < sessionIds.length; di++) {
|
|
1044
|
+
sm.deleteSessionQuiet(sessionIds[di]);
|
|
1045
|
+
}
|
|
1046
|
+
loopRegistry.remove(loopIdToDel);
|
|
1047
|
+
sm.broadcastSessionList();
|
|
1048
|
+
return true;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (msg.type === "loop_registry_toggle") {
|
|
1052
|
+
var toggledRec = loopRegistry.toggleEnabled(msg.id);
|
|
1053
|
+
if (!toggledRec) {
|
|
1054
|
+
sendTo(ws, { type: "loop_registry_error", text: "Record not found or not scheduled" });
|
|
1055
|
+
}
|
|
1056
|
+
return true;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
if (msg.type === "loop_registry_rerun") {
|
|
1060
|
+
// Re-run an existing job (one-off from library)
|
|
1061
|
+
if (loopState.active || loopState.phase === "executing") {
|
|
1062
|
+
sendTo(ws, { type: "loop_registry_error", text: "A loop is already running" });
|
|
1063
|
+
return true;
|
|
1064
|
+
}
|
|
1065
|
+
var rerunRec = loopRegistry.getById(msg.id);
|
|
1066
|
+
if (!rerunRec) {
|
|
1067
|
+
sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
|
|
1068
|
+
return true;
|
|
1069
|
+
}
|
|
1070
|
+
var rerunDir = path.join(cwd, ".claude", "loops", rerunRec.id);
|
|
1071
|
+
try {
|
|
1072
|
+
fs.accessSync(path.join(rerunDir, "PROMPT.md"));
|
|
1073
|
+
} catch (e) {
|
|
1074
|
+
sendTo(ws, { type: "loop_registry_error", text: "PROMPT.md missing for " + rerunRec.id });
|
|
1075
|
+
return true;
|
|
1076
|
+
}
|
|
1077
|
+
loopState.loopId = rerunRec.id;
|
|
1078
|
+
loopState.loopFilesId = null;
|
|
1079
|
+
activeRegistryId = null; // not a scheduled trigger
|
|
1080
|
+
send({ type: "loop_rerun_started", recordId: rerunRec.id });
|
|
1081
|
+
startLoop();
|
|
1082
|
+
return true;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
return false; // not handled
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// --- Connection state: send loop state to newly connected client ---
|
|
1089
|
+
function sendConnectionState(ws) {
|
|
1090
|
+
// Ralph Loop availability
|
|
1091
|
+
var hasLoopFiles = false;
|
|
1092
|
+
try {
|
|
1093
|
+
fs.accessSync(path.join(cwd, ".claude", "PROMPT.md"));
|
|
1094
|
+
fs.accessSync(path.join(cwd, ".claude", "JUDGE.md"));
|
|
1095
|
+
hasLoopFiles = true;
|
|
1096
|
+
} catch (e) {}
|
|
1097
|
+
// Also check loop directory files
|
|
1098
|
+
if (!hasLoopFiles && loopState.loopId) {
|
|
1099
|
+
var _avDir = loopDir();
|
|
1100
|
+
if (_avDir) {
|
|
1101
|
+
try {
|
|
1102
|
+
fs.accessSync(path.join(_avDir, "PROMPT.md"));
|
|
1103
|
+
fs.accessSync(path.join(_avDir, "JUDGE.md"));
|
|
1104
|
+
hasLoopFiles = true;
|
|
1105
|
+
} catch (e) {}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
sendTo(ws, {
|
|
1109
|
+
type: "loop_available",
|
|
1110
|
+
available: hasLoopFiles,
|
|
1111
|
+
active: loopState.active,
|
|
1112
|
+
iteration: loopState.iteration,
|
|
1113
|
+
maxIterations: loopState.maxIterations,
|
|
1114
|
+
name: loopState.name || null,
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
// Ralph phase state
|
|
1118
|
+
sendTo(ws, {
|
|
1119
|
+
type: "ralph_phase",
|
|
1120
|
+
phase: loopState.phase,
|
|
1121
|
+
wizardData: loopState.wizardData,
|
|
1122
|
+
craftingSessionId: loopState.craftingSessionId || null,
|
|
1123
|
+
});
|
|
1124
|
+
if (loopState.phase === "crafting" || loopState.phase === "approval") {
|
|
1125
|
+
var _hasPrompt = false;
|
|
1126
|
+
var _hasJudge = false;
|
|
1127
|
+
var _lDir = loopDir();
|
|
1128
|
+
if (_lDir) {
|
|
1129
|
+
try { fs.accessSync(path.join(_lDir, "PROMPT.md")); _hasPrompt = true; } catch (e) {}
|
|
1130
|
+
try { fs.accessSync(path.join(_lDir, "JUDGE.md")); _hasJudge = true; } catch (e) {}
|
|
1131
|
+
}
|
|
1132
|
+
sendTo(ws, {
|
|
1133
|
+
type: "ralph_files_status",
|
|
1134
|
+
promptReady: _hasPrompt,
|
|
1135
|
+
judgeReady: _hasJudge,
|
|
1136
|
+
bothReady: _hasPrompt && _hasJudge,
|
|
1137
|
+
taskId: loopState.loopId,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// --- Public API ---
|
|
1143
|
+
return {
|
|
1144
|
+
loopState: loopState,
|
|
1145
|
+
loopRegistry: loopRegistry,
|
|
1146
|
+
loopDir: loopDir,
|
|
1147
|
+
startLoop: startLoop,
|
|
1148
|
+
stopLoop: stopLoop,
|
|
1149
|
+
resumeLoop: resumeLoop,
|
|
1150
|
+
handleLoopMessage: handleLoopMessage,
|
|
1151
|
+
sendConnectionState: sendConnectionState,
|
|
1152
|
+
stopClaudeDirWatch: stopClaudeDirWatch,
|
|
1153
|
+
getSchedules: function () { return loopRegistry.getAll(); },
|
|
1154
|
+
importSchedule: function (data) { return loopRegistry.register(data); },
|
|
1155
|
+
removeSchedule: function (id) { return loopRegistry.remove(id); },
|
|
1156
|
+
stopTimer: function () { loopRegistry.stopTimer(); },
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
module.exports = { attachLoop: attachLoop };
|