clay-server 2.7.0 → 2.7.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.
@@ -15,3 +15,5 @@
15
15
  @import url("css/skills.css");
16
16
  @import url("css/mobile-nav.css");
17
17
  @import url("css/loop.css");
18
+ @import url("css/scheduler.css");
19
+ @import url("css/scheduler-modal.css");
@@ -0,0 +1,362 @@
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
+ }
147
+ records.push(rec);
148
+ } catch (e) {
149
+ // skip malformed line
150
+ }
151
+ }
152
+ } catch (e) {
153
+ records = [];
154
+ }
155
+ }
156
+
157
+ function save() {
158
+ try {
159
+ fs.mkdirSync(registryDir, { recursive: true });
160
+ var lines = [];
161
+ for (var i = 0; i < records.length; i++) {
162
+ lines.push(JSON.stringify(records[i]));
163
+ }
164
+ var tmpPath = registryPath + ".tmp";
165
+ fs.writeFileSync(tmpPath, lines.join("\n") + "\n");
166
+ fs.renameSync(tmpPath, registryPath);
167
+ } catch (e) {
168
+ console.error("[loop-registry] Failed to save:", e.message);
169
+ }
170
+ }
171
+
172
+ // --- Timer (scheduled loops only) ---
173
+
174
+ function startTimer() {
175
+ if (timerId) return;
176
+ timerId = setInterval(function () {
177
+ tick();
178
+ }, CHECK_INTERVAL);
179
+ tick();
180
+ }
181
+
182
+ function stopTimer() {
183
+ if (timerId) {
184
+ clearInterval(timerId);
185
+ timerId = null;
186
+ }
187
+ }
188
+
189
+ function tick() {
190
+ var now = Date.now();
191
+ var nowMinuteKey = Math.floor(now / 60000);
192
+
193
+ for (var i = 0; i < records.length; i++) {
194
+ var rec = records[i];
195
+ if (!rec.cron) continue; // skip one-off
196
+ if (!rec.enabled) continue;
197
+ if (!rec.nextRunAt) continue;
198
+ if (rec.nextRunAt > now) continue;
199
+
200
+ // Avoid double-trigger within same minute
201
+ var triggerKey = rec.id + "_" + nowMinuteKey;
202
+ if (lastTriggeredMinute[triggerKey]) continue;
203
+ lastTriggeredMinute[triggerKey] = true;
204
+
205
+ // Clean old trigger keys
206
+ var keys = Object.keys(lastTriggeredMinute);
207
+ for (var k = 0; k < keys.length; k++) {
208
+ var keyParts = keys[k].split("_");
209
+ var keyMinute = parseInt(keyParts[keyParts.length - 1], 10);
210
+ if (keyMinute < nowMinuteKey - 1) {
211
+ delete lastTriggeredMinute[keys[k]];
212
+ }
213
+ }
214
+
215
+ // Update nextRunAt
216
+ rec.lastRunAt = now;
217
+ rec.nextRunAt = nextRunTime(rec.cron, now);
218
+ save();
219
+
220
+ console.log("[loop-registry] Triggering scheduled loop: " + rec.name + " (" + rec.id + ")");
221
+ if (onTrigger) {
222
+ try { onTrigger(rec); } catch (e) {
223
+ console.error("[loop-registry] Trigger error:", e.message);
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ // --- CRUD ---
230
+
231
+ function register(data) {
232
+ var rec = {
233
+ id: data.id || ("loop_" + Date.now() + "_" + require("crypto").randomBytes(3).toString("hex")),
234
+ name: data.name || "Untitled",
235
+ task: data.task || "",
236
+ cron: data.cron || null,
237
+ enabled: data.cron ? (data.enabled !== false) : false,
238
+ maxIterations: data.maxIterations || 20,
239
+ createdAt: Date.now(),
240
+ updatedAt: Date.now(),
241
+ lastRunAt: null,
242
+ lastRunResult: null,
243
+ nextRunAt: null,
244
+ craftingSessionId: data.craftingSessionId || null,
245
+ runs: [],
246
+ };
247
+ if (rec.cron && rec.enabled) {
248
+ rec.nextRunAt = nextRunTime(rec.cron);
249
+ }
250
+ records.push(rec);
251
+ save();
252
+ if (onChange) onChange(records);
253
+ return rec;
254
+ }
255
+
256
+ function update(id, data) {
257
+ var rec = getById(id);
258
+ if (!rec) return null;
259
+
260
+ if (data.name !== undefined) rec.name = data.name;
261
+ if (data.cron !== undefined) rec.cron = data.cron;
262
+ if (data.enabled !== undefined) rec.enabled = data.enabled;
263
+ if (data.maxIterations !== undefined) rec.maxIterations = data.maxIterations;
264
+ rec.updatedAt = Date.now();
265
+ rec.nextRunAt = (rec.cron && rec.enabled) ? nextRunTime(rec.cron) : null;
266
+
267
+ save();
268
+ if (onChange) onChange(records);
269
+ return rec;
270
+ }
271
+
272
+ function updateRecord(id, data) {
273
+ var rec = getById(id);
274
+ if (!rec) return null;
275
+ var keys = Object.keys(data);
276
+ for (var i = 0; i < keys.length; i++) {
277
+ rec[keys[i]] = data[keys[i]];
278
+ }
279
+ save();
280
+ return rec;
281
+ }
282
+
283
+ function remove(id) {
284
+ var idx = -1;
285
+ for (var i = 0; i < records.length; i++) {
286
+ if (records[i].id === id) { idx = i; break; }
287
+ }
288
+ if (idx === -1) return false;
289
+ records.splice(idx, 1);
290
+ save();
291
+ if (onChange) onChange(records);
292
+ return true;
293
+ }
294
+
295
+ function toggleEnabled(id) {
296
+ var rec = getById(id);
297
+ if (!rec || !rec.cron) return null; // only toggle scheduled loops
298
+ rec.enabled = !rec.enabled;
299
+ rec.updatedAt = Date.now();
300
+ rec.nextRunAt = rec.enabled ? nextRunTime(rec.cron) : null;
301
+ save();
302
+ if (onChange) onChange(records);
303
+ return rec;
304
+ }
305
+
306
+ function recordRun(id, result) {
307
+ var rec = getById(id);
308
+ if (!rec) return;
309
+ rec.lastRunAt = result.startedAt || Date.now();
310
+ rec.lastRunResult = result.reason || null;
311
+ rec.runs.push({
312
+ startedAt: result.startedAt || rec.lastRunAt,
313
+ finishedAt: Date.now(),
314
+ result: result.reason || "unknown",
315
+ iterations: result.iterations || 0,
316
+ });
317
+ // Keep only last 20 run entries
318
+ if (rec.runs.length > 20) {
319
+ rec.runs = rec.runs.slice(-20);
320
+ }
321
+ save();
322
+ if (onChange) onChange(records);
323
+ }
324
+
325
+ function getAll() {
326
+ return records;
327
+ }
328
+
329
+ function getById(id) {
330
+ for (var i = 0; i < records.length; i++) {
331
+ if (records[i].id === id) return records[i];
332
+ }
333
+ return null;
334
+ }
335
+
336
+ function getScheduled() {
337
+ var result = [];
338
+ for (var i = 0; i < records.length; i++) {
339
+ if (records[i].cron) result.push(records[i]);
340
+ }
341
+ return result;
342
+ }
343
+
344
+ return {
345
+ load: load,
346
+ save: save,
347
+ startTimer: startTimer,
348
+ stopTimer: stopTimer,
349
+ register: register,
350
+ update: update,
351
+ updateRecord: updateRecord,
352
+ remove: remove,
353
+ toggleEnabled: toggleEnabled,
354
+ recordRun: recordRun,
355
+ getAll: getAll,
356
+ getById: getById,
357
+ getScheduled: getScheduled,
358
+ nextRunTime: nextRunTime,
359
+ };
360
+ }
361
+
362
+ module.exports = { createLoopRegistry: createLoopRegistry };
package/lib/sdk-bridge.js CHANGED
@@ -159,6 +159,11 @@ function createSDKBridge(opts) {
159
159
  if (parsed.type === "stream_event" && parsed.event) {
160
160
  var evt = parsed.event;
161
161
 
162
+ if (evt.type === "message_start" && evt.message && evt.message.usage) {
163
+ var u = evt.message.usage;
164
+ session.lastStreamInputTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0);
165
+ }
166
+
162
167
  if (evt.type === "content_block_start") {
163
168
  var block = evt.content_block;
164
169
  var idx = evt.index;
@@ -309,6 +314,8 @@ function createSDKBridge(opts) {
309
314
  session.taskIdMap = {};
310
315
  session.isProcessing = false;
311
316
  onProcessingChanged();
317
+ var lastStreamInput = session.lastStreamInputTokens || null;
318
+ session.lastStreamInputTokens = null;
312
319
  sendAndRecord(session, {
313
320
  type: "result",
314
321
  cost: parsed.total_cost_usd,
@@ -316,6 +323,7 @@ function createSDKBridge(opts) {
316
323
  usage: parsed.usage || null,
317
324
  modelUsage: parsed.modelUsage || null,
318
325
  sessionId: parsed.session_id,
326
+ lastStreamInputTokens: lastStreamInput,
319
327
  });
320
328
  if (parsed.fast_mode_state) {
321
329
  sendAndRecord(session, { type: "fast_mode_state", state: parsed.fast_mode_state });
package/lib/sessions.js CHANGED
@@ -16,8 +16,9 @@ function createSessionManager(opts) {
16
16
  var skillNames = null; // Claude-only skills to filter from slash menu
17
17
 
18
18
  // --- Session persistence (centralized in ~/.clay/sessions/{encoded-cwd}/) ---
19
- var encodedCwd = utils.encodeCwd(cwd);
20
- var sessionsDir = path.join(config.CONFIG_DIR, "sessions", encodedCwd);
19
+ var sessionsBase = path.join(config.CONFIG_DIR, "sessions");
20
+ var encodedCwd = utils.resolveEncodedDir(sessionsBase, cwd);
21
+ var sessionsDir = path.join(sessionsBase, encodedCwd);
21
22
  fs.mkdirSync(sessionsDir, { recursive: true });
22
23
 
23
24
  // Auto-migrate sessions from legacy locations:
@@ -170,7 +171,7 @@ function createSessionManager(opts) {
170
171
  function broadcastSessionList() {
171
172
  send({
172
173
  type: "session_list",
173
- sessions: [...sessions.values()].map(function(s) {
174
+ sessions: [...sessions.values()].filter(function(s) { return !s.hidden; }).map(function(s) {
174
175
  return {
175
176
  id: s.localId,
176
177
  cliSessionId: s.cliSessionId || null,
@@ -237,17 +238,19 @@ function createSessionManager(opts) {
237
238
  var lastUsage = null;
238
239
  var lastModelUsage = null;
239
240
  var lastCost = null;
241
+ var lastStreamInputTokens = null;
240
242
  for (var j = total - 1; j >= 0; j--) {
241
243
  if (session.history[j].type === "result") {
242
244
  var r = session.history[j];
243
245
  lastUsage = r.usage || null;
244
246
  lastModelUsage = r.modelUsage || null;
245
247
  lastCost = r.cost != null ? r.cost : null;
248
+ lastStreamInputTokens = r.lastStreamInputTokens || null;
246
249
  break;
247
250
  }
248
251
  }
249
252
 
250
- send({ type: "history_done", lastUsage: lastUsage, lastModelUsage: lastModelUsage, lastCost: lastCost });
253
+ send({ type: "history_done", lastUsage: lastUsage, lastModelUsage: lastModelUsage, lastCost: lastCost, lastStreamInputTokens: lastStreamInputTokens });
251
254
  }
252
255
 
253
256
  function switchSession(localId) {
package/lib/utils.js CHANGED
@@ -2,17 +2,63 @@
2
2
  * Shared utility functions.
3
3
  */
4
4
 
5
+ var fs = require("fs");
6
+
5
7
  /**
6
8
  * Encode a cwd path into a filesystem-safe directory/file name.
7
- * Replaces forward slashes and dots with hyphens so that usernames
8
- * like "jon.doe" don't break session/note lookups.
9
+ * Replaces all non-alphanumeric characters with hyphens, matching
10
+ * Claude Code CLI's encoding logic exactly (/[^a-zA-Z0-9]/g -> "-").
9
11
  *
10
- * Example: "/Users/jon.doe/my-project" -> "-Users-jon-doe-my-project"
12
+ * Example: "/Users/jon.doe_42/my project" -> "-Users-jon-doe-42-my-project"
11
13
  */
12
14
  function encodeCwd(cwd) {
15
+ return cwd.replace(/[^a-zA-Z0-9]/g, "-");
16
+ }
17
+
18
+ /**
19
+ * Legacy encoding (pre-#182 fix). Only slashes and dots were replaced.
20
+ * Used for fallback resolution of on-disk data written before the fix.
21
+ */
22
+ function legacyEncodeCwd(cwd) {
13
23
  return cwd.replace(/[\/\.]/g, "-");
14
24
  }
15
25
 
26
+ /**
27
+ * Try candidate encoded names against a base directory.
28
+ * Returns the first match that exists on disk, or the first candidate
29
+ * (newest encoding) if none exist yet.
30
+ */
31
+ function resolveEncoded(baseDir, cwd, ext, checkFn) {
32
+ var newEncoded = encodeCwd(cwd);
33
+ var legacyEncoded = legacyEncodeCwd(cwd);
34
+ if (newEncoded === legacyEncoded) return newEncoded;
35
+ var full = baseDir + "/" + newEncoded + (ext || "");
36
+ try { if (checkFn(full)) return newEncoded; } catch (e) {}
37
+ var legacyFull = baseDir + "/" + legacyEncoded + (ext || "");
38
+ try { if (checkFn(legacyFull)) return legacyEncoded; } catch (e) {}
39
+ return newEncoded;
40
+ }
41
+
42
+ /**
43
+ * Resolve an encoded directory path with legacy fallback.
44
+ */
45
+ function resolveEncodedDir(baseDir, cwd) {
46
+ return resolveEncoded(baseDir, cwd, "", function(p) {
47
+ return fs.statSync(p).isDirectory();
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Resolve an encoded file path with legacy fallback.
53
+ */
54
+ function resolveEncodedFile(baseDir, cwd, ext) {
55
+ return resolveEncoded(baseDir, cwd, ext, function(p) {
56
+ return fs.statSync(p).isFile();
57
+ });
58
+ }
59
+
16
60
  module.exports = {
17
61
  encodeCwd: encodeCwd,
62
+ resolveEncodedDir: resolveEncodedDir,
63
+ resolveEncodedFile: resolveEncodedFile,
18
64
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.7.0",
3
+ "version": "2.7.1",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",