clay-server 2.27.1 → 2.28.0-beta.2
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/daemon.js +21 -0
- package/lib/mcp-local.js +355 -0
- package/lib/project-connection.js +2 -0
- package/lib/project-loop.js +116 -34
- package/lib/project-mcp.js +371 -0
- package/lib/project-user-message.js +6 -3
- package/lib/project.js +51 -11
- package/lib/public/app.js +4 -0
- package/lib/public/css/filebrowser.css +204 -0
- package/lib/public/css/scheduler-modal.css +156 -1
- package/lib/public/css/scheduler.css +81 -0
- package/lib/public/index.html +99 -59
- package/lib/public/modules/app-loop-ui.js +85 -2
- package/lib/public/modules/app-messages.js +11 -1
- package/lib/public/modules/app-misc.js +104 -0
- package/lib/public/modules/mcp-ui.js +295 -0
- package/lib/public/modules/scheduler-config.js +241 -162
- package/lib/public/modules/scheduler-history.js +57 -5
- package/lib/public/modules/scheduler.js +80 -36
- package/lib/sdk-bridge.js +86 -17
- package/lib/server-mates.js +7 -2
- package/lib/server.js +6 -0
- package/lib/ws-schema.js +10 -0
- package/package.json +1 -1
package/lib/daemon.js
CHANGED
|
@@ -597,6 +597,27 @@ var relay = createServer({
|
|
|
597
597
|
}
|
|
598
598
|
return { ok: false, error: "Project not found" };
|
|
599
599
|
},
|
|
600
|
+
onGetProjectMcpServers: function (slug) {
|
|
601
|
+
for (var i = 0; i < config.projects.length; i++) {
|
|
602
|
+
if (config.projects[i].slug === slug) {
|
|
603
|
+
return config.projects[i].enabledMcpServers || [];
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return [];
|
|
607
|
+
},
|
|
608
|
+
onSetProjectMcpServers: function (slug, servers) {
|
|
609
|
+
for (var i = 0; i < config.projects.length; i++) {
|
|
610
|
+
if (config.projects[i].slug === slug) {
|
|
611
|
+
if (servers && servers.length > 0) {
|
|
612
|
+
config.projects[i].enabledMcpServers = servers;
|
|
613
|
+
} else {
|
|
614
|
+
delete config.projects[i].enabledMcpServers;
|
|
615
|
+
}
|
|
616
|
+
saveConfig(config);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
},
|
|
600
621
|
onGetServerDefaultMode: function () {
|
|
601
622
|
return { mode: config.defaultMode || null };
|
|
602
623
|
},
|
package/lib/mcp-local.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// mcp-local.js - Local MCP process manager
|
|
2
|
+
// Spawns and manages MCP stdio processes directly on the Clay server machine.
|
|
3
|
+
// Used when the client connects from localhost (no Native Host needed).
|
|
4
|
+
|
|
5
|
+
var child_process = require("child_process");
|
|
6
|
+
var fs = require("fs");
|
|
7
|
+
var path = require("path");
|
|
8
|
+
var os = require("os");
|
|
9
|
+
|
|
10
|
+
var CLAY_CONFIG_PATH = path.join(os.homedir(), ".clay", "mcp.json");
|
|
11
|
+
|
|
12
|
+
function createLocalMcp() {
|
|
13
|
+
var _configCache = {}; // name -> { command, args, env, url }
|
|
14
|
+
var _processes = {}; // name -> { proc, buffer, ready, tools, pendingInit }
|
|
15
|
+
var _pendingRequests = {}; // rpcId -> { callId, resolve, reject, timer }
|
|
16
|
+
var _initCallbacks = {}; // rpcId -> { name, phase }
|
|
17
|
+
var _jsonRpcId = 1;
|
|
18
|
+
var _initialized = false;
|
|
19
|
+
var _onServersReady = null; // callback when server list changes
|
|
20
|
+
|
|
21
|
+
// ---------- Config ----------
|
|
22
|
+
|
|
23
|
+
function ensureConfig() {
|
|
24
|
+
var dir = path.dirname(CLAY_CONFIG_PATH);
|
|
25
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
if (!fs.existsSync(CLAY_CONFIG_PATH)) {
|
|
27
|
+
fs.writeFileSync(CLAY_CONFIG_PATH, JSON.stringify({ mcpServers: {}, include: [] }, null, 2));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readConfig() {
|
|
32
|
+
ensureConfig();
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(fs.readFileSync(CLAY_CONFIG_PATH, "utf8"));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return { mcpServers: {}, include: [] };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeConfig(config) {
|
|
41
|
+
ensureConfig();
|
|
42
|
+
fs.writeFileSync(CLAY_CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getMergedServers() {
|
|
46
|
+
var config = readConfig();
|
|
47
|
+
var merged = Object.assign({}, config.mcpServers || {});
|
|
48
|
+
|
|
49
|
+
var includes = config.include || [];
|
|
50
|
+
for (var i = 0; i < includes.length; i++) {
|
|
51
|
+
var resolved = includes[i].replace(/^~/, os.homedir());
|
|
52
|
+
try {
|
|
53
|
+
var ext = JSON.parse(fs.readFileSync(resolved, "utf8"));
|
|
54
|
+
var extServers = ext.mcpServers || {};
|
|
55
|
+
var names = Object.keys(extServers);
|
|
56
|
+
for (var j = 0; j < names.length; j++) {
|
|
57
|
+
if (!merged[names[j]]) merged[names[j]] = extServers[names[j]];
|
|
58
|
+
}
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// Skip unreadable files
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_configCache = merged;
|
|
65
|
+
return merged;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------- Process Spawning ----------
|
|
69
|
+
|
|
70
|
+
function spawnServer(name) {
|
|
71
|
+
if (_processes[name]) return;
|
|
72
|
+
var cfg = _configCache[name];
|
|
73
|
+
if (!cfg || cfg.url || !cfg.command) return;
|
|
74
|
+
|
|
75
|
+
var env = Object.assign({}, process.env, cfg.env || {});
|
|
76
|
+
var proc;
|
|
77
|
+
try {
|
|
78
|
+
proc = child_process.spawn(cfg.command, cfg.args || [], {
|
|
79
|
+
env: env,
|
|
80
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
81
|
+
windowsHide: true,
|
|
82
|
+
});
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
var entry = {
|
|
88
|
+
proc: proc,
|
|
89
|
+
buffer: "",
|
|
90
|
+
ready: false,
|
|
91
|
+
tools: [],
|
|
92
|
+
};
|
|
93
|
+
_processes[name] = entry;
|
|
94
|
+
|
|
95
|
+
proc.stdout.on("data", function (chunk) {
|
|
96
|
+
entry.buffer += chunk.toString("utf8");
|
|
97
|
+
drainJsonRpc(name, entry);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
proc.stderr.on("data", function () {
|
|
101
|
+
// Ignore stderr
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
proc.on("error", function () {
|
|
105
|
+
delete _processes[name];
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
proc.on("exit", function () {
|
|
109
|
+
delete _processes[name];
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// MCP initialize handshake
|
|
113
|
+
var initId = _jsonRpcId++;
|
|
114
|
+
_initCallbacks[initId] = { name: name, phase: "initialize" };
|
|
115
|
+
proc.stdin.write(JSON.stringify({
|
|
116
|
+
jsonrpc: "2.0",
|
|
117
|
+
id: initId,
|
|
118
|
+
method: "initialize",
|
|
119
|
+
params: {
|
|
120
|
+
protocolVersion: "2024-11-05",
|
|
121
|
+
capabilities: {},
|
|
122
|
+
clientInfo: { name: "clay-mcp-local", version: "1.0.0" },
|
|
123
|
+
},
|
|
124
|
+
}) + "\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function killServer(name) {
|
|
128
|
+
var entry = _processes[name];
|
|
129
|
+
if (!entry) return;
|
|
130
|
+
entry.proc.kill("SIGTERM");
|
|
131
|
+
setTimeout(function () {
|
|
132
|
+
if (entry.proc && !entry.proc.killed) entry.proc.kill("SIGKILL");
|
|
133
|
+
}, 3000);
|
|
134
|
+
delete _processes[name];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------- JSON-RPC Parser ----------
|
|
138
|
+
|
|
139
|
+
function drainJsonRpc(name, entry) {
|
|
140
|
+
var lines = entry.buffer.split("\n");
|
|
141
|
+
entry.buffer = lines.pop();
|
|
142
|
+
for (var i = 0; i < lines.length; i++) {
|
|
143
|
+
var line = lines[i].trim();
|
|
144
|
+
if (!line) continue;
|
|
145
|
+
try {
|
|
146
|
+
var msg = JSON.parse(line);
|
|
147
|
+
handleMcpResponse(name, msg);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
// Skip unparseable
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function handleMcpResponse(name, msg) {
|
|
155
|
+
// Init handshake responses
|
|
156
|
+
if (msg.id !== undefined && _initCallbacks[msg.id]) {
|
|
157
|
+
var cb = _initCallbacks[msg.id];
|
|
158
|
+
delete _initCallbacks[msg.id];
|
|
159
|
+
|
|
160
|
+
if (cb.phase === "initialize") {
|
|
161
|
+
var entry = _processes[name];
|
|
162
|
+
if (entry) {
|
|
163
|
+
// Send initialized notification
|
|
164
|
+
entry.proc.stdin.write(JSON.stringify({
|
|
165
|
+
jsonrpc: "2.0",
|
|
166
|
+
method: "notifications/initialized",
|
|
167
|
+
}) + "\n");
|
|
168
|
+
|
|
169
|
+
// Request tools/list
|
|
170
|
+
var toolsId = _jsonRpcId++;
|
|
171
|
+
_initCallbacks[toolsId] = { name: name, phase: "tools_list" };
|
|
172
|
+
entry.proc.stdin.write(JSON.stringify({
|
|
173
|
+
jsonrpc: "2.0",
|
|
174
|
+
id: toolsId,
|
|
175
|
+
method: "tools/list",
|
|
176
|
+
params: {},
|
|
177
|
+
}) + "\n");
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (cb.phase === "tools_list") {
|
|
183
|
+
var tools = (msg.result && msg.result.tools) || [];
|
|
184
|
+
var entry2 = _processes[name];
|
|
185
|
+
if (entry2) {
|
|
186
|
+
entry2.tools = tools;
|
|
187
|
+
entry2.ready = true;
|
|
188
|
+
}
|
|
189
|
+
if (_onServersReady) _onServersReady();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Tool call responses
|
|
195
|
+
if (msg.id !== undefined && _pendingRequests[msg.id]) {
|
|
196
|
+
var req = _pendingRequests[msg.id];
|
|
197
|
+
delete _pendingRequests[msg.id];
|
|
198
|
+
if (req.timer) clearTimeout(req.timer);
|
|
199
|
+
|
|
200
|
+
if (msg.error) {
|
|
201
|
+
req.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
202
|
+
} else {
|
|
203
|
+
req.resolve(msg.result);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------- Tool Call Relay ----------
|
|
210
|
+
|
|
211
|
+
function callTool(serverName, toolName, args) {
|
|
212
|
+
return new Promise(function (resolve, reject) {
|
|
213
|
+
var entry = _processes[serverName];
|
|
214
|
+
if (!entry || !entry.ready) {
|
|
215
|
+
reject(new Error("MCP server not running: " + serverName));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
var rpcId = _jsonRpcId++;
|
|
220
|
+
_pendingRequests[rpcId] = {
|
|
221
|
+
resolve: resolve,
|
|
222
|
+
reject: reject,
|
|
223
|
+
timer: setTimeout(function () {
|
|
224
|
+
delete _pendingRequests[rpcId];
|
|
225
|
+
reject(new Error("Tool call timed out after 30s"));
|
|
226
|
+
}, 30000),
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
entry.proc.stdin.write(JSON.stringify({
|
|
230
|
+
jsonrpc: "2.0",
|
|
231
|
+
id: rpcId,
|
|
232
|
+
method: "tools/call",
|
|
233
|
+
params: { name: toolName, arguments: args || {} },
|
|
234
|
+
}) + "\n");
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------- Public API ----------
|
|
239
|
+
|
|
240
|
+
function initialize(onReady) {
|
|
241
|
+
if (_initialized) return;
|
|
242
|
+
_initialized = true;
|
|
243
|
+
_onServersReady = onReady || null;
|
|
244
|
+
|
|
245
|
+
getMergedServers();
|
|
246
|
+
var names = Object.keys(_configCache);
|
|
247
|
+
for (var i = 0; i < names.length; i++) {
|
|
248
|
+
spawnServer(names[i]);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function shutdown() {
|
|
253
|
+
var names = Object.keys(_processes);
|
|
254
|
+
for (var i = 0; i < names.length; i++) {
|
|
255
|
+
killServer(names[i]);
|
|
256
|
+
}
|
|
257
|
+
_processes = {};
|
|
258
|
+
_initialized = false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function getAvailableServers() {
|
|
262
|
+
var servers = [];
|
|
263
|
+
var names = Object.keys(_configCache);
|
|
264
|
+
for (var i = 0; i < names.length; i++) {
|
|
265
|
+
var name = names[i];
|
|
266
|
+
var cfg = _configCache[name];
|
|
267
|
+
var entry = _processes[name];
|
|
268
|
+
servers.push({
|
|
269
|
+
name: name,
|
|
270
|
+
transport: cfg.url ? "http" : "stdio",
|
|
271
|
+
ready: !!(entry && entry.ready),
|
|
272
|
+
tools: entry ? entry.tools : [],
|
|
273
|
+
toolCount: entry ? entry.tools.length : 0,
|
|
274
|
+
source: "local",
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return servers;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function isReady() {
|
|
281
|
+
return _initialized;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function addServer(name, command, args, env) {
|
|
285
|
+
var config = readConfig();
|
|
286
|
+
config.mcpServers = config.mcpServers || {};
|
|
287
|
+
config.mcpServers[name] = { command: command, args: args || [], env: env || {} };
|
|
288
|
+
writeConfig(config);
|
|
289
|
+
_configCache[name] = config.mcpServers[name];
|
|
290
|
+
spawnServer(name);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function removeServer(name) {
|
|
294
|
+
var config = readConfig();
|
|
295
|
+
if (config.mcpServers && config.mcpServers[name]) {
|
|
296
|
+
delete config.mcpServers[name];
|
|
297
|
+
writeConfig(config);
|
|
298
|
+
}
|
|
299
|
+
killServer(name);
|
|
300
|
+
delete _configCache[name];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function addImport(filePath) {
|
|
304
|
+
var resolved = filePath.replace(/^~/, os.homedir());
|
|
305
|
+
try {
|
|
306
|
+
var ext = JSON.parse(fs.readFileSync(resolved, "utf8"));
|
|
307
|
+
var count = Object.keys(ext.mcpServers || {}).length;
|
|
308
|
+
if (count === 0) return { error: "No mcpServers found in " + filePath };
|
|
309
|
+
} catch (e) {
|
|
310
|
+
return { error: "Cannot read file: " + e.message };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
var config = readConfig();
|
|
314
|
+
config.include = config.include || [];
|
|
315
|
+
if (config.include.indexOf(filePath) === -1) {
|
|
316
|
+
config.include.push(filePath);
|
|
317
|
+
writeConfig(config);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Re-merge and spawn new servers
|
|
321
|
+
getMergedServers();
|
|
322
|
+
var names = Object.keys(_configCache);
|
|
323
|
+
for (var i = 0; i < names.length; i++) {
|
|
324
|
+
if (!_processes[names[i]]) spawnServer(names[i]);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return { ok: true, count: count };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function removeImport(filePath) {
|
|
331
|
+
var config = readConfig();
|
|
332
|
+
config.include = (config.include || []).filter(function (p) { return p !== filePath; });
|
|
333
|
+
writeConfig(config);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function getImports() {
|
|
337
|
+
var config = readConfig();
|
|
338
|
+
return config.include || [];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
initialize: initialize,
|
|
343
|
+
shutdown: shutdown,
|
|
344
|
+
callTool: callTool,
|
|
345
|
+
getAvailableServers: getAvailableServers,
|
|
346
|
+
isReady: isReady,
|
|
347
|
+
addServer: addServer,
|
|
348
|
+
removeServer: removeServer,
|
|
349
|
+
addImport: addImport,
|
|
350
|
+
removeImport: removeImport,
|
|
351
|
+
getImports: getImports,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
module.exports = { createLocalMcp: createLocalMcp };
|
|
@@ -34,6 +34,7 @@ function attachConnection(ctx) {
|
|
|
34
34
|
var sendTo = ctx.sendTo;
|
|
35
35
|
var opts = ctx.opts;
|
|
36
36
|
var _loop = ctx._loop;
|
|
37
|
+
var _mcp = ctx._mcp;
|
|
37
38
|
var _notifications = ctx._notifications;
|
|
38
39
|
var hydrateImageRefs = ctx.hydrateImageRefs;
|
|
39
40
|
var broadcastClientCount = ctx.broadcastClientCount;
|
|
@@ -98,6 +99,7 @@ function attachConnection(ctx) {
|
|
|
98
99
|
sendTo(ws, { type: "notes_list", notes: nm.list() });
|
|
99
100
|
sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules() });
|
|
100
101
|
_loop.sendConnectionState(ws);
|
|
102
|
+
if (_mcp) _mcp.sendConnectionState(ws);
|
|
101
103
|
if (_notifications) _notifications.sendConnectionState(ws, sendTo);
|
|
102
104
|
|
|
103
105
|
// Session list (filtered for access control)
|
package/lib/project-loop.js
CHANGED
|
@@ -269,50 +269,56 @@ function attachLoop(ctx) {
|
|
|
269
269
|
|
|
270
270
|
// --- Loop Registry (unified one-off + scheduled) ---
|
|
271
271
|
var activeRegistryId = null; // track which registry record triggered current loop
|
|
272
|
+
var pendingTriggers = []; // queue for deferred triggers when skipIfRunning=false
|
|
273
|
+
|
|
274
|
+
function triggerFromQueue(record) {
|
|
275
|
+
// For schedule records, resolve the linked task to get loop files
|
|
276
|
+
var loopFilesId = record.id;
|
|
277
|
+
if (record.source === "schedule") {
|
|
278
|
+
if (!record.linkedTaskId) {
|
|
279
|
+
console.error("[loop-registry] Schedule has no linked task: " + record.name);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
loopFilesId = record.linkedTaskId;
|
|
283
|
+
console.log("[loop-registry] Schedule triggered: " + record.name + " -> linked task " + loopFilesId);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Verify the loop directory and PROMPT.md exist
|
|
287
|
+
var recDir = path.join(cwd, ".claude", "loops", loopFilesId);
|
|
288
|
+
try {
|
|
289
|
+
fs.accessSync(path.join(recDir, "PROMPT.md"));
|
|
290
|
+
} catch (e) {
|
|
291
|
+
console.error("[loop-registry] PROMPT.md missing for " + loopFilesId);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Set the loopId to the schedule's own id (not the linked task) so sidebar groups correctly
|
|
295
|
+
loopState.loopId = record.id;
|
|
296
|
+
loopState.loopFilesId = loopFilesId;
|
|
297
|
+
// Restore loopMode from LOOP.json so simple loops work correctly on trigger
|
|
298
|
+
var _triggerCfg = {};
|
|
299
|
+
try { _triggerCfg = JSON.parse(fs.readFileSync(path.join(recDir, "LOOP.json"), "utf8")); } catch (e) {}
|
|
300
|
+
loopState.wizardData = { loopMode: _triggerCfg.loopMode || "judge" };
|
|
301
|
+
activeRegistryId = record.id;
|
|
302
|
+
console.log("[loop-registry] Auto-starting loop: " + record.name + " (" + loopState.loopId + ")");
|
|
303
|
+
send({ type: "schedule_run_started", recordId: record.id });
|
|
304
|
+
startLoop({ maxIterations: record.maxIterations, name: record.name });
|
|
305
|
+
}
|
|
272
306
|
|
|
273
307
|
var loopRegistry = createLoopRegistry({
|
|
274
308
|
cwd: cwd,
|
|
275
309
|
onTrigger: function (record) {
|
|
276
|
-
// Skip trigger if a loop is already active
|
|
310
|
+
// Skip or queue trigger if a loop is already active
|
|
277
311
|
if (loopState.active || loopState.phase === "executing") {
|
|
278
312
|
if (record.skipIfRunning !== false) {
|
|
279
313
|
console.log("[loop-registry] Skipping trigger for " + record.name + " — loop already active (skipIfRunning)");
|
|
280
314
|
return;
|
|
281
315
|
}
|
|
282
|
-
console.log("[loop-registry] Loop active
|
|
316
|
+
console.log("[loop-registry] Loop active, queuing trigger for " + record.name);
|
|
317
|
+
pendingTriggers.push(record);
|
|
283
318
|
return;
|
|
284
319
|
}
|
|
285
320
|
|
|
286
|
-
|
|
287
|
-
var loopFilesId = record.id;
|
|
288
|
-
if (record.source === "schedule") {
|
|
289
|
-
if (!record.linkedTaskId) {
|
|
290
|
-
console.error("[loop-registry] Schedule has no linked task: " + record.name);
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
loopFilesId = record.linkedTaskId;
|
|
294
|
-
console.log("[loop-registry] Schedule triggered: " + record.name + " → linked task " + loopFilesId);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Verify the loop directory and PROMPT.md exist
|
|
298
|
-
var recDir = path.join(cwd, ".claude", "loops", loopFilesId);
|
|
299
|
-
try {
|
|
300
|
-
fs.accessSync(path.join(recDir, "PROMPT.md"));
|
|
301
|
-
} catch (e) {
|
|
302
|
-
console.error("[loop-registry] PROMPT.md missing for " + loopFilesId);
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
// Set the loopId to the schedule's own id (not the linked task) so sidebar groups correctly
|
|
306
|
-
loopState.loopId = record.id;
|
|
307
|
-
loopState.loopFilesId = loopFilesId;
|
|
308
|
-
// Restore loopMode from LOOP.json so simple loops work correctly on trigger
|
|
309
|
-
var _triggerCfg = {};
|
|
310
|
-
try { _triggerCfg = JSON.parse(fs.readFileSync(path.join(recDir, "LOOP.json"), "utf8")); } catch (e) {}
|
|
311
|
-
loopState.wizardData = { loopMode: _triggerCfg.loopMode || "judge" };
|
|
312
|
-
activeRegistryId = record.id;
|
|
313
|
-
console.log("[loop-registry] Auto-starting loop: " + record.name + " (" + loopState.loopId + ")");
|
|
314
|
-
send({ type: "schedule_run_started", recordId: record.id });
|
|
315
|
-
startLoop({ maxIterations: record.maxIterations, name: record.name });
|
|
321
|
+
triggerFromQueue(record);
|
|
316
322
|
},
|
|
317
323
|
onChange: function () {
|
|
318
324
|
send({ type: "loop_registry_updated", records: getHubSchedules() });
|
|
@@ -383,6 +389,7 @@ function attachLoop(ctx) {
|
|
|
383
389
|
loopState.results = [];
|
|
384
390
|
loopState.stopping = false;
|
|
385
391
|
loopState.name = loopOpts.name || null;
|
|
392
|
+
loopState.settings = loopConfig.settings || null;
|
|
386
393
|
loopState.startedAt = Date.now();
|
|
387
394
|
saveLoopState();
|
|
388
395
|
|
|
@@ -500,6 +507,7 @@ function attachLoop(ctx) {
|
|
|
500
507
|
sendToSession(session.localId, { type: "status", status: "processing" });
|
|
501
508
|
session.acceptEditsAfterStart = true;
|
|
502
509
|
session.singleTurn = true;
|
|
510
|
+
if (loopState.settings) session.loopSettings = loopState.settings;
|
|
503
511
|
sdk.startQuery(session, loopState.promptText, undefined, getLinuxUserForSession(session));
|
|
504
512
|
}
|
|
505
513
|
|
|
@@ -616,6 +624,7 @@ function attachLoop(ctx) {
|
|
|
616
624
|
judgeSession.sentToolResults = {};
|
|
617
625
|
judgeSession.acceptEditsAfterStart = true;
|
|
618
626
|
judgeSession.singleTurn = true;
|
|
627
|
+
if (loopState.settings) judgeSession.loopSettings = loopState.settings;
|
|
619
628
|
sdk.startQuery(judgeSession, judgePrompt, undefined, getLinuxUserForSession(judgeSession));
|
|
620
629
|
}
|
|
621
630
|
|
|
@@ -643,6 +652,16 @@ function attachLoop(ctx) {
|
|
|
643
652
|
|
|
644
653
|
function finishLoop(reason) {
|
|
645
654
|
console.log("[ralph-loop] finishLoop called, reason: " + reason + ", iteration: " + loopState.iteration);
|
|
655
|
+
|
|
656
|
+
// Unlock the last coder session so users can continue interacting with it
|
|
657
|
+
if (loopState.currentSessionId) {
|
|
658
|
+
var lastCoderSession = sm.sessions.get(loopState.currentSessionId);
|
|
659
|
+
if (lastCoderSession) {
|
|
660
|
+
lastCoderSession.singleTurn = false;
|
|
661
|
+
lastCoderSession.loop.active = false;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
646
665
|
loopState.active = false;
|
|
647
666
|
loopState.phase = "done";
|
|
648
667
|
loopState.stopping = false;
|
|
@@ -690,6 +709,15 @@ function attachLoop(ctx) {
|
|
|
690
709
|
sessionId: loopState.currentSessionId,
|
|
691
710
|
});
|
|
692
711
|
}
|
|
712
|
+
|
|
713
|
+
// Process next queued trigger if any
|
|
714
|
+
if (pendingTriggers.length > 0) {
|
|
715
|
+
var next = pendingTriggers.shift();
|
|
716
|
+
console.log("[loop-registry] Processing queued trigger: " + next.name);
|
|
717
|
+
setTimeout(function () {
|
|
718
|
+
triggerFromQueue(next);
|
|
719
|
+
}, 1000);
|
|
720
|
+
}
|
|
693
721
|
}
|
|
694
722
|
|
|
695
723
|
function resumeLoop() {
|
|
@@ -774,6 +802,17 @@ function attachLoop(ctx) {
|
|
|
774
802
|
send({ type: "loop_scheduled", recordId: loopState.loopId, cron: loopState.wizardData.cron });
|
|
775
803
|
return true;
|
|
776
804
|
}
|
|
805
|
+
// Save per-loop settings to LOOP.json if provided
|
|
806
|
+
if (msg.settings && Object.keys(msg.settings).length > 0) {
|
|
807
|
+
var lDir3 = loopDir();
|
|
808
|
+
if (lDir3) {
|
|
809
|
+
var ljPath = path.join(lDir3, "LOOP.json");
|
|
810
|
+
var lj = {};
|
|
811
|
+
try { lj = JSON.parse(fs.readFileSync(ljPath, "utf8")); } catch (e) {}
|
|
812
|
+
lj.settings = msg.settings;
|
|
813
|
+
fs.writeFileSync(ljPath, JSON.stringify(lj, null, 2), "utf8");
|
|
814
|
+
}
|
|
815
|
+
}
|
|
777
816
|
startLoop({ maxIterations: msg.maxIterations });
|
|
778
817
|
return true;
|
|
779
818
|
}
|
|
@@ -789,6 +828,7 @@ function attachLoop(ctx) {
|
|
|
789
828
|
var wizardCron = wData.cron || null;
|
|
790
829
|
var newLoopId = generateLoopId();
|
|
791
830
|
loopState.loopId = newLoopId;
|
|
831
|
+
var recordSource = wData.source === "task" ? null : "ralph";
|
|
792
832
|
loopState.wizardData = {
|
|
793
833
|
name: wData.name || wData.task || "Untitled",
|
|
794
834
|
task: wData.task || "",
|
|
@@ -797,13 +837,13 @@ function attachLoop(ctx) {
|
|
|
797
837
|
loopMode: wData.loopMode || "judge",
|
|
798
838
|
promptAuthor: wData.promptAuthor || "clay",
|
|
799
839
|
judgeAuthor: wData.judgeAuthor || null,
|
|
840
|
+
source: recordSource,
|
|
800
841
|
};
|
|
801
842
|
loopState.phase = "crafting";
|
|
802
843
|
loopState.startedAt = Date.now();
|
|
803
844
|
saveLoopState();
|
|
804
845
|
|
|
805
846
|
// Register in loop registry
|
|
806
|
-
var recordSource = wData.source === "task" ? null : "ralph";
|
|
807
847
|
loopRegistry.register({
|
|
808
848
|
id: newLoopId,
|
|
809
849
|
name: loopState.wizardData.name,
|
|
@@ -963,17 +1003,59 @@ function attachLoop(ctx) {
|
|
|
963
1003
|
var lDir = path.join(cwd, ".claude", "loops", recId);
|
|
964
1004
|
var promptContent = "";
|
|
965
1005
|
var judgeContent = "";
|
|
1006
|
+
var loopSettings = null;
|
|
966
1007
|
try { promptContent = fs.readFileSync(path.join(lDir, "PROMPT.md"), "utf8"); } catch (e) {}
|
|
967
1008
|
try { judgeContent = fs.readFileSync(path.join(lDir, "JUDGE.md"), "utf8"); } catch (e) {}
|
|
1009
|
+
try {
|
|
1010
|
+
var loopJson = JSON.parse(fs.readFileSync(path.join(lDir, "LOOP.json"), "utf8"));
|
|
1011
|
+
loopSettings = loopJson.settings || null;
|
|
1012
|
+
} catch (e) {}
|
|
968
1013
|
send({
|
|
969
1014
|
type: "loop_registry_files_content",
|
|
970
1015
|
id: recId,
|
|
971
1016
|
prompt: promptContent,
|
|
972
1017
|
judge: judgeContent,
|
|
1018
|
+
settings: loopSettings,
|
|
973
1019
|
});
|
|
974
1020
|
return true;
|
|
975
1021
|
}
|
|
976
1022
|
|
|
1023
|
+
if (msg.type === "loop_registry_save_files") {
|
|
1024
|
+
var recId2 = msg.id;
|
|
1025
|
+
var lDir2 = path.join(cwd, ".claude", "loops", recId2);
|
|
1026
|
+
try {
|
|
1027
|
+
fs.mkdirSync(lDir2, { recursive: true });
|
|
1028
|
+
if (msg.prompt !== undefined) {
|
|
1029
|
+
fs.writeFileSync(path.join(lDir2, "PROMPT.md"), msg.prompt, "utf8");
|
|
1030
|
+
}
|
|
1031
|
+
if (msg.judge !== undefined) {
|
|
1032
|
+
fs.writeFileSync(path.join(lDir2, "JUDGE.md"), msg.judge, "utf8");
|
|
1033
|
+
}
|
|
1034
|
+
if (msg.settings !== undefined) {
|
|
1035
|
+
var loopJsonPath2 = path.join(lDir2, "LOOP.json");
|
|
1036
|
+
var loopJson2 = {};
|
|
1037
|
+
try { loopJson2 = JSON.parse(fs.readFileSync(loopJsonPath2, "utf8")); } catch (e) {}
|
|
1038
|
+
loopJson2.settings = msg.settings;
|
|
1039
|
+
fs.writeFileSync(loopJsonPath2, JSON.stringify(loopJson2, null, 2), "utf8");
|
|
1040
|
+
}
|
|
1041
|
+
send({ type: "loop_registry_save_files_result", id: recId2, ok: true });
|
|
1042
|
+
// Re-send updated content so the UI refreshes
|
|
1043
|
+
var updatedPrompt = "";
|
|
1044
|
+
var updatedJudge = "";
|
|
1045
|
+
var updatedSettings = null;
|
|
1046
|
+
try { updatedPrompt = fs.readFileSync(path.join(lDir2, "PROMPT.md"), "utf8"); } catch (e) {}
|
|
1047
|
+
try { updatedJudge = fs.readFileSync(path.join(lDir2, "JUDGE.md"), "utf8"); } catch (e) {}
|
|
1048
|
+
try {
|
|
1049
|
+
var uj = JSON.parse(fs.readFileSync(path.join(lDir2, "LOOP.json"), "utf8"));
|
|
1050
|
+
updatedSettings = uj.settings || null;
|
|
1051
|
+
} catch (e) {}
|
|
1052
|
+
send({ type: "loop_registry_files_content", id: recId2, prompt: updatedPrompt, judge: updatedJudge, settings: updatedSettings });
|
|
1053
|
+
} catch (e) {
|
|
1054
|
+
send({ type: "loop_registry_save_files_result", id: recId2, ok: false, error: e.message });
|
|
1055
|
+
}
|
|
1056
|
+
return true;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
977
1059
|
if (msg.type === "ralph_preview_files") {
|
|
978
1060
|
var promptContent = "";
|
|
979
1061
|
var judgeContent = "";
|
|
@@ -1174,7 +1256,7 @@ function attachLoop(ctx) {
|
|
|
1174
1256
|
|
|
1175
1257
|
// Ralph phase state
|
|
1176
1258
|
// Derive source from wizardData for reconnect (so client can distinguish ralph vs task)
|
|
1177
|
-
var _connSource =
|
|
1259
|
+
var _connSource = loopState.wizardData ? (loopState.wizardData.source || null) : null;
|
|
1178
1260
|
sendTo(ws, {
|
|
1179
1261
|
type: "ralph_phase",
|
|
1180
1262
|
phase: loopState.phase,
|