clay-server 2.5.1 → 2.6.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.
@@ -553,6 +553,13 @@ export function resetTerminals() {
553
553
  renderTabBar();
554
554
  }
555
555
 
556
+ export function sendTerminalCommand(command) {
557
+ if (!activeTabId || !tabs.has(activeTabId)) return;
558
+ if (ctx.ws && ctx.connected) {
559
+ ctx.ws.send(JSON.stringify({ type: "term_input", id: activeTabId, data: command }));
560
+ }
561
+ }
562
+
556
563
  export function setTerminalTheme(xtermTheme) {
557
564
  for (var tab of tabs.values()) {
558
565
  if (tab.xterm) {
@@ -12,4 +12,5 @@
12
12
  @import url("css/highlight.css");
13
13
  @import url("css/server-settings.css");
14
14
  @import url("css/sticky-notes.css");
15
+ @import url("css/skills.css");
15
16
  @import url("css/mobile-nav.css");
package/lib/sdk-bridge.js CHANGED
@@ -53,6 +53,7 @@ function createSDKBridge(opts) {
53
53
  var pushModule = opts.pushModule;
54
54
  var getSDK = opts.getSDK;
55
55
  var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
56
+ var onProcessingChanged = opts.onProcessingChanged || function () {};
56
57
 
57
58
  // --- Skill discovery helpers ---
58
59
 
@@ -147,8 +148,8 @@ function createSDKBridge(opts) {
147
148
  send({ type: "slash_commands", commands: sm.slashCommands });
148
149
  }
149
150
  if (parsed.model) {
150
- sm.currentModel = parsed.model;
151
- send({ type: "model_info", model: parsed.model, models: sm.availableModels || [] });
151
+ sm.currentModel = sm._savedDefaultModel || parsed.model;
152
+ send({ type: "model_info", model: sm.currentModel, models: sm.availableModels || [] });
152
153
  }
153
154
  if (parsed.fast_mode_state) {
154
155
  sendAndRecord(session, { type: "fast_mode_state", state: parsed.fast_mode_state });
@@ -307,6 +308,7 @@ function createSDKBridge(opts) {
307
308
  session.activeTaskToolIds = {};
308
309
  session.taskIdMap = {};
309
310
  session.isProcessing = false;
311
+ onProcessingChanged();
310
312
  sendAndRecord(session, {
311
313
  type: "result",
312
314
  cost: parsed.total_cost_usd,
@@ -609,6 +611,7 @@ function createSDKBridge(opts) {
609
611
  } catch (err) {
610
612
  if (session.isProcessing) {
611
613
  session.isProcessing = false;
614
+ onProcessingChanged();
612
615
  if (err.name === "AbortError" || (session.abortController && session.abortController.signal.aborted)) {
613
616
  sendAndRecord(session, { type: "info", text: "Interrupted \u00b7 What should Claude do instead?" });
614
617
  sendAndRecord(session, { type: "done", code: 0 });
@@ -705,6 +708,7 @@ function createSDKBridge(opts) {
705
708
  sdk = await getSDK();
706
709
  } catch (e) {
707
710
  session.isProcessing = false;
711
+ onProcessingChanged();
708
712
  send({ type: "error", text: "Failed to load Claude SDK: " + (e.message || e) });
709
713
  sendAndRecord(session, { type: "done", code: 1 });
710
714
  sm.broadcastSessionList();
@@ -794,6 +798,7 @@ function createSDKBridge(opts) {
794
798
  console.error("[sdk-bridge] cliSessionId:", session.cliSessionId, "resume:", !!queryOptions.resume);
795
799
  console.error("[sdk-bridge] Stack:", e.stack || "(no stack)");
796
800
  session.isProcessing = false;
801
+ onProcessingChanged();
797
802
  session.queryInstance = null;
798
803
  session.messageQueue = null;
799
804
  session.abortController = null;
@@ -803,7 +808,7 @@ function createSDKBridge(opts) {
803
808
  return;
804
809
  }
805
810
 
806
- processQueryStream(session).catch(function(err) {
811
+ session.streamPromise = processQueryStream(session).catch(function(err) {
807
812
  });
808
813
  }
809
814
 
@@ -929,30 +934,36 @@ function createSDKBridge(opts) {
929
934
  // No active query — just store the model for next startQuery
930
935
  sm.currentModel = model;
931
936
  send({ type: "model_info", model: model, models: sm.availableModels || [] });
932
- send({ type: "config_state", model: sm.currentModel, mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "high", betas: sm.currentBetas || [] });
937
+ send({ type: "config_state", model: sm.currentModel, mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
933
938
  return;
934
939
  }
935
940
  try {
936
941
  await session.queryInstance.setModel(model);
937
942
  sm.currentModel = model;
938
943
  send({ type: "model_info", model: model, models: sm.availableModels || [] });
939
- send({ type: "config_state", model: sm.currentModel, mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "high", betas: sm.currentBetas || [] });
944
+ send({ type: "config_state", model: sm.currentModel, mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
940
945
  } catch (e) {
941
946
  send({ type: "error", text: "Failed to switch model: " + (e.message || e) });
942
947
  }
943
948
  }
944
949
 
945
950
  async function setPermissionMode(session, mode) {
951
+ // When dangerouslySkipPermissions is active, ignore mode changes from UI
952
+ // to prevent accidentally downgrading from bypassPermissions
953
+ if (dangerouslySkipPermissions) {
954
+ send({ type: "config_state", model: sm.currentModel || "", mode: "bypassPermissions", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
955
+ return;
956
+ }
946
957
  if (!session.queryInstance) {
947
958
  // No active query — just store the mode for next startQuery
948
959
  sm.currentPermissionMode = mode;
949
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "high", betas: sm.currentBetas || [] });
960
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
950
961
  return;
951
962
  }
952
963
  try {
953
964
  await session.queryInstance.setPermissionMode(mode);
954
965
  sm.currentPermissionMode = mode;
955
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "high", betas: sm.currentBetas || [] });
966
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
956
967
  } catch (e) {
957
968
  send({ type: "error", text: "Failed to set permission mode: " + (e.message || e) });
958
969
  }
package/lib/server.js CHANGED
@@ -8,10 +8,160 @@ var { createProjectContext } = require("./project");
8
8
 
9
9
  var { CONFIG_DIR } = require("./config");
10
10
 
11
+ var https = require("https");
12
+
11
13
  var publicDir = path.join(__dirname, "public");
12
14
  var bundledThemesDir = path.join(__dirname, "themes");
13
15
  var userThemesDir = path.join(CONFIG_DIR, "themes");
14
16
 
17
+ // --- Skills proxy cache & helpers ---
18
+ var skillsCache = {};
19
+
20
+ function httpGet(url) {
21
+ return new Promise(function (resolve, reject) {
22
+ var mod = url.startsWith("https") ? https : http;
23
+ mod.get(url, { headers: { "User-Agent": "Clay/1.0" } }, function (resp) {
24
+ if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) {
25
+ return httpGet(resp.headers.location).then(resolve, reject);
26
+ }
27
+ var chunks = [];
28
+ resp.on("data", function (c) { chunks.push(c); });
29
+ resp.on("end", function () { resolve(Buffer.concat(chunks).toString("utf8")); });
30
+ resp.on("error", reject);
31
+ }).on("error", reject);
32
+ });
33
+ }
34
+
35
+ function fetchSkillsPage(url) {
36
+ return httpGet(url).then(function (html) {
37
+ // Data is inside self.__next_f.push() with escaped quotes: \"initialSkills\":[{\"source\":...}]
38
+ var marker = 'initialSkills';
39
+ var idx = html.indexOf(marker);
40
+ if (idx < 0) return { skills: [] };
41
+
42
+ // Find the start of the array: look for \\\":[
43
+ var arrStart = html.indexOf(':[', idx);
44
+ if (arrStart < 0) return { skills: [] };
45
+ arrStart += 1; // point to '['
46
+
47
+ // Find matching ']' — track bracket depth
48
+ var depth = 0;
49
+ var arrEnd = -1;
50
+ for (var i = arrStart; i < html.length; i++) {
51
+ var ch = html[i];
52
+ if (ch === '[') depth++;
53
+ else if (ch === ']') {
54
+ depth--;
55
+ if (depth === 0) { arrEnd = i + 1; break; }
56
+ }
57
+ }
58
+ if (arrEnd < 0) return { skills: [] };
59
+
60
+ var raw = html.substring(arrStart, arrEnd);
61
+ // Unescape: \\\" → " and \\\\ → backslash
62
+ var unescaped = raw.replace(/\\\\"/g, '__BSLASH_QUOTE__').replace(/\\"/g, '"').replace(/__BSLASH_QUOTE__/g, '\\"');
63
+
64
+ try {
65
+ return { skills: JSON.parse(unescaped) };
66
+ } catch (e) {
67
+ return { skills: [] };
68
+ }
69
+ });
70
+ }
71
+
72
+ function fetchSkillDetail(url) {
73
+ return httpGet(url).then(function (html) {
74
+ var result = {};
75
+
76
+ // Title: "skill-name by owner/repo"
77
+ var titleMatch = html.match(/<title>([^<]+)<\/title>/);
78
+ if (titleMatch) {
79
+ var parts = titleMatch[1].split(" by ");
80
+ result.name = parts[0].trim();
81
+ }
82
+
83
+ // Description from meta
84
+ var descMatch = html.match(/meta name="description" content="([^"]+)"/);
85
+ if (descMatch) result.description = descMatch[1];
86
+
87
+ // Install command
88
+ var cmdMatch = html.match(/npx skills add [^ ]+ --skill [^ "<]+/);
89
+ if (cmdMatch) result.command = cmdMatch[0];
90
+
91
+ // Weekly installs: "Weekly Installs</span></div><div ...>VALUE</div>"
92
+ var wiMatch = html.match(/Weekly Installs<\/span><\/div><div[^>]*>([\d,.]+K?)<\/div>/);
93
+ if (wiMatch) result.weeklyInstalls = wiMatch[1];
94
+
95
+ // GitHub Stars: after SVG icon, inside <span>X.XK</span>
96
+ var gsIdx = html.indexOf("GitHub Stars");
97
+ if (gsIdx > 0) {
98
+ var gsRegion = html.substring(gsIdx, gsIdx + 1000);
99
+ var gsVal = gsRegion.match(/<span>(\d[\d,.]*K?)<\/span>/);
100
+ if (gsVal) result.githubStars = gsVal[1];
101
+ }
102
+
103
+ // First Seen
104
+ var fsMatch = html.match(/First Seen<\/span><\/div><div[^>]*>([^<]+)<\/div>/);
105
+ if (fsMatch) result.firstSeen = fsMatch[1].trim();
106
+
107
+ // Repository: from title "by owner/repo"
108
+ if (titleMatch) {
109
+ var byParts = titleMatch[1].split(" by ");
110
+ if (byParts[1]) result.repository = byParts[1].trim();
111
+ }
112
+
113
+ // Security audits: "text-foreground truncate">NAME</span><span ...>STATUS</span>"
114
+ var audits = [];
115
+ var auditRegex = /class="text-sm font-medium text-foreground truncate">([^<]+)<\/span><span class="[^"]*">(\w+)<\/span>/g;
116
+ var am;
117
+ while ((am = auditRegex.exec(html)) !== null) {
118
+ audits.push({ name: am[1], status: am[2].toLowerCase() });
119
+ }
120
+ if (audits.length) result.audits = audits;
121
+
122
+ // Installed on: "text-foreground">NAME</span><span class="text-muted-foreground font-mono">COUNT</span>
123
+ var ioIdx = html.indexOf("Installed On");
124
+ if (ioIdx > 0) {
125
+ var ioRegion = html.substring(ioIdx, ioIdx + 3000);
126
+ var platforms = [];
127
+ var platRegex = /text-foreground">([^<]+)<\/span><span class="text-muted-foreground font-mono">([\d,.]+K?)<\/span>/g;
128
+ var pm;
129
+ while ((pm = platRegex.exec(ioRegion)) !== null) {
130
+ platforms.push({ name: pm[1], installs: pm[2] });
131
+ }
132
+ if (platforms.length) result.installedOn = platforms;
133
+ }
134
+
135
+ // SKILL.md content: rendered HTML inside the main content area
136
+ var skillMdIdx = html.indexOf("SKILL.md");
137
+ if (skillMdIdx > 0) {
138
+ // Find the prose content div after SKILL.md marker
139
+ var proseIdx = html.indexOf("prose", skillMdIdx);
140
+ if (proseIdx > 0) {
141
+ var proseStart = html.indexOf(">", proseIdx) + 1;
142
+ // Find the closing of the prose div (heuristic: next major section boundary)
143
+ var endMarkers = ["<div class=\"bg-background", "<div class=\"sticky"];
144
+ var proseEnd = html.length;
145
+ for (var em = 0; em < endMarkers.length; em++) {
146
+ var endIdx = html.indexOf(endMarkers[em], proseStart);
147
+ if (endIdx > 0 && endIdx < proseEnd) proseEnd = endIdx;
148
+ }
149
+ var rawMd = html.substring(proseStart, proseEnd);
150
+ // Rebase relative URLs to absolute (skills.sh base)
151
+ result.skillMd = rawMd
152
+ .replace(/src="(?!https?:\/\/|data:)([^"]+)"/g, function (m, p) {
153
+ return 'src="' + new URL(p, url).href + '"';
154
+ })
155
+ .replace(/href="(?!https?:\/\/|mailto:|#)([^"]+)"/g, function (m, p) {
156
+ return 'href="' + new URL(p, url).href + '"';
157
+ });
158
+ }
159
+ }
160
+
161
+ return result;
162
+ });
163
+ }
164
+
15
165
  var MIME_TYPES = {
16
166
  ".html": "text/html",
17
167
  ".css": "text/css",
@@ -130,6 +280,21 @@ function createServer(opts) {
130
280
  var lanHost = opts.lanHost || null;
131
281
  var onAddProject = opts.onAddProject || null;
132
282
  var onRemoveProject = opts.onRemoveProject || null;
283
+ var onReorderProjects = opts.onReorderProjects || null;
284
+ var onSetProjectTitle = opts.onSetProjectTitle || null;
285
+ var onSetProjectIcon = opts.onSetProjectIcon || null;
286
+ var onGetServerDefaultEffort = opts.onGetServerDefaultEffort || null;
287
+ var onSetServerDefaultEffort = opts.onSetServerDefaultEffort || null;
288
+ var onGetProjectDefaultEffort = opts.onGetProjectDefaultEffort || null;
289
+ var onSetProjectDefaultEffort = opts.onSetProjectDefaultEffort || null;
290
+ var onGetServerDefaultModel = opts.onGetServerDefaultModel || null;
291
+ var onSetServerDefaultModel = opts.onSetServerDefaultModel || null;
292
+ var onGetProjectDefaultModel = opts.onGetProjectDefaultModel || null;
293
+ var onSetProjectDefaultModel = opts.onSetProjectDefaultModel || null;
294
+ var onGetServerDefaultMode = opts.onGetServerDefaultMode || null;
295
+ var onSetServerDefaultMode = opts.onSetServerDefaultMode || null;
296
+ var onGetProjectDefaultMode = opts.onGetProjectDefaultMode || null;
297
+ var onSetProjectDefaultMode = opts.onSetProjectDefaultMode || null;
133
298
  var onGetDaemonConfig = opts.onGetDaemonConfig || null;
134
299
  var onSetPin = opts.onSetPin || null;
135
300
  var onSetKeepAwake = opts.onSetKeepAwake || null;
@@ -286,6 +451,89 @@ function createServer(opts) {
286
451
  return;
287
452
  }
288
453
 
454
+ // Skills proxy: leaderboard list
455
+ if (req.method === "GET" && fullUrl === "/api/skills") {
456
+ var qs = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
457
+ var tabParam = new URLSearchParams(qs).get("tab") || "all";
458
+ var tabPath = tabParam === "trending" ? "/trending" : tabParam === "hot" ? "/hot" : "/";
459
+ var cacheKey = "skills_" + tabParam;
460
+ var cached = skillsCache[cacheKey];
461
+ if (cached && Date.now() - cached.ts < 300000) {
462
+ res.writeHead(200, { "Content-Type": "application/json" });
463
+ res.end(cached.data);
464
+ return;
465
+ }
466
+ fetchSkillsPage("https://skills.sh" + tabPath).then(function (data) {
467
+ var json = JSON.stringify(data);
468
+ skillsCache[cacheKey] = { ts: Date.now(), data: json };
469
+ res.writeHead(200, { "Content-Type": "application/json" });
470
+ res.end(json);
471
+ }).catch(function (err) {
472
+ res.writeHead(502, { "Content-Type": "application/json" });
473
+ res.end(JSON.stringify({ error: "Failed to fetch skills: " + (err.message || err) }));
474
+ });
475
+ return;
476
+ }
477
+
478
+ // Skills proxy: search
479
+ if (req.method === "GET" && fullUrl.startsWith("/api/skills/search")) {
480
+ var sqsRaw = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
481
+ var searchQ = new URLSearchParams(sqsRaw).get("q") || "";
482
+ if (!searchQ) {
483
+ res.writeHead(400, { "Content-Type": "application/json" });
484
+ res.end('{"error":"missing q param"}');
485
+ return;
486
+ }
487
+ var searchCacheKey = "search_" + searchQ.toLowerCase();
488
+ var searchCached = skillsCache[searchCacheKey];
489
+ if (searchCached && Date.now() - searchCached.ts < 300000) {
490
+ res.writeHead(200, { "Content-Type": "application/json" });
491
+ res.end(searchCached.data);
492
+ return;
493
+ }
494
+ fetchSkillsPage("https://skills.sh/?q=" + encodeURIComponent(searchQ)).then(function (data) {
495
+ var json = JSON.stringify(data);
496
+ skillsCache[searchCacheKey] = { ts: Date.now(), data: json };
497
+ res.writeHead(200, { "Content-Type": "application/json" });
498
+ res.end(json);
499
+ }).catch(function (err) {
500
+ res.writeHead(502, { "Content-Type": "application/json" });
501
+ res.end(JSON.stringify({ error: "Failed to search skills: " + (err.message || err) }));
502
+ });
503
+ return;
504
+ }
505
+
506
+ // Skills proxy: skill detail
507
+ if (req.method === "GET" && fullUrl.startsWith("/api/skills/detail")) {
508
+ var qs2 = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
509
+ var params2 = new URLSearchParams(qs2);
510
+ var detailSource = params2.get("source");
511
+ var detailSkill = params2.get("skill");
512
+ if (!detailSource || !detailSkill) {
513
+ res.writeHead(400, { "Content-Type": "application/json" });
514
+ res.end('{"error":"missing source or skill param"}');
515
+ return;
516
+ }
517
+ var detailCacheKey = "detail_" + detailSource + "_" + detailSkill;
518
+ var detailCached = skillsCache[detailCacheKey];
519
+ if (detailCached && Date.now() - detailCached.ts < 300000) {
520
+ res.writeHead(200, { "Content-Type": "application/json" });
521
+ res.end(detailCached.data);
522
+ return;
523
+ }
524
+ var detailUrl = "https://skills.sh/" + encodeURIComponent(detailSource).replace(/%2F/g, "/") + "/" + encodeURIComponent(detailSkill);
525
+ fetchSkillDetail(detailUrl).then(function (data) {
526
+ var json = JSON.stringify(data);
527
+ skillsCache[detailCacheKey] = { ts: Date.now(), data: json };
528
+ res.writeHead(200, { "Content-Type": "application/json" });
529
+ res.end(json);
530
+ }).catch(function (err) {
531
+ res.writeHead(502, { "Content-Type": "application/json" });
532
+ res.end(JSON.stringify({ error: "Failed to fetch skill detail: " + (err.message || err) }));
533
+ });
534
+ return;
535
+ }
536
+
289
537
  // Root path — redirect to first project
290
538
  if (fullUrl === "/" && req.method === "GET") {
291
539
  if (!isAuthed(req, authToken)) {
@@ -494,13 +742,28 @@ function createServer(opts) {
494
742
  });
495
743
  });
496
744
 
745
+ // --- Debounced broadcast for processing status changes ---
746
+ var processingUpdateTimer = null;
747
+ function broadcastProcessingChange() {
748
+ if (processingUpdateTimer) clearTimeout(processingUpdateTimer);
749
+ processingUpdateTimer = setTimeout(function () {
750
+ processingUpdateTimer = null;
751
+ broadcastAll({
752
+ type: "projects_updated",
753
+ projects: getProjects(),
754
+ projectCount: projects.size,
755
+ });
756
+ }, 200);
757
+ }
758
+
497
759
  // --- Project management ---
498
- function addProject(cwd, slug, title) {
760
+ function addProject(cwd, slug, title, icon) {
499
761
  if (projects.has(slug)) return false;
500
762
  var ctx = createProjectContext({
501
763
  cwd: cwd,
502
764
  slug: slug,
503
765
  title: title || null,
766
+ icon: icon || null,
504
767
  pushModule: pushModule,
505
768
  debug: debug,
506
769
  dangerouslySkipPermissions: dangerouslySkipPermissions,
@@ -512,8 +775,24 @@ function createServer(opts) {
512
775
  projects.forEach(function (ctx) { list.push(ctx.getStatus()); });
513
776
  return list;
514
777
  },
778
+ onProcessingChanged: broadcastProcessingChange,
515
779
  onAddProject: onAddProject,
516
780
  onRemoveProject: onRemoveProject,
781
+ onReorderProjects: onReorderProjects,
782
+ onSetProjectTitle: onSetProjectTitle,
783
+ onSetProjectIcon: onSetProjectIcon,
784
+ onGetServerDefaultEffort: onGetServerDefaultEffort,
785
+ onSetServerDefaultEffort: onSetServerDefaultEffort,
786
+ onGetProjectDefaultEffort: onGetProjectDefaultEffort,
787
+ onSetProjectDefaultEffort: onSetProjectDefaultEffort,
788
+ onGetServerDefaultModel: onGetServerDefaultModel,
789
+ onSetServerDefaultModel: onSetServerDefaultModel,
790
+ onGetProjectDefaultModel: onGetProjectDefaultModel,
791
+ onSetProjectDefaultModel: onSetProjectDefaultModel,
792
+ onGetServerDefaultMode: onGetServerDefaultMode,
793
+ onSetServerDefaultMode: onSetServerDefaultMode,
794
+ onGetProjectDefaultMode: onGetProjectDefaultMode,
795
+ onSetProjectDefaultMode: onSetProjectDefaultMode,
517
796
  onGetDaemonConfig: onGetDaemonConfig,
518
797
  onSetPin: onSetPin,
519
798
  onSetKeepAwake: onSetKeepAwake,
@@ -540,6 +819,22 @@ function createServer(opts) {
540
819
  return list;
541
820
  }
542
821
 
822
+ function reorderProjects(slugs) {
823
+ var ordered = new Map();
824
+ for (var i = 0; i < slugs.length; i++) {
825
+ var ctx = projects.get(slugs[i]);
826
+ if (ctx) ordered.set(slugs[i], ctx);
827
+ }
828
+ // Append any remaining (safety)
829
+ projects.forEach(function (ctx, slug) {
830
+ if (!ordered.has(slug)) ordered.set(slug, ctx);
831
+ });
832
+ projects.clear();
833
+ ordered.forEach(function (ctx, slug) {
834
+ projects.set(slug, ctx);
835
+ });
836
+ }
837
+
543
838
  function setProjectTitle(slug, title) {
544
839
  var ctx = projects.get(slug);
545
840
  if (!ctx) return false;
@@ -547,6 +842,13 @@ function createServer(opts) {
547
842
  return true;
548
843
  }
549
844
 
845
+ function setProjectIcon(slug, icon) {
846
+ var ctx = projects.get(slug);
847
+ if (!ctx) return false;
848
+ ctx.setIcon(icon);
849
+ return true;
850
+ }
851
+
550
852
  function setAuthToken(hash) {
551
853
  authToken = hash;
552
854
  }
@@ -572,7 +874,9 @@ function createServer(opts) {
572
874
  addProject: addProject,
573
875
  removeProject: removeProject,
574
876
  getProjects: getProjects,
877
+ reorderProjects: reorderProjects,
575
878
  setProjectTitle: setProjectTitle,
879
+ setProjectIcon: setProjectIcon,
576
880
  setAuthToken: setAuthToken,
577
881
  broadcastAll: broadcastAll,
578
882
  destroyAll: destroyAll,
package/lib/sessions.js CHANGED
@@ -1,6 +1,7 @@
1
- const fs = require("fs");
2
- const path = require("path");
3
- const config = require("./config");
1
+ var fs = require("fs");
2
+ var path = require("path");
3
+ var config = require("./config");
4
+ var utils = require("./utils");
4
5
 
5
6
  function createSessionManager(opts) {
6
7
  var cwd = opts.cwd;
@@ -15,7 +16,7 @@ function createSessionManager(opts) {
15
16
  var skillNames = null; // Claude-only skills to filter from slash menu
16
17
 
17
18
  // --- Session persistence (centralized in ~/.clay/sessions/{encoded-cwd}/) ---
18
- var encodedCwd = cwd.replace(/\//g, "-");
19
+ var encodedCwd = utils.encodeCwd(cwd);
19
20
  var sessionsDir = path.join(config.CONFIG_DIR, "sessions", encodedCwd);
20
21
  fs.mkdirSync(sessionsDir, { recursive: true });
21
22
 
@@ -310,6 +311,10 @@ function createSessionManager(opts) {
310
311
  appendToSessionFile(session, obj);
311
312
  if (session.localId === activeSessionId) {
312
313
  send(obj);
314
+ } else if (session.isProcessing && !session._ioThrottle) {
315
+ session._ioThrottle = true;
316
+ send({ type: "session_io", id: session.localId });
317
+ setTimeout(function () { session._ioThrottle = false; }, 80);
313
318
  }
314
319
  }
315
320
 
package/lib/utils.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared utility functions.
3
+ */
4
+
5
+ /**
6
+ * 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
+ *
10
+ * Example: "/Users/jon.doe/my-project" -> "-Users-jon-doe-my-project"
11
+ */
12
+ function encodeCwd(cwd) {
13
+ return cwd.replace(/[\/\.]/g, "-");
14
+ }
15
+
16
+ module.exports = {
17
+ encodeCwd: encodeCwd,
18
+ };
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
- "clay-server": "./bin/cli.js"
6
+ "clay-server": "./bin/cli.js",
7
+ "claude-relay": "./bin/claude-relay.js"
7
8
  },
8
9
  "scripts": {
9
10
  "dev": "node bin/cli.js --dev",