clay-server 2.7.1 → 2.8.0

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 (56) hide show
  1. package/lib/project.js +176 -20
  2. package/lib/public/app.js +846 -92
  3. package/lib/public/apple-touch-icon-dark.png +0 -0
  4. package/lib/public/apple-touch-icon.png +0 -0
  5. package/lib/public/clay-logo.png +0 -0
  6. package/lib/public/css/base.css +10 -0
  7. package/lib/public/css/filebrowser.css +1 -0
  8. package/lib/public/css/home-hub.css +455 -0
  9. package/lib/public/css/icon-strip.css +6 -5
  10. package/lib/public/css/loop.css +86 -29
  11. package/lib/public/css/messages.css +2 -0
  12. package/lib/public/css/mobile-nav.css +38 -12
  13. package/lib/public/css/overlays.css +205 -169
  14. package/lib/public/css/playbook.css +264 -0
  15. package/lib/public/css/profile.css +268 -0
  16. package/lib/public/css/scheduler-modal.css +883 -0
  17. package/lib/public/css/scheduler.css +379 -18
  18. package/lib/public/css/sidebar.css +305 -11
  19. package/lib/public/css/sticky-notes.css +23 -19
  20. package/lib/public/css/stt.css +155 -0
  21. package/lib/public/css/title-bar.css +14 -6
  22. package/lib/public/favicon-banded-32.png +0 -0
  23. package/lib/public/favicon-banded.png +0 -0
  24. package/lib/public/icon-192-dark.png +0 -0
  25. package/lib/public/icon-192.png +0 -0
  26. package/lib/public/icon-512-dark.png +0 -0
  27. package/lib/public/icon-512.png +0 -0
  28. package/lib/public/icon-banded-76.png +0 -0
  29. package/lib/public/icon-banded-96.png +0 -0
  30. package/lib/public/index.html +252 -32
  31. package/lib/public/modules/ascii-logo.js +389 -0
  32. package/lib/public/modules/filebrowser.js +2 -1
  33. package/lib/public/modules/markdown.js +108 -0
  34. package/lib/public/modules/notifications.js +50 -63
  35. package/lib/public/modules/playbook.js +578 -0
  36. package/lib/public/modules/profile.js +357 -0
  37. package/lib/public/modules/project-settings.js +4 -9
  38. package/lib/public/modules/scheduler.js +1620 -34
  39. package/lib/public/modules/server-settings.js +1 -1
  40. package/lib/public/modules/sidebar.js +378 -31
  41. package/lib/public/modules/sticky-notes.js +2 -0
  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/modules/tools.js +2 -1
  46. package/lib/public/style.css +4 -0
  47. package/lib/public/sw.js +82 -3
  48. package/lib/public/wordmark-banded-20.png +0 -0
  49. package/lib/public/wordmark-banded-32.png +0 -0
  50. package/lib/public/wordmark-banded-64.png +0 -0
  51. package/lib/public/wordmark-banded-80.png +0 -0
  52. package/lib/scheduler.js +43 -3
  53. package/lib/sdk-bridge.js +3 -2
  54. package/lib/server.js +124 -3
  55. package/lib/sessions.js +34 -1
  56. package/package.json +1 -1
