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 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
  },
@@ -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)
@@ -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 and skipIfRunning is enabled
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 but skipIfRunning disabled for " + record.name + "; deferring");
316
+ console.log("[loop-registry] Loop active, queuing trigger for " + record.name);
317
+ pendingTriggers.push(record);
283
318
  return;
284
319
  }
285
320
 
286
- // For schedule records, resolve the linked task to get loop files
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 = (loopState.wizardData && loopState.wizardData.source === "task") ? null : "ralph";
1259
+ var _connSource = loopState.wizardData ? (loopState.wizardData.source || null) : null;
1178
1260
  sendTo(ws, {
1179
1261
  type: "ralph_phase",
1180
1262
  phase: loopState.phase,