clay-server 2.7.2 → 2.8.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.
Files changed (55) hide show
  1. package/bin/cli.js +31 -17
  2. package/lib/config.js +7 -4
  3. package/lib/project.js +343 -15
  4. package/lib/public/app.js +1039 -134
  5. package/lib/public/apple-touch-icon-dark.png +0 -0
  6. package/lib/public/apple-touch-icon.png +0 -0
  7. package/lib/public/clay-logo.png +0 -0
  8. package/lib/public/css/base.css +18 -1
  9. package/lib/public/css/filebrowser.css +1 -0
  10. package/lib/public/css/home-hub.css +455 -0
  11. package/lib/public/css/icon-strip.css +6 -5
  12. package/lib/public/css/loop.css +141 -23
  13. package/lib/public/css/messages.css +2 -0
  14. package/lib/public/css/mobile-nav.css +38 -12
  15. package/lib/public/css/overlays.css +205 -169
  16. package/lib/public/css/playbook.css +264 -0
  17. package/lib/public/css/profile.css +268 -0
  18. package/lib/public/css/scheduler-modal.css +1429 -0
  19. package/lib/public/css/scheduler.css +1305 -0
  20. package/lib/public/css/sidebar.css +305 -11
  21. package/lib/public/css/sticky-notes.css +23 -19
  22. package/lib/public/css/stt.css +155 -0
  23. package/lib/public/css/title-bar.css +14 -6
  24. package/lib/public/favicon-banded-32.png +0 -0
  25. package/lib/public/favicon-banded.png +0 -0
  26. package/lib/public/icon-192-dark.png +0 -0
  27. package/lib/public/icon-192.png +0 -0
  28. package/lib/public/icon-512-dark.png +0 -0
  29. package/lib/public/icon-512.png +0 -0
  30. package/lib/public/icon-banded-76.png +0 -0
  31. package/lib/public/icon-banded-96.png +0 -0
  32. package/lib/public/index.html +336 -44
  33. package/lib/public/modules/ascii-logo.js +442 -0
  34. package/lib/public/modules/markdown.js +18 -0
  35. package/lib/public/modules/notifications.js +50 -63
  36. package/lib/public/modules/playbook.js +578 -0
  37. package/lib/public/modules/profile.js +357 -0
  38. package/lib/public/modules/project-settings.js +1 -9
  39. package/lib/public/modules/scheduler.js +2826 -0
  40. package/lib/public/modules/server-settings.js +1 -1
  41. package/lib/public/modules/sidebar.js +376 -32
  42. package/lib/public/modules/stt.js +272 -0
  43. package/lib/public/modules/terminal.js +32 -0
  44. package/lib/public/modules/theme.js +3 -10
  45. package/lib/public/style.css +6 -0
  46. package/lib/public/sw.js +82 -3
  47. package/lib/public/wordmark-banded-20.png +0 -0
  48. package/lib/public/wordmark-banded-32.png +0 -0
  49. package/lib/public/wordmark-banded-64.png +0 -0
  50. package/lib/public/wordmark-banded-80.png +0 -0
  51. package/lib/scheduler.js +402 -0
  52. package/lib/sdk-bridge.js +3 -2
  53. package/lib/server.js +124 -3
  54. package/lib/sessions.js +35 -2
  55. package/package.json +1 -1
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Loop Registry — unified storage for all Ralph Loops (one-off + scheduled).
3
+ *
4
+ * Stores loop records in ~/.clay/loops/{encodedCwd}.jsonl
5
+ * Each record represents a job defined via clay-ralph (PROMPT.md + JUDGE.md).
6
+ * Records with a `cron` field are checked every 30s and auto-triggered.
7
+ * Records without cron are one-off (standard Ralph Loop behavior).
8
+ */
9
+
10
+ var fs = require("fs");
11
+ var path = require("path");
12
+ var { CONFIG_DIR } = require("./config");
13
+ var { encodeCwd } = require("./utils");
14
+
15
+ // --- Cron parser (5-field: minute hour day-of-month month day-of-week) ---
16
+
17
+ function parseCronField(field, min, max) {
18
+ var values = [];
19
+ var parts = field.split(",");
20
+ for (var i = 0; i < parts.length; i++) {
21
+ var part = parts[i].trim();
22
+
23
+ // wildcard with step: */N
24
+ if (part.indexOf("/") !== -1) {
25
+ var slashParts = part.split("/");
26
+ var step = parseInt(slashParts[1], 10);
27
+ var rangeStr = slashParts[0];
28
+ var rangeMin = min;
29
+ var rangeMax = max;
30
+ if (rangeStr !== "*") {
31
+ var rp = rangeStr.split("-");
32
+ rangeMin = parseInt(rp[0], 10);
33
+ rangeMax = rp.length > 1 ? parseInt(rp[1], 10) : rangeMin;
34
+ }
35
+ for (var v = rangeMin; v <= rangeMax; v += step) {
36
+ values.push(v);
37
+ }
38
+ continue;
39
+ }
40
+
41
+ // wildcard
42
+ if (part === "*") {
43
+ for (var v = min; v <= max; v++) {
44
+ values.push(v);
45
+ }
46
+ continue;
47
+ }
48
+
49
+ // range: N-M
50
+ if (part.indexOf("-") !== -1) {
51
+ var rangeParts = part.split("-");
52
+ var from = parseInt(rangeParts[0], 10);
53
+ var to = parseInt(rangeParts[1], 10);
54
+ for (var v = from; v <= to; v++) {
55
+ values.push(v);
56
+ }
57
+ continue;
58
+ }
59
+
60
+ // single value
61
+ values.push(parseInt(part, 10));
62
+ }
63
+ return values;
64
+ }
65
+
66
+ function parseCron(expr) {
67
+ var fields = expr.trim().split(/\s+/);
68
+ if (fields.length !== 5) return null;
69
+ return {
70
+ minutes: parseCronField(fields[0], 0, 59),
71
+ hours: parseCronField(fields[1], 0, 23),
72
+ daysOfMonth: parseCronField(fields[2], 1, 31),
73
+ months: parseCronField(fields[3], 1, 12),
74
+ daysOfWeek: parseCronField(fields[4], 0, 6),
75
+ };
76
+ }
77
+
78
+ function cronMatches(parsed, date) {
79
+ var minute = date.getMinutes();
80
+ var hour = date.getHours();
81
+ var dayOfMonth = date.getDate();
82
+ var month = date.getMonth() + 1;
83
+ var dayOfWeek = date.getDay();
84
+
85
+ return (
86
+ parsed.minutes.indexOf(minute) !== -1 &&
87
+ parsed.hours.indexOf(hour) !== -1 &&
88
+ parsed.daysOfMonth.indexOf(dayOfMonth) !== -1 &&
89
+ parsed.months.indexOf(month) !== -1 &&
90
+ parsed.daysOfWeek.indexOf(dayOfWeek) !== -1
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Calculate next run time from a cron expression after a given date.
96
+ * Brute-force: check each minute for up to 366 days.
97
+ */
98
+ function nextRunTime(cronExpr, after) {
99
+ var parsed = parseCron(cronExpr);
100
+ if (!parsed) return null;
101
+
102
+ var d = new Date(after || Date.now());
103
+ d.setSeconds(0, 0);
104
+ d.setMinutes(d.getMinutes() + 1);
105
+
106
+ var limit = 366 * 24 * 60;
107
+ for (var i = 0; i < limit; i++) {
108
+ if (cronMatches(parsed, d)) {
109
+ return d.getTime();
110
+ }
111
+ d.setMinutes(d.getMinutes() + 1);
112
+ }
113
+ return null;
114
+ }
115
+
116
+ // --- Loop Registry factory ---
117
+
118
+ function createLoopRegistry(opts) {
119
+ var cwd = opts.cwd;
120
+ var onTrigger = opts.onTrigger;
121
+ var onChange = opts.onChange;
122
+
123
+ var encoded = encodeCwd(cwd);
124
+ var registryDir = path.join(CONFIG_DIR, "loops");
125
+ var registryPath = path.join(registryDir, encoded + ".jsonl");
126
+
127
+ var records = [];
128
+ var timerId = null;
129
+ var CHECK_INTERVAL = 30 * 1000;
130
+ var lastTriggeredMinute = {};
131
+
132
+ // --- Persistence (JSONL) ---
133
+
134
+ function load() {
135
+ try {
136
+ var raw = fs.readFileSync(registryPath, "utf8");
137
+ var lines = raw.trim().split("\n");
138
+ records = [];
139
+ for (var i = 0; i < lines.length; i++) {
140
+ if (!lines[i].trim()) continue;
141
+ try {
142
+ var rec = JSON.parse(lines[i]);
143
+ // Recalculate nextRunAt for scheduled records
144
+ if (rec.cron && rec.enabled) {
145
+ rec.nextRunAt = nextRunTime(rec.cron);
146
+ } else if (!rec.cron && rec.enabled && rec.date && rec.time && rec.source === "schedule") {
147
+ // One-off: recalculate from date+time
148
+ var dtP = rec.date.split("-");
149
+ var tmP = rec.time.split(":");
150
+ var runD = new Date(parseInt(dtP[0], 10), parseInt(dtP[1], 10) - 1, parseInt(dtP[2], 10), parseInt(tmP[0], 10), parseInt(tmP[1], 10), 0);
151
+ rec.nextRunAt = runD.getTime();
152
+ }
153
+ records.push(rec);
154
+ } catch (e) {
155
+ // skip malformed line
156
+ }
157
+ }
158
+ } catch (e) {
159
+ records = [];
160
+ }
161
+ }
162
+
163
+ function save() {
164
+ try {
165
+ fs.mkdirSync(registryDir, { recursive: true });
166
+ var lines = [];
167
+ for (var i = 0; i < records.length; i++) {
168
+ lines.push(JSON.stringify(records[i]));
169
+ }
170
+ var tmpPath = registryPath + ".tmp";
171
+ fs.writeFileSync(tmpPath, lines.join("\n") + "\n");
172
+ fs.renameSync(tmpPath, registryPath);
173
+ } catch (e) {
174
+ console.error("[loop-registry] Failed to save:", e.message);
175
+ }
176
+ }
177
+
178
+ // --- Timer (scheduled loops only) ---
179
+
180
+ function startTimer() {
181
+ if (timerId) return;
182
+ timerId = setInterval(function () {
183
+ tick();
184
+ }, CHECK_INTERVAL);
185
+ tick();
186
+ }
187
+
188
+ function stopTimer() {
189
+ if (timerId) {
190
+ clearInterval(timerId);
191
+ timerId = null;
192
+ }
193
+ }
194
+
195
+ function tick() {
196
+ var now = Date.now();
197
+ var nowMinuteKey = Math.floor(now / 60000);
198
+
199
+ for (var i = 0; i < records.length; i++) {
200
+ var rec = records[i];
201
+ if (!rec.enabled) continue;
202
+ if (!rec.nextRunAt) continue;
203
+ if (rec.nextRunAt > now) continue;
204
+
205
+ // Avoid double-trigger within same minute
206
+ var triggerKey = rec.id + "_" + nowMinuteKey;
207
+ if (lastTriggeredMinute[triggerKey]) continue;
208
+ lastTriggeredMinute[triggerKey] = true;
209
+
210
+ // Clean old trigger keys
211
+ var keys = Object.keys(lastTriggeredMinute);
212
+ for (var k = 0; k < keys.length; k++) {
213
+ var keyParts = keys[k].split("_");
214
+ var keyMinute = parseInt(keyParts[keyParts.length - 1], 10);
215
+ if (keyMinute < nowMinuteKey - 1) {
216
+ delete lastTriggeredMinute[keys[k]];
217
+ }
218
+ }
219
+
220
+ // Update nextRunAt
221
+ rec.lastRunAt = now;
222
+ if (rec.cron) {
223
+ rec.nextRunAt = nextRunTime(rec.cron, now);
224
+ } else {
225
+ // One-off schedule: disable after firing
226
+ rec.nextRunAt = null;
227
+ rec.enabled = false;
228
+ }
229
+ save();
230
+ if (onChange) onChange(records);
231
+
232
+ console.log("[loop-registry] Triggering scheduled loop: " + rec.name + " (" + rec.id + ")");
233
+ if (onTrigger) {
234
+ try { onTrigger(rec); } catch (e) {
235
+ console.error("[loop-registry] Trigger error:", e.message);
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ // --- CRUD ---
242
+
243
+ function register(data) {
244
+ var rec = {
245
+ id: data.id || ("loop_" + Date.now() + "_" + require("crypto").randomBytes(3).toString("hex")),
246
+ name: data.name || "Untitled",
247
+ task: data.task || "",
248
+ cron: data.cron || null,
249
+ enabled: data.cron ? (data.enabled !== false) : false,
250
+ maxIterations: data.maxIterations || 20,
251
+ createdAt: Date.now(),
252
+ updatedAt: Date.now(),
253
+ lastRunAt: null,
254
+ lastRunResult: null,
255
+ nextRunAt: null,
256
+ description: data.description || "",
257
+ date: data.date || null,
258
+ time: data.time || null,
259
+ allDay: data.allDay !== undefined ? data.allDay : true,
260
+ linkedTaskId: data.linkedTaskId || null,
261
+ craftingSessionId: data.craftingSessionId || null,
262
+ source: data.source || null,
263
+ color: data.color || null,
264
+ recurrenceEnd: data.recurrenceEnd || null,
265
+ runs: [],
266
+ };
267
+ if (rec.cron && rec.enabled) {
268
+ rec.nextRunAt = nextRunTime(rec.cron);
269
+ } else if (!rec.cron && rec.date && rec.time && rec.source === "schedule") {
270
+ // One-off schedule: compute nextRunAt from date + time
271
+ var dtParts = rec.date.split("-");
272
+ var tmParts = rec.time.split(":");
273
+ var runDate = new Date(parseInt(dtParts[0], 10), parseInt(dtParts[1], 10) - 1, parseInt(dtParts[2], 10), parseInt(tmParts[0], 10), parseInt(tmParts[1], 10), 0);
274
+ rec.nextRunAt = runDate.getTime();
275
+ rec.enabled = true;
276
+ }
277
+ records.push(rec);
278
+ save();
279
+ if (onChange) onChange(records);
280
+ return rec;
281
+ }
282
+
283
+ function update(id, data) {
284
+ var rec = getById(id);
285
+ if (!rec) return null;
286
+
287
+ if (data.name !== undefined) rec.name = data.name;
288
+ if (data.cron !== undefined) rec.cron = data.cron;
289
+ if (data.enabled !== undefined) rec.enabled = data.enabled;
290
+ if (data.maxIterations !== undefined) rec.maxIterations = data.maxIterations;
291
+ if (data.date !== undefined) rec.date = data.date;
292
+ if (data.recurrenceEnd !== undefined) rec.recurrenceEnd = data.recurrenceEnd;
293
+ rec.updatedAt = Date.now();
294
+ if (rec.cron && rec.enabled) {
295
+ rec.nextRunAt = nextRunTime(rec.cron);
296
+ } else if (!rec.cron && rec.date && rec.time && rec.source === "schedule") {
297
+ var dtP2 = rec.date.split("-");
298
+ var tmP2 = rec.time.split(":");
299
+ var runD2 = new Date(parseInt(dtP2[0], 10), parseInt(dtP2[1], 10) - 1, parseInt(dtP2[2], 10), parseInt(tmP2[0], 10), parseInt(tmP2[1], 10), 0);
300
+ rec.nextRunAt = runD2.getTime();
301
+ rec.enabled = true;
302
+ } else {
303
+ rec.nextRunAt = null;
304
+ }
305
+
306
+ save();
307
+ if (onChange) onChange(records);
308
+ return rec;
309
+ }
310
+
311
+ function updateRecord(id, data) {
312
+ var rec = getById(id);
313
+ if (!rec) return null;
314
+ var keys = Object.keys(data);
315
+ for (var i = 0; i < keys.length; i++) {
316
+ rec[keys[i]] = data[keys[i]];
317
+ }
318
+ save();
319
+ if (onChange) onChange(records);
320
+ return rec;
321
+ }
322
+
323
+ function remove(id) {
324
+ var idx = -1;
325
+ for (var i = 0; i < records.length; i++) {
326
+ if (records[i].id === id) { idx = i; break; }
327
+ }
328
+ if (idx === -1) return false;
329
+ records.splice(idx, 1);
330
+ save();
331
+ if (onChange) onChange(records);
332
+ return true;
333
+ }
334
+
335
+ function toggleEnabled(id) {
336
+ var rec = getById(id);
337
+ if (!rec || !rec.cron) return null; // only toggle scheduled loops
338
+ rec.enabled = !rec.enabled;
339
+ rec.updatedAt = Date.now();
340
+ rec.nextRunAt = rec.enabled ? nextRunTime(rec.cron) : null;
341
+ save();
342
+ if (onChange) onChange(records);
343
+ return rec;
344
+ }
345
+
346
+ function recordRun(id, result) {
347
+ var rec = getById(id);
348
+ if (!rec) return;
349
+ rec.lastRunAt = result.startedAt || Date.now();
350
+ rec.lastRunResult = result.reason || null;
351
+ rec.runs.push({
352
+ startedAt: result.startedAt || rec.lastRunAt,
353
+ finishedAt: Date.now(),
354
+ result: result.reason || "unknown",
355
+ iterations: result.iterations || 0,
356
+ });
357
+ // Keep only last 20 run entries
358
+ if (rec.runs.length > 20) {
359
+ rec.runs = rec.runs.slice(-20);
360
+ }
361
+ save();
362
+ if (onChange) onChange(records);
363
+ }
364
+
365
+ function getAll() {
366
+ return records;
367
+ }
368
+
369
+ function getById(id) {
370
+ for (var i = 0; i < records.length; i++) {
371
+ if (records[i].id === id) return records[i];
372
+ }
373
+ return null;
374
+ }
375
+
376
+ function getScheduled() {
377
+ var result = [];
378
+ for (var i = 0; i < records.length; i++) {
379
+ if (records[i].cron) result.push(records[i]);
380
+ }
381
+ return result;
382
+ }
383
+
384
+ return {
385
+ load: load,
386
+ save: save,
387
+ startTimer: startTimer,
388
+ stopTimer: stopTimer,
389
+ register: register,
390
+ update: update,
391
+ updateRecord: updateRecord,
392
+ remove: remove,
393
+ toggleEnabled: toggleEnabled,
394
+ recordRun: recordRun,
395
+ getAll: getAll,
396
+ getById: getById,
397
+ getScheduled: getScheduled,
398
+ nextRunTime: nextRunTime,
399
+ };
400
+ }
401
+
402
+ module.exports = { createLoopRegistry: createLoopRegistry };
package/lib/sdk-bridge.js CHANGED
@@ -487,8 +487,9 @@ function createSDKBridge(opts) {
487
487
  // --- SDK query lifecycle ---
488
488
 
489
489
  function handleCanUseTool(session, toolName, input, opts) {
490
- // Ralph Loop: auto-approve all tools, deny interactive ones
491
- if (session.loop && session.loop.active) {
490
+ // Ralph Loop execution: auto-approve all tools, deny interactive ones.
491
+ // Crafting sessions are interactive — user and Claude collaborate to build PROMPT.md / JUDGE.md.
492
+ if (session.loop && session.loop.active && session.loop.role !== "crafting") {
492
493
  if (toolName === "AskUserQuestion") {
493
494
  return Promise.resolve({ behavior: "deny", message: "Autonomous mode. Make your own decision." });
494
495
  }
package/lib/server.js CHANGED
@@ -240,7 +240,10 @@ function serveStatic(urlPath, res) {
240
240
  var content = fs.readFileSync(filePath);
241
241
  var ext = path.extname(filePath);
242
242
  var mime = MIME_TYPES[ext] || "application/octet-stream";
243
- res.writeHead(200, { "Content-Type": mime + "; charset=utf-8", "Cache-Control": "no-cache" });
243
+ res.writeHead(200, {
244
+ "Content-Type": mime + "; charset=utf-8",
245
+ "Cache-Control": "no-cache",
246
+ });
244
247
  res.end(content);
245
248
  return true;
246
249
  } catch (e) {
@@ -451,6 +454,50 @@ function createServer(opts) {
451
454
  return;
452
455
  }
453
456
 
457
+ // User profile
458
+ var profilePath = path.join(CONFIG_DIR, "profile.json");
459
+
460
+ if (req.method === "GET" && fullUrl === "/api/profile") {
461
+ var profile = { name: "", lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "" };
462
+ try {
463
+ var raw = fs.readFileSync(profilePath, "utf8");
464
+ var saved = JSON.parse(raw);
465
+ if (saved.name !== undefined) profile.name = saved.name;
466
+ if (saved.lang) profile.lang = saved.lang;
467
+ if (saved.avatarColor) profile.avatarColor = saved.avatarColor;
468
+ if (saved.avatarStyle) profile.avatarStyle = saved.avatarStyle;
469
+ if (saved.avatarSeed) profile.avatarSeed = saved.avatarSeed;
470
+ } catch (e) { /* file doesn't exist yet */ }
471
+ res.writeHead(200, { "Content-Type": "application/json" });
472
+ res.end(JSON.stringify(profile));
473
+ return;
474
+ }
475
+
476
+ if (req.method === "PUT" && fullUrl === "/api/profile") {
477
+ var body = "";
478
+ req.on("data", function (chunk) { body += chunk; });
479
+ req.on("end", function () {
480
+ try {
481
+ var data = JSON.parse(body);
482
+ var profile = {};
483
+ if (typeof data.name === "string") profile.name = data.name.substring(0, 50);
484
+ if (typeof data.lang === "string") profile.lang = data.lang.substring(0, 10);
485
+ if (typeof data.avatarColor === "string" && /^#[0-9a-fA-F]{6}$/.test(data.avatarColor)) {
486
+ profile.avatarColor = data.avatarColor;
487
+ }
488
+ if (typeof data.avatarStyle === "string") profile.avatarStyle = data.avatarStyle.substring(0, 30);
489
+ if (typeof data.avatarSeed === "string") profile.avatarSeed = data.avatarSeed.substring(0, 30);
490
+ fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2), "utf8");
491
+ res.writeHead(200, { "Content-Type": "application/json" });
492
+ res.end(JSON.stringify(profile));
493
+ } catch (e) {
494
+ res.writeHead(400, { "Content-Type": "application/json" });
495
+ res.end(JSON.stringify({ error: "Invalid request" }));
496
+ }
497
+ });
498
+ return;
499
+ }
500
+
454
501
  // Skills proxy: leaderboard list
455
502
  if (req.method === "GET" && fullUrl === "/api/skills") {
456
503
  var qs = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
@@ -491,8 +538,20 @@ function createServer(opts) {
491
538
  res.end(searchCached.data);
492
539
  return;
493
540
  }
494
- fetchSkillsPage("https://skills.sh/?q=" + encodeURIComponent(searchQ)).then(function (data) {
495
- var json = JSON.stringify(data);
541
+ // Reuse cached "all" tab data if available, otherwise fetch
542
+ var allCached = skillsCache["skills_all"];
543
+ var allPromise = (allCached && Date.now() - allCached.ts < 300000)
544
+ ? Promise.resolve(JSON.parse(allCached.data))
545
+ : fetchSkillsPage("https://skills.sh/");
546
+ allPromise.then(function (data) {
547
+ var q = searchQ.toLowerCase();
548
+ var filtered = (data.skills || []).filter(function (s) {
549
+ var name = (s.name || "").toLowerCase();
550
+ var source = (s.source || "").toLowerCase();
551
+ var skillId = (s.skillId || "").toLowerCase();
552
+ return name.indexOf(q) >= 0 || source.indexOf(q) >= 0 || skillId.indexOf(q) >= 0;
553
+ });
554
+ var json = JSON.stringify({ skills: filtered });
496
555
  skillsCache[searchCacheKey] = { ts: Date.now(), data: json };
497
556
  res.writeHead(200, { "Content-Type": "application/json" });
498
557
  res.end(json);
@@ -775,6 +834,68 @@ function createServer(opts) {
775
834
  projects.forEach(function (ctx) { list.push(ctx.getStatus()); });
776
835
  return list;
777
836
  },
837
+ getHubSchedules: function () {
838
+ var allSchedules = [];
839
+ projects.forEach(function (ctx, s) {
840
+ var status = ctx.getStatus();
841
+ var recs = ctx.getSchedules();
842
+ for (var i = 0; i < recs.length; i++) {
843
+ // Shallow-copy full record and augment with project metadata
844
+ var copy = {};
845
+ var keys = Object.keys(recs[i]);
846
+ for (var k = 0; k < keys.length; k++) copy[keys[k]] = recs[i][keys[k]];
847
+ copy.projectSlug = s;
848
+ copy.projectTitle = status.title || status.project;
849
+ allSchedules.push(copy);
850
+ }
851
+ });
852
+ return allSchedules;
853
+ },
854
+ // Move a schedule record from one project to another
855
+ moveScheduleToProject: function (recordId, fromSlug, toSlug) {
856
+ var fromCtx = projects.get(fromSlug);
857
+ var toCtx = projects.get(toSlug);
858
+ if (!fromCtx || !toCtx) return { ok: false, error: "Project not found" };
859
+ var recs = fromCtx.getSchedules();
860
+ var rec = null;
861
+ for (var i = 0; i < recs.length; i++) {
862
+ if (recs[i].id === recordId) { rec = recs[i]; break; }
863
+ }
864
+ if (!rec) return { ok: false, error: "Record not found" };
865
+ // Copy full record data
866
+ var data = {};
867
+ var keys = Object.keys(rec);
868
+ for (var k = 0; k < keys.length; k++) data[keys[k]] = rec[keys[k]];
869
+ // Import into target, remove from source
870
+ toCtx.importSchedule(data);
871
+ fromCtx.removeSchedule(recordId);
872
+ return { ok: true };
873
+ },
874
+ // Bulk move all schedules from one project to another
875
+ moveAllSchedulesToProject: function (fromSlug, toSlug) {
876
+ var fromCtx = projects.get(fromSlug);
877
+ var toCtx = projects.get(toSlug);
878
+ if (!fromCtx || !toCtx) return { ok: false, error: "Project not found" };
879
+ var recs = fromCtx.getSchedules();
880
+ for (var i = 0; i < recs.length; i++) {
881
+ var data = {};
882
+ var keys = Object.keys(recs[i]);
883
+ for (var k = 0; k < keys.length; k++) data[keys[k]] = recs[i][keys[k]];
884
+ toCtx.importSchedule(data);
885
+ }
886
+ // Remove all from source
887
+ var ids = recs.map(function (r) { return r.id; });
888
+ for (var j = 0; j < ids.length; j++) {
889
+ fromCtx.removeSchedule(ids[j]);
890
+ }
891
+ return { ok: true };
892
+ },
893
+ // Get schedule count for a project slug
894
+ getScheduleCount: function (slug) {
895
+ var ctx = projects.get(slug);
896
+ if (!ctx) return 0;
897
+ return ctx.getSchedules().length;
898
+ },
778
899
  onProcessingChanged: broadcastProcessingChange,
779
900
  onAddProject: onAddProject,
780
901
  onRemoveProject: onRemoveProject,
package/lib/sessions.js CHANGED
@@ -81,6 +81,7 @@ function createSessionManager(opts) {
81
81
  createdAt: session.createdAt,
82
82
  };
83
83
  if (session.lastRewindUuid) metaObj.lastRewindUuid = session.lastRewindUuid;
84
+ if (session.loop) metaObj.loop = session.loop;
84
85
  var meta = JSON.stringify(metaObj);
85
86
  var lines = [meta];
86
87
  for (var i = 0; i < session.history.length; i++) {
@@ -157,6 +158,7 @@ function createSessionManager(opts) {
157
158
  messageUUIDs: messageUUIDs,
158
159
  lastRewindUuid: m.lastRewindUuid || null,
159
160
  };
161
+ if (m.loop) session.loop = m.loop;
160
162
  sessions.set(localId, session);
161
163
  }
162
164
  }
@@ -168,10 +170,24 @@ function createSessionManager(opts) {
168
170
  return sessions.get(activeSessionId) || null;
169
171
  }
170
172
 
173
+ var resolveLoopInfo = null; // optional callback: (loopId) => { name, source } or null
174
+
175
+ function setResolveLoopInfo(fn) {
176
+ resolveLoopInfo = fn;
177
+ }
178
+
171
179
  function broadcastSessionList() {
172
180
  send({
173
181
  type: "session_list",
174
- sessions: [...sessions.values()].map(function(s) {
182
+ sessions: [...sessions.values()].filter(function(s) { return !s.hidden; }).map(function(s) {
183
+ var loop = s.loop ? Object.assign({}, s.loop) : null;
184
+ if (loop && loop.loopId && resolveLoopInfo) {
185
+ var info = resolveLoopInfo(loop.loopId);
186
+ if (info) {
187
+ if (info.name) loop.name = info.name;
188
+ if (info.source) loop.source = info.source;
189
+ }
190
+ }
175
191
  return {
176
192
  id: s.localId,
177
193
  cliSessionId: s.cliSessionId || null,
@@ -179,7 +195,7 @@ function createSessionManager(opts) {
179
195
  active: s.localId === activeSessionId,
180
196
  isProcessing: s.isProcessing,
181
197
  lastActivity: s.lastActivity || s.createdAt || 0,
182
- loop: s.loop || null,
198
+ loop: loop,
183
199
  };
184
200
  }),
185
201
  });
@@ -310,6 +326,21 @@ function createSessionManager(opts) {
310
326
  }
311
327
  }
312
328
 
329
+ function deleteSessionQuiet(localId) {
330
+ var session = sessions.get(localId);
331
+ if (!session) return;
332
+ if (session.abortController) {
333
+ try { session.abortController.abort(); } catch(e) {}
334
+ }
335
+ if (session.messageQueue) {
336
+ try { session.messageQueue.end(); } catch(e) {}
337
+ }
338
+ if (session.cliSessionId) {
339
+ try { fs.unlinkSync(sessionFilePath(session.cliSessionId)); } catch(e) {}
340
+ }
341
+ sessions.delete(localId);
342
+ }
343
+
313
344
  function doSendAndRecord(session, obj) {
314
345
  session.history.push(obj);
315
346
  appendToSessionFile(session, obj);
@@ -419,6 +450,7 @@ function createSessionManager(opts) {
419
450
  createSession: createSession,
420
451
  switchSession: switchSession,
421
452
  deleteSession: deleteSession,
453
+ deleteSessionQuiet: deleteSessionQuiet,
422
454
  resumeSession: resumeSession,
423
455
  broadcastSessionList: broadcastSessionList,
424
456
  saveSessionFile: saveSessionFile,
@@ -427,6 +459,7 @@ function createSessionManager(opts) {
427
459
  findTurnBoundary: findTurnBoundary,
428
460
  replayHistory: replayHistory,
429
461
  searchSessions: searchSessions,
462
+ setResolveLoopInfo: setResolveLoopInfo,
430
463
  };
431
464
  }
432
465
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.7.2",
3
+ "version": "2.8.2",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",