@@ -0,0 +1,272 @@
1
+ // Speech-to-Text module using Web Speech API
2
+ // Uses browser's built-in speech recognition (Chrome/Edge/Safari → Google servers)
3
+
4
+ import { iconHtml, refreshIcons } from './icons.js';
5
+ import { autoResize } from './input.js';
6
+
7
+ var ctx;
8
+
9
+ // --- State ---
10
+ var recording = false;
11
+ var recognition = null;
12
+ var selectedLang = null;
13
+ var textBeforeSTT = '';
14
+ var interimText = '';
15
+
16
+ // DOM refs
17
+ var sttBtn = null;
18
+ var langPopover = null;
19
+
20
+ // --- Language options ---
21
+ // Web Speech API uses BCP-47 language tags
22
+ var LANGUAGES = [
23
+ { code: 'en-US', name: 'English' },
24
+ { code: 'ko-KR', name: 'Korean' },
25
+ { code: 'ja-JP', name: 'Japanese' },
26
+ { code: 'zh-CN', name: 'Chinese' },
27
+ { code: 'es-ES', name: 'Spanish' },
28
+ { code: 'fr-FR', name: 'French' },
29
+ { code: 'de-DE', name: 'German' },
30
+ ];
31
+
32
+ // --- Persist language choice ---
33
+ function saveLang(code) {
34
+ try { localStorage.setItem('stt-lang', code); } catch (e) { /* ignore */ }
35
+ }
36
+
37
+ function loadLang() {
38
+ try { return localStorage.getItem('stt-lang'); } catch (e) { return null; }
39
+ }
40
+
41
+ // --- Check browser support ---
42
+ function getSpeechRecognition() {
43
+ return window.SpeechRecognition || window.webkitSpeechRecognition || null;
44
+ }
45
+
46
+ // --- Init ---
47
+ export function initSTT(_ctx) {
48
+ ctx = _ctx;
49
+
50
+ sttBtn = document.getElementById('stt-btn');
51
+ if (!sttBtn) return;
52
+
53
+ if (!getSpeechRecognition()) {
54
+ sttBtn.style.display = 'none';
55
+ console.warn('[STT] Web Speech API not supported in this browser');
56
+ return;
57
+ }
58
+
59
+ // Restore saved language
60
+ selectedLang = loadLang();
61
+
62
+ sttBtn.addEventListener('click', function(e) {
63
+ e.stopPropagation();
64
+
65
+ if (recording) {
66
+ stopRecording();
67
+ return;
68
+ }
69
+
70
+ if (!selectedLang) {
71
+ showLangPopover();
72
+ } else {
73
+ startRecording();
74
+ }
75
+ });
76
+
77
+ // Right-click to change language
78
+ sttBtn.addEventListener('contextmenu', function(e) {
79
+ e.preventDefault();
80
+ e.stopPropagation();
81
+ if (recording) stopRecording();
82
+ showLangPopover();
83
+ });
84
+ }
85
+
86
+ // --- Language popover ---
87
+ function showLangPopover() {
88
+ if (langPopover) {
89
+ hideLangPopover();
90
+ return;
91
+ }
92
+
93
+ langPopover = document.createElement('div');
94
+ langPopover.className = 'stt-lang-popover';
95
+
96
+ var html = '<div class="stt-lang-title">Voice Input Language</div>';
97
+ for (var i = 0; i < LANGUAGES.length; i++) {
98
+ var l = LANGUAGES[i];
99
+ var activeClass = (selectedLang === l.code) ? ' stt-lang-active' : '';
100
+ html += '<button class="stt-lang-option' + activeClass + '" data-lang="' + l.code + '">' +
101
+ '<span class="stt-lang-name">' + l.name + '</span>' +
102
+ '</button>';
103
+ }
104
+ langPopover.innerHTML = html;
105
+
106
+ langPopover.querySelectorAll('.stt-lang-option').forEach(function(btn) {
107
+ btn.addEventListener('click', function() {
108
+ onLangSelected(btn.dataset.lang);
109
+ });
110
+ });
111
+
112
+ var wrapper = document.getElementById('input-wrapper');
113
+ wrapper.appendChild(langPopover);
114
+
115
+ setTimeout(function() {
116
+ document.addEventListener('click', closeLangOnOutside);
117
+ }, 0);
118
+ }
119
+
120
+ function closeLangOnOutside(e) {
121
+ if (langPopover && !langPopover.contains(e.target) && e.target !== sttBtn && !sttBtn.contains(e.target)) {
122
+ hideLangPopover();
123
+ }
124
+ }
125
+
126
+ function hideLangPopover() {
127
+ if (langPopover) {
128
+ langPopover.remove();
129
+ langPopover = null;
130
+ }
131
+ document.removeEventListener('click', closeLangOnOutside);
132
+ }
133
+
134
+ function onLangSelected(code) {
135
+ selectedLang = code;
136
+ saveLang(code);
137
+ hideLangPopover();
138
+ startRecording();
139
+ }
140
+
141
+ // --- Recording ---
142
+ function startRecording() {
143
+ if (recording) return;
144
+
145
+ var SpeechRecognition = getSpeechRecognition();
146
+ if (!SpeechRecognition) return;
147
+
148
+ recognition = new SpeechRecognition();
149
+ recognition.lang = selectedLang || 'en-US';
150
+ recognition.continuous = true;
151
+ recognition.interimResults = true;
152
+
153
+ textBeforeSTT = ctx.inputEl.value;
154
+ interimText = '';
155
+
156
+ recognition.onresult = function(e) {
157
+ var final = '';
158
+ var interim = '';
159
+
160
+ for (var i = 0; i < e.results.length; i++) {
161
+ var result = e.results[i];
162
+ if (result.isFinal) {
163
+ final += result[0].transcript;
164
+ } else {
165
+ interim += result[0].transcript;
166
+ }
167
+ }
168
+
169
+ var text = textBeforeSTT;
170
+ if (final) {
171
+ if (text && text.length > 0 && text[text.length - 1] !== ' ' && text[text.length - 1] !== '\n') {
172
+ text += ' ';
173
+ }
174
+ text += final;
175
+ }
176
+ if (interim) {
177
+ if (text && text.length > 0 && text[text.length - 1] !== ' ' && text[text.length - 1] !== '\n') {
178
+ text += ' ';
179
+ }
180
+ text += interim;
181
+ }
182
+
183
+ ctx.inputEl.value = text;
184
+ autoResize();
185
+ };
186
+
187
+ recognition.onerror = function(e) {
188
+ console.error('[STT] Recognition error:', e.error);
189
+ if (e.error === 'not-allowed') {
190
+ if (ctx.addSystemMessage) {
191
+ ctx.addSystemMessage('Microphone access denied.\n\nTo fix: click the lock icon in the address bar → Site settings → Microphone → Allow, then reload.', true);
192
+ }
193
+ stopRecording();
194
+ } else if (e.error === 'no-speech') {
195
+ // Silence — just keep listening
196
+ } else if (e.error === 'network') {
197
+ if (ctx.addSystemMessage) {
198
+ ctx.addSystemMessage('Speech recognition unavailable.\n\nWeb Speech API sends audio to Google servers for recognition. Some Chromium forks (Arc, Brave) block this connection.\n\nSupported: Chrome, Edge, Safari 14.1+, Samsung Internet\nNot supported: Arc, Brave, Firefox', true);
199
+ }
200
+ stopRecording();
201
+ }
202
+ };
203
+
204
+ recognition.onend = function() {
205
+ // Auto-restart if still recording (browser may stop after silence)
206
+ if (recording) {
207
+ // Save confirmed text so far
208
+ textBeforeSTT = ctx.inputEl.value;
209
+ try {
210
+ recognition.start();
211
+ } catch (e) {
212
+ // Already started or other error
213
+ stopRecording();
214
+ }
215
+ }
216
+ };
217
+
218
+ try {
219
+ recognition.start();
220
+ recording = true;
221
+ sttBtn.classList.add('stt-active');
222
+ sttBtn.innerHTML =
223
+ '<span class="stt-wave">' +
224
+ '<span class="stt-wave-bar"></span>' +
225
+ '<span class="stt-wave-bar"></span>' +
226
+ '<span class="stt-wave-bar"></span>' +
227
+ '<span class="stt-wave-bar"></span>' +
228
+ '<span class="stt-wave-bar"></span>' +
229
+ '</span>' +
230
+ '<span class="stt-stop-label">Stop</span>';
231
+ ctx.inputEl.setAttribute('placeholder', 'Listening...');
232
+ } catch (err) {
233
+ console.error('[STT] Failed to start:', err);
234
+ if (ctx.addSystemMessage) {
235
+ ctx.addSystemMessage('Failed to start voice input: ' + err.message, true);
236
+ }
237
+ }
238
+ }
239
+
240
+ function stopRecording() {
241
+ if (!recording) return;
242
+ recording = false;
243
+
244
+ if (recognition) {
245
+ try { recognition.stop(); } catch (e) { /* ignore */ }
246
+ recognition = null;
247
+ }
248
+
249
+ sttBtn.classList.remove('stt-active');
250
+ sttBtn.innerHTML = iconHtml('mic');
251
+ refreshIcons();
252
+ ctx.inputEl.setAttribute('placeholder', 'Message Claude Code...');
253
+ }
254
+
255
+ // --- External lang setter (used by profile module) ---
256
+ export function setSTTLang(code) {
257
+ selectedLang = code;
258
+ saveLang(code);
259
+ }
260
+
261
+ export function getSTTLang() {
262
+ return selectedLang;
263
+ }
264
+
265
+ // --- Exports ---
266
+ export function isSTTRecording() {
267
+ return recording;
268
+ }
269
+
270
+ export function isSTTInitializing() {
271
+ return false;
272
+ }
@@ -239,6 +239,20 @@ function createXtermForTab(tab) {
239
239
  }
