clay-server 2.17.0 → 2.18.0-beta.10

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.
@@ -751,6 +751,10 @@ function renderDetailBody(tab, rec) {
751
751
  html += '<span class="scheduler-detail-meta-value">' + esc(createdStr) + '</span>';
752
752
  html += '<span class="scheduler-detail-meta-label">Last Run</span>';
753
753
  html += '<span class="scheduler-detail-meta-value">' + esc(lastRunStr) + '</span>';
754
+ if (isScheduled && rec.nextRunAt) {
755
+ html += '<span class="scheduler-detail-meta-label">Next Run</span>';
756
+ html += '<span class="scheduler-detail-meta-value">' + esc(formatDateTime(new Date(rec.nextRunAt))) + '</span>';
757
+ }
754
758
  html += '</div>';
755
759
  bodyEl2.innerHTML = html;
756
760
  } else {
@@ -2681,6 +2685,18 @@ function closeDeleteDialog() {
2681
2685
  }
2682
2686
  }
2683
2687
 
2688
+ // Build an explicit list of values offset from a start value with a given step, wrapping at max
2689
+ function buildOffsetList(start, step, max) {
2690
+ var vals = [];
2691
+ var v = start % max;
2692
+ for (var i = 0; i < max; i += step) {
2693
+ vals.push(v);
2694
+ v = (v + step) % max;
2695
+ }
2696
+ vals.sort(function (a, b) { return a - b; });
2697
+ return vals.join(",");
2698
+ }
2699
+
2684
2700
  function buildCreateCron() {
2685
2701
  if (!createSelectedDate) return null;
2686
2702
 
@@ -2707,9 +2723,9 @@ function buildCreateCron() {
2707
2723
 
2708
2724
  // Interval only (no recurrence) = interval every day
2709
2725
  if (intervalMins > 0 && createRecurrence === "none") {
2710
- if (intervalMins < 60) return "*/" + intervalMins + " * * * *";
2726
+ if (intervalMins < 60) return buildOffsetList(m, intervalMins, 60) + " * * * *";
2711
2727
  var intHrs = Math.floor(intervalMins / 60);
2712
- return "0 */" + intHrs + " * * *";
2728
+ return String(m) + " " + buildOffsetList(h, intHrs, 24) + " * * *";
2713
2729
  }
2714
2730
 
2715
2731
  if (createRecurrence === "none" && intervalMins === 0) return null;
@@ -2718,12 +2734,12 @@ function buildCreateCron() {
2718
2734
  var minField = String(m);
2719
2735
  var hourField = String(h);
2720
2736
  if (intervalMins > 0 && intervalMins < 60) {
2721
- minField = "*/" + intervalMins;
2737
+ minField = buildOffsetList(m, intervalMins, 60);
2722
2738
  hourField = "*";
2723
2739
  } else if (intervalMins >= 60) {
2724
2740
  var intHrs2 = Math.floor(intervalMins / 60);
2725
2741
  minField = String(m);
2726
- hourField = "*/" + intHrs2;
2742
+ hourField = buildOffsetList(h, intHrs2, 24);
2727
2743
  }
2728
2744
 
2729
2745
  if (createRecurrence === "daily") return minField + " " + hourField + " * * *";
@@ -2837,10 +2853,10 @@ function buildCustomCron(h, m) {
2837
2853
  var unit = document.getElementById("sched-custom-unit").value;
2838
2854
 
2839
2855
  if (unit === "minute") {
2840
- return interval === 1 ? "*/1 * * * *" : "*/" + interval + " * * * *";
2856
+ return interval === 1 ? "*/1 * * * *" : buildOffsetList(m, interval, 60) + " * * * *";
2841
2857
  }
2842
2858
  if (unit === "hour") {
2843
- return interval === 1 ? "0 */1 * * *" : "0 */" + interval + " * * *";
2859
+ return interval === 1 ? m + " */1 * * *" : m + " " + buildOffsetList(h, interval, 24) + " * * *";
2844
2860
  }
2845
2861
  if (unit === "day") {
2846
2862
  if (interval === 1) return m + " " + h + " * * *";
@@ -3022,15 +3038,15 @@ function cronToHuman(cron) {
3022
3038
  if (!cron) return "";
3023
3039
  var parts = cron.trim().split(/\s+/);
3024
3040
  if (parts.length !== 5) return cron;
3025
- // Minute interval patterns (e.g. */5 * * * *)
3026
- if (parts[0].indexOf("/") !== -1 && parts[1] === "*" && parts[2] === "*") {
3027
- var minStep = parseInt(parts[0].split("/")[1], 10);
3028
- return minStep === 1 ? "Every minute" : "Every " + minStep + " minutes";
3041
+ // Minute interval patterns (e.g. */5 * * * * or 0,15,30,45 * * * *)
3042
+ if (parts[1] === "*" && parts[2] === "*") {
3043
+ var minStep = detectInterval(parts[0], 60);
3044
+ if (minStep) return minStep === 1 ? "Every minute" : "Every " + minStep + " minutes";
3029
3045
  }
3030
- // Hour interval patterns (e.g. 0 */2 * * *)
3031
- if (parts[1].indexOf("/") !== -1 && parts[2] === "*") {
3032
- var hrStep = parseInt(parts[1].split("/")[1], 10);
3033
- return hrStep === 1 ? "Every hour" : "Every " + hrStep + " hours";
3046
+ // Hour interval patterns (e.g. 0 */2 * * * or 0 1,5,9,13,17,21 * * *)
3047
+ if (parts[2] === "*") {
3048
+ var hrStep = detectInterval(parts[1], 24);
3049
+ if (hrStep) return hrStep === 1 ? "Every hour" : "Every " + hrStep + " hours";
3034
3050
  }
3035
3051
  var t = pad(parseInt(parts[1], 10)) + ":" + pad(parseInt(parts[0], 10));
3036
3052
  var dow = parts[4], dom = parts[2];
@@ -3043,3 +3059,20 @@ function cronToHuman(cron) {
3043
3059
  }
3044
3060
  return cron;
3045
3061
  }
3062
+
3063
+ // Detect if a cron field represents an evenly-spaced interval (*/N or comma-separated offset list)
3064
+ function detectInterval(field, max) {
3065
+ if (field.indexOf("/") !== -1) return parseInt(field.split("/")[1], 10) || null;
3066
+ if (field.indexOf(",") === -1) return null;
3067
+ var vals = field.split(",").map(function (v) { return parseInt(v, 10); }).sort(function (a, b) { return a - b; });
3068
+ if (vals.length < 2) return null;
3069
+ var step = vals[1] - vals[0];
3070
+ if (step <= 0) return null;
3071
+ // Verify all values are evenly spaced (wrapping around max)
3072
+ for (var i = 1; i < vals.length; i++) {
3073
+ if (vals[i] - vals[i - 1] !== step) return null;
3074
+ }
3075
+ // Check the wrap-around gap matches too
3076
+ if ((max - vals[vals.length - 1] + vals[0]) !== step) return null;
3077
+ return step;
3078
+ }
@@ -26,3 +26,4 @@
26
26
  @import url("css/tooltip.css");
27
27
  @import url("css/mates.css");
28
28
  @import url("css/command-palette.css");
29
+ @import url("css/mention.css");
package/lib/sdk-bridge.js CHANGED
@@ -56,6 +56,7 @@ function createSDKBridge(opts) {
56
56
  var pushModule = opts.pushModule;
57
57
  var getSDK = opts.getSDK;
58
58
  var mateDisplayName = opts.mateDisplayName || "";
59
+ var isMate = opts.isMate || (slug.indexOf("mate-") === 0);
59
60
  var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
60
61
  var onProcessingChanged = opts.onProcessingChanged || function () {};
61
62
 
@@ -1115,6 +1116,14 @@ function createSDKBridge(opts) {
1115
1116
  return Promise.resolve({ behavior: "allow", updatedInput: input });
1116
1117
  }
1117
1118
 
1119
+ // Mate sessions (DM, debate, mention): auto-approve read + fetch tools
1120
+ if (isMate || mateDisplayName) {
1121
+ var mateAutoTools = { Read: true, Glob: true, Grep: true, WebFetch: true, WebSearch: true };
1122
+ if (mateAutoTools[toolName]) {
1123
+ return Promise.resolve({ behavior: "allow", updatedInput: input });
1124
+ }
1125
+ }
1126
+
1118
1127
  // AskUserQuestion: wait for user answers via WebSocket
1119
1128
  if (toolName === "AskUserQuestion") {
1120
1129
  return new Promise(function(resolve) {
@@ -1729,6 +1738,187 @@ function createSDKBridge(opts) {
1729
1738
  }
1730
1739
  }
1731
1740
 
1741
+ // --- @Mention: persistent read-only session for a mentioned Mate ---
1742
+ // Creates a mention session that can be reused across multiple mentions
1743
+ // within a conversation flow (session continuity).
1744
+ async function createMentionSession(opts) {
1745
+ // opts: { claudeMd, initialContext, initialMessage, onDelta, onDone, onError, onActivity }
1746
+ var sdk;
1747
+ try {
1748
+ sdk = await getSDK();
1749
+ } catch (e) {
1750
+ opts.onError("Failed to load Claude SDK: " + (e.message || e));
1751
+ return null;
1752
+ }
1753
+
1754
+ var mq = createMessageQueue();
1755
+ var abortController = new AbortController();
1756
+
1757
+ // Current response callbacks (swapped on each pushMessage)
1758
+ var currentOnDelta = opts.onDelta;
1759
+ var currentOnDone = opts.onDone;
1760
+ var currentOnError = opts.onError;
1761
+ var currentOnActivity = opts.onActivity || null;
1762
+ var responseFullText = "";
1763
+ var responseStreamedText = false;
1764
+ var mentionBlocks = {};
1765
+ var alive = true;
1766
+
1767
+ var query;
1768
+ try {
1769
+ query = sdk.query({
1770
+ prompt: mq,
1771
+ options: {
1772
+ cwd: cwd,
1773
+ systemPrompt: opts.claudeMd,
1774
+ settingSources: ["user"],
1775
+ includePartialMessages: true,
1776
+ abortController: abortController,
1777
+ canUseTool: opts.canUseTool || function (toolName, input) {
1778
+ var allowed = { Read: true, Glob: true, Grep: true, WebFetch: true, WebSearch: true };
1779
+ if (allowed[toolName]) {
1780
+ return Promise.resolve({ behavior: "allow", updatedInput: input });
1781
+ }
1782
+ return Promise.resolve({
1783
+ behavior: "deny",
1784
+ message: "Read-only access. You cannot make changes via @mention.",
1785
+ });
1786
+ },
1787
+ },
1788
+ });
1789
+ } catch (e) {
1790
+ opts.onError("Failed to create mention query: " + (e.message || e));
1791
+ return null;
1792
+ }
1793
+
1794
+ // Push the initial message (context + question)
1795
+ var initialPrompt = opts.initialContext + "\n\n" + opts.initialMessage;
1796
+ mq.push({
1797
+ type: "user",
1798
+ message: { role: "user", content: [{ type: "text", text: initialPrompt }] },
1799
+ });
1800
+
1801
+ // Background stream processing loop
1802
+ (async function () {
1803
+ try {
1804
+ for await (var sdkMsg of query) {
1805
+ if (sdkMsg.type === "stream_event" && sdkMsg.event) {
1806
+ var evt = sdkMsg.event;
1807
+
1808
+ // Track content blocks for activity reporting
1809
+ if (evt.type === "content_block_start") {
1810
+ var block = evt.content_block;
1811
+ var idx = evt.index;
1812
+ if (block.type === "thinking") {
1813
+ mentionBlocks[idx] = { type: "thinking" };
1814
+ if (currentOnActivity) currentOnActivity("thinking");
1815
+ } else if (block.type === "tool_use") {
1816
+ mentionBlocks[idx] = { type: "tool_use", name: block.name, inputJson: "" };
1817
+ var toolLabel = block.name;
1818
+ if (toolLabel === "Read") toolLabel = "Reading file...";
1819
+ else if (toolLabel === "Grep") toolLabel = "Searching code...";
1820
+ else if (toolLabel === "Glob") toolLabel = "Finding files...";
1821
+ if (currentOnActivity) currentOnActivity(toolLabel);
1822
+ } else if (block.type === "text") {
1823
+ mentionBlocks[idx] = { type: "text" };
1824
+ }
1825
+ }
1826
+
1827
+ if (evt.type === "content_block_delta" && evt.delta) {
1828
+ if (evt.delta.type === "text_delta" && typeof evt.delta.text === "string") {
1829
+ responseStreamedText = true;
1830
+ responseFullText += evt.delta.text;
1831
+ if (currentOnActivity) currentOnActivity(null); // clear activity on text
1832
+ if (currentOnDelta) currentOnDelta(evt.delta.text);
1833
+ } else if (evt.delta.type === "input_json_delta" && mentionBlocks[evt.index]) {
1834
+ mentionBlocks[evt.index].inputJson += evt.delta.partial_json;
1835
+ }
1836
+ }
1837
+
1838
+ if (evt.type === "content_block_stop") {
1839
+ var blk = mentionBlocks[evt.index];
1840
+ if (blk && blk.type === "tool_use") {
1841
+ // Show what file is being read
1842
+ var toolInput = {};
1843
+ try { toolInput = JSON.parse(blk.inputJson); } catch (e) {}
1844
+ if (blk.name === "Read" && toolInput.file_path) {
1845
+ var fname = toolInput.file_path.split(/[/\\]/).pop();
1846
+ if (currentOnActivity) currentOnActivity("Reading " + fname + "...");
1847
+ } else if (blk.name === "Grep" && toolInput.pattern) {
1848
+ if (currentOnActivity) currentOnActivity("Searching: " + toolInput.pattern.substring(0, 30) + "...");
1849
+ } else if (blk.name === "Glob" && toolInput.pattern) {
1850
+ if (currentOnActivity) currentOnActivity("Finding: " + toolInput.pattern.substring(0, 30) + "...");
1851
+ }
1852
+ }
1853
+ delete mentionBlocks[evt.index];
1854
+ }
1855
+
1856
+ } else if (sdkMsg.type === "assistant" && !responseStreamedText && sdkMsg.message && sdkMsg.message.content) {
1857
+ // Fallback: if text was not streamed via deltas, extract from assistant message
1858
+ var content = sdkMsg.message.content;
1859
+ if (Array.isArray(content)) {
1860
+ for (var ci = 0; ci < content.length; ci++) {
1861
+ if (content[ci].type === "text" && content[ci].text) {
1862
+ responseFullText += content[ci].text;
1863
+ if (currentOnDelta) currentOnDelta(content[ci].text);
1864
+ }
1865
+ }
1866
+ }
1867
+ } else if (sdkMsg.type === "result") {
1868
+ // One response complete. Signal done and reset for next message.
1869
+ if (currentOnActivity) currentOnActivity(null);
1870
+ var doneRef = currentOnDone;
1871
+ if (doneRef) {
1872
+ doneRef(responseFullText);
1873
+ }
1874
+ // Only reset if pushMessage was not called during onDone
1875
+ // (pushMessage swaps callbacks and resets state itself)
1876
+ if (currentOnDone === doneRef) {
1877
+ currentOnDelta = null;
1878
+ currentOnDone = null;
1879
+ currentOnError = null;
1880
+ currentOnActivity = null;
1881
+ mentionBlocks = {};
1882
+ responseFullText = "";
1883
+ responseStreamedText = false;
1884
+ }
1885
+ }
1886
+ }
1887
+ } catch (err) {
1888
+ if (currentOnError) {
1889
+ if (err.name === "AbortError" || (abortController && abortController.signal.aborted)) {
1890
+ currentOnError("Mention query was cancelled.");
1891
+ } else {
1892
+ currentOnError(err.message || String(err));
1893
+ }
1894
+ }
1895
+ }
1896
+ alive = false;
1897
+ })();
1898
+
1899
+ return {
1900
+ // Push a follow-up message to the existing mention session
1901
+ pushMessage: function (text, callbacks) {
1902
+ currentOnDelta = callbacks.onDelta;
1903
+ currentOnDone = callbacks.onDone;
1904
+ currentOnError = callbacks.onError;
1905
+ currentOnActivity = callbacks.onActivity || null;
1906
+ mentionBlocks = {};
1907
+ responseFullText = "";
1908
+ responseStreamedText = false;
1909
+ mq.push({
1910
+ type: "user",
1911
+ message: { role: "user", content: [{ type: "text", text: text }] },
1912
+ });
1913
+ },
1914
+ close: function () {
1915
+ alive = false;
1916
+ try { mq.end(); } catch (e) {}
1917
+ },
1918
+ isAlive: function () { return alive; },
1919
+ };
1920
+ }
1921
+
1732
1922
  return {
1733
1923
  createMessageQueue: createMessageQueue,
1734
1924
  processSDKMessage: processSDKMessage,
@@ -1746,6 +1936,7 @@ function createSDKBridge(opts) {
1746
1936
  permissionPushBody: permissionPushBody,
1747
1937
  warmup: warmup,
1748
1938
  stopTask: stopTask,
1939
+ createMentionSession: createMentionSession,
1749
1940
  };
1750
1941
  }
1751
1942
 
package/lib/sessions.js CHANGED
@@ -395,6 +395,16 @@ function createSessionManager(opts) {
395
395
  }
396
396
  }
397
397
 
398
+ function cleanupMentionSessions(session) {
399
+ if (session._mentionSessions) {
400
+ var mateIds = Object.keys(session._mentionSessions);
401
+ for (var mi = 0; mi < mateIds.length; mi++) {
402
+ try { session._mentionSessions[mateIds[mi]].close(); } catch (e) {}
403
+ }
404
+ session._mentionSessions = {};
405
+ }
406
+ }
407
+
398
408
  function deleteSession(localId, targetWs) {
399
409
  var session = sessions.get(localId);
400
410
  if (!session) return;
@@ -402,6 +412,8 @@ function createSessionManager(opts) {
402
412
  // Clean up unread tracking
403
413
  delete singleUserUnread[localId];
404
414
 
415
+ cleanupMentionSessions(session);
416
+
405
417
  if (session.abortController) {
406
418
  try { session.abortController.abort(); } catch(e) {}
407
419
  }
@@ -431,6 +443,7 @@ function createSessionManager(opts) {
431
443
  var session = sessions.get(localId);
432
444
  if (!session) return;
433
445
  delete singleUserUnread[localId];
446
+ cleanupMentionSessions(session);
434
447
  if (session.abortController) {
435
448
  try { session.abortController.abort(); } catch(e) {}
436
449
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.17.0",
3
+ "version": "2.18.0-beta.10",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",