240
240
  });
241
241
 
242
+ // Ctrl+V paste for Firefox (Firefox blocks xterm.js clipboard access)
243
+ bodyEl.addEventListener("keydown", function (e) {
244
+ if ((e.ctrlKey || e.metaKey) && e.key === "v") {
245
+ e.preventDefault();
246
+ if (navigator.clipboard && navigator.clipboard.readText) {
247
+ navigator.clipboard.readText().then(function (text) {
248
+ if (text && ctx.ws && ctx.connected) {
249
+ ctx.ws.send(JSON.stringify({ type: "term_input", id: tab.id, data: text }));
250
+ }
251
+ }).catch(function () { /* permission denied or not available */ });
252
+ }
253
+ }
254
+ });
255
+
242
256
  // Right-click context menu
243
257
  bodyEl.addEventListener("contextmenu", function (e) {
244
258
  showTermCtxMenu(e, tab);
@@ -599,6 +613,24 @@ function showTermCtxMenu(e, tab) {
599
613
  });
600
614
  menu.appendChild(copyItem);
601
615
 
616
+ // Paste
617
+ var pasteItem = document.createElement("button");
618
+ pasteItem.className = "term-ctx-item";
619
+ pasteItem.innerHTML = iconHtml("clipboard-paste") + " <span>Paste</span>";
620
+ pasteItem.addEventListener("click", function (ev) {
621
+ ev.stopPropagation();
622
+ closeTermCtxMenu();
623
+ if (!tab.xterm) return;
624
+ if (navigator.clipboard && navigator.clipboard.readText) {
625
+ navigator.clipboard.readText().then(function (text) {
626
+ if (text && ctx.ws && ctx.connected) {
627
+ ctx.ws.send(JSON.stringify({ type: "term_input", id: tab.id, data: text }));
628
+ }
629
+ }).catch(function () { /* permission denied or not available */ });
630
+ }
631
+ });
632
+ menu.appendChild(pasteItem);
633
+
602
634
  // Clear
603
635
  var clearItem = document.createElement("button");
604
636
  clearItem.className = "term-ctx-item";
@@ -370,17 +370,10 @@ export function applyTheme(themeId, fromPicker) {
370
370
  }
371
371
  }
372
372
 
373
- // --- Mascot icon update (swap light/dark src) ---
373
+ // --- Favicon update on theme change ---
374
374
  function updateMascotSvgs(vars, isLight) {
375
- var lightSrc = "favicon.svg";
376
- var darkSrc = "favicon-dark.svg";
377
- var src = isLight ? lightSrc : darkSrc;
378
- var mascots = document.querySelectorAll("img.footer-mascot");
379
- for (var i = 0; i < mascots.length; i++) {
380
- mascots[i].setAttribute("src", src);
381
- }
382
- var faviconEl = document.querySelector('link[rel="icon"][type="image/svg+xml"]');
383
- if (faviconEl) faviconEl.setAttribute("href", src);
375
+ var faviconEl = document.querySelector('link[rel="icon"][type="image/png"]');
376
+ if (faviconEl) faviconEl.setAttribute("href", "favicon-banded.png");
384
377
  }
385
378
 
386
379
  // --- Theme loading from server ---
@@ -1,6 +1,6 @@
1
1
  import { escapeHtml, copyToClipboard } from './utils.js';
2
2
  import { iconHtml, refreshIcons, randomThinkingVerb } from './icons.js';
3
- import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks } from './markdown.js';
3
+ import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks, parseEmojis } from './markdown.js';
4
4
  import { renderUnifiedDiff, renderSplitDiff, renderPatchDiff } from './diff.js';
5
5
  import { openFile } from './filebrowser.js';
6
6
 
@@ -744,6 +744,7 @@ export function renderPlanCard(content) {
744
744
  body.innerHTML = renderMarkdown(content);
745
745
  highlightCodeBlocks(body);
746
746
  renderMermaidBlocks(body);
747
+ parseEmojis(body);
747
748
 
748
749
  var copyBtn = header.querySelector(".plan-card-copy");
749
750
  if (copyBtn) {
@@ -17,3 +17,7 @@
17
17
  @import url("css/loop.css");
18
18
  @import url("css/scheduler.css");
19
19
  @import url("css/scheduler-modal.css");
20
+ @import url("css/home-hub.css");
21
+ @import url("css/playbook.css");
22
+ @import url("css/stt.css");
23
+ @import url("css/profile.css");
package/lib/public/sw.js CHANGED
@@ -1,11 +1,88 @@
1
- self.addEventListener("install", function () {
2
- self.skipWaiting();
1
+ var CACHE_NAME = "clay-offline-v1";
2
+
3
+ self.addEventListener("install", function (event) {
4
+ event.waitUntil(self.skipWaiting());
3
5
  });
4
6
 
5
7
  self.addEventListener("activate", function (event) {
6
- event.waitUntil(self.clients.claim());
8
+ // Clean up old cache versions
9
+ event.waitUntil(
10
+ caches.keys().then(function (names) {
11
+ return Promise.all(
12
+ names.filter(function (n) { return n !== CACHE_NAME; })
13
+ .map(function (n) { return caches.delete(n); })
14
+ );
15
+ }).then(function () {
16
+ return self.clients.claim();
17
+ })
18
+ );
19
+ });
20
+
21
+ // --- Offline cache: network-first, cache-fallback ---
22
+
23
+ function shouldCache(request, response) {
24
+ if (request.method !== "GET") return false;
25
+ if (!response || !response.ok) return false;
26
+ // Cache same-origin static assets and CDN resources (jsdelivr, fonts)
27
+ var url = new URL(request.url);
28
+ if (url.origin === self.location.origin) return true;
29
+ if (url.hostname === "cdn.jsdelivr.net") return true;
30
+ if (url.hostname === "fonts.googleapis.com") return true;
31
+ if (url.hostname === "fonts.gstatic.com") return true;
32
+ return false;
33
+ }
34
+
35
+ self.addEventListener("fetch", function (event) {
36
+ var request = event.request;
37
+
38
+ // Only handle GET requests
39
+ if (request.method !== "GET") return;
40
+
41
+ // Skip WebSocket upgrade requests and API/data endpoints
42
+ var url = new URL(request.url);
43
+ if (url.pathname.indexOf("/ws") !== -1) return;
44
+ if (url.pathname.indexOf("/api/") !== -1) return;
45
+
46
+ event.respondWith(
47
+ fetch(request).then(function (response) {
48
+ // Network succeeded: cache a clone for offline use
49
+ if (shouldCache(request, response)) {
50
+ var clone = response.clone();
51
+ caches.open(CACHE_NAME).then(function (cache) {
52
+ cache.put(request, clone);
53
+ });
54
+ }
55
+ return response;
56
+ }).catch(function () {
57
+ // Network failed: serve from cache
58
+ return caches.match(request).then(function (cached) {
59
+ if (cached) return cached;
60
+
61
+ // For navigation requests, serve cached index.html as fallback
62
+ // (handles /p/slug/ routes that all serve the same SPA shell)
63
+ if (request.mode === "navigate") {
64
+ return caches.match("/index.html").then(function (indexCached) {
65
+ // If even the index page is not cached, show a minimal offline page
66
+ if (indexCached) return indexCached;
67
+ return new Response(
68
+ "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Clay</title>" +
69
+ "<style>body{margin:0;background:#000;color:#888;display:flex;" +
70
+ "align-items:center;justify-content:center;height:100vh;" +
71
+ "font-family:monospace;font-size:1.2em}</style></head>" +
72
+ "<body><p>Waiting for server&hellip;</p></body></html>",
73
+ { headers: { "Content-Type": "text/html" } }
74
+ );
75
+ });
76
+ }
77
+ // Non-navigation request with no cache: return network error
78
+ return new Response("", { status: 503, statusText: "Offline" });
79
+ });
80
+ })
81
+ );
7
82
  });
8
83
 
84
+ // --- Push notifications ---
85
+
9
86
  self.addEventListener("push", function (event) {
10
87
  var data = {};
11
88
  try { data = event.data.json(); } catch (e) { return; }
@@ -46,6 +123,8 @@ self.addEventListener("push", function (event) {
46
123
  );
47
124
  });
48
125
 
126
+ // --- Notification click ---
127
+
49
128
  self.addEventListener("notificationclick", function (event) {
50
129
  var data = event.notification.data || {};
51
130
  event.notification.close();
package/lib/scheduler.js CHANGED
@@ -143,6 +143,12 @@ function createLoopRegistry(opts) {
143
143
  // Recalculate nextRunAt for scheduled records
144
144
  if (rec.cron && rec.enabled) {
145
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();
146
152
  }
147
153
  records.push(rec);
148
154
  } catch (e) {
@@ -192,7 +198,6 @@ function createLoopRegistry(opts) {
192
198
 
193
199
  for (var i = 0; i < records.length; i++) {
194
200
  var rec = records[i];
195
- if (!rec.cron) continue; // skip one-off
196
201
  if (!rec.enabled) continue;
197
202
  if (!rec.nextRunAt) continue;
198
203
  if (rec.nextRunAt > now) continue;
@@ -214,8 +219,15 @@ function createLoopRegistry(opts) {
214
219
 
215
220
  // Update nextRunAt
216
221
  rec.lastRunAt = now;
217
- rec.nextRunAt = nextRunTime(rec.cron, 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
+ }
218
229
  save();
230
+ if (onChange) onChange(records);
219
231
 
220
232
  console.log("[loop-registry] Triggering scheduled loop: " + rec.name + " (" + rec.id + ")");
221
233
  if (onTrigger) {
@@ -241,11 +253,26 @@ function createLoopRegistry(opts) {
241
253
  lastRunAt: null,
242
254
  lastRunResult: null,
243
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,
244
261
  craftingSessionId: data.craftingSessionId || null,
262
+ source: data.source || null,
263
+ color: data.color || null,
264
+ recurrenceEnd: data.recurrenceEnd || null,
245
265
  runs: [],
246
266
  };
247
267
  if (rec.cron && rec.enabled) {
248
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;
249
276
  }
250
277
  records.push(rec);
251
278
  save();
@@ -261,8 +288,20 @@ function createLoopRegistry(opts) {
261
288
  if (data.cron !== undefined) rec.cron = data.cron;
262
289
  if (data.enabled !== undefined) rec.enabled = data.enabled;
263
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;
264
293
  rec.updatedAt = Date.now();
265
- rec.nextRunAt = (rec.cron && rec.enabled) ? nextRunTime(rec.cron) : null;
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
+ }
266
305
 
267
306
  save();
268
307
  if (onChange) onChange(records);
@@ -277,6 +316,7 @@ function createLoopRegistry(opts) {
277
316
  rec[keys[i]] = data[keys[i]];
278
317
  }
279
318
  save();
319
+ if (onChange) onChange(records);
280
320
  return rec;
281
321
  }
282
322
 
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
  }