claude-relay 2.3.1 → 2.4.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 (52) hide show
  1. package/README.md +20 -5
  2. package/bin/cli.js +206 -8
  3. package/lib/cli-sessions.js +270 -0
  4. package/lib/daemon.js +40 -0
  5. package/lib/project.js +121 -1
  6. package/lib/public/app.js +385 -76
  7. package/lib/public/css/base.css +41 -7
  8. package/lib/public/css/diff.css +6 -6
  9. package/lib/public/css/filebrowser.css +34 -54
  10. package/lib/public/css/highlight.css +144 -0
  11. package/lib/public/css/input.css +9 -9
  12. package/lib/public/css/menus.css +82 -23
  13. package/lib/public/css/messages.css +178 -34
  14. package/lib/public/css/overlays.css +166 -50
  15. package/lib/public/css/rewind.css +17 -17
  16. package/lib/public/css/sidebar.css +210 -137
  17. package/lib/public/index.html +73 -40
  18. package/lib/public/modules/filebrowser.js +2 -1
  19. package/lib/public/modules/markdown.js +10 -10
  20. package/lib/public/modules/notifications.js +38 -1
  21. package/lib/public/modules/sidebar.js +109 -31
  22. package/lib/public/modules/terminal.js +11 -23
  23. package/lib/public/modules/theme.js +622 -0
  24. package/lib/public/modules/tools.js +245 -4
  25. package/lib/public/modules/utils.js +21 -5
  26. package/lib/public/style.css +1 -0
  27. package/lib/sdk-bridge.js +95 -0
  28. package/lib/server.js +41 -0
  29. package/lib/sessions.js +16 -3
  30. package/lib/themes/ayu-light.json +9 -0
  31. package/lib/themes/catppuccin-latte.json +9 -0
  32. package/lib/themes/catppuccin-mocha.json +9 -0
  33. package/lib/themes/claude-light.json +9 -0
  34. package/lib/themes/claude.json +9 -0
  35. package/lib/themes/dracula.json +9 -0
  36. package/lib/themes/everforest-light.json +9 -0
  37. package/lib/themes/everforest.json +9 -0
  38. package/lib/themes/github-light.json +9 -0
  39. package/lib/themes/gruvbox-dark.json +9 -0
  40. package/lib/themes/gruvbox-light.json +9 -0
  41. package/lib/themes/monokai.json +9 -0
  42. package/lib/themes/nord-light.json +9 -0
  43. package/lib/themes/nord.json +9 -0
  44. package/lib/themes/one-dark.json +9 -0
  45. package/lib/themes/one-light.json +9 -0
  46. package/lib/themes/rose-pine-dawn.json +9 -0
  47. package/lib/themes/rose-pine.json +9 -0
  48. package/lib/themes/solarized-dark.json +9 -0
  49. package/lib/themes/solarized-light.json +9 -0
  50. package/lib/themes/tokyo-night-light.json +9 -0
  51. package/lib/themes/tokyo-night.json +9 -0
  52. package/package.json +2 -1
@@ -1,4 +1,4 @@
1
- import { escapeHtml } from './utils.js';
1
+ import { escapeHtml, copyToClipboard } from './utils.js';
2
2
  import { iconHtml, refreshIcons, randomThinkingVerb } from './icons.js';
3
3
  import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks } from './markdown.js';
4
4
  import { renderUnifiedDiff, renderSplitDiff, renderPatchDiff } from './diff.js';
@@ -21,11 +21,110 @@ var tools = {};
21
21
  var currentThinking = null;
22
22
  var pendingPermissions = {};
23
23
 
24
+ // --- Tool group tracking ---
25
+ var currentToolGroup = null;
26
+ var toolGroupCounter = 0;
27
+ var toolGroups = {};
28
+
24
29
  // --- Tool helpers ---
25
30
  var PLAN_MODE_TOOLS = { EnterPlanMode: 1, ExitPlanMode: 1 };
26
31
  var TODO_TOOLS = { TodoWrite: 1, TaskCreate: 1, TaskUpdate: 1, TaskList: 1, TaskGet: 1 };
27
32
  var HIDDEN_RESULT_TOOLS = { EnterPlanMode: 1, ExitPlanMode: 1, TaskCreate: 1, TaskUpdate: 1, TaskList: 1, TaskGet: 1, TodoWrite: 1 };
28
33
 
34
+ // --- Tool group helpers ---
35
+ function closeToolGroup() {
36
+ if (currentToolGroup) {
37
+ currentToolGroup.closed = true;
38
+ }
39
+ currentToolGroup = null;
40
+ }
41
+
42
+ function findToolGroup(groupId) {
43
+ return toolGroups[groupId] || null;
44
+ }
45
+
46
+ function toolGroupSummary(group) {
47
+ var names = group.toolNames;
48
+ var count = names.length;
49
+ var allDone = group.doneCount >= count;
50
+
51
+ // Count by tool name
52
+ var counts = {};
53
+ for (var i = 0; i < names.length; i++) {
54
+ counts[names[i]] = (counts[names[i]] || 0) + 1;
55
+ }
56
+ var uniqueNames = Object.keys(counts);
57
+
58
+ if (uniqueNames.length === 1) {
59
+ var name = uniqueNames[0];
60
+ var n = counts[name];
61
+ if (allDone) {
62
+ switch (name) {
63
+ case "Read": return "Read " + n + " file" + (n > 1 ? "s" : "");
64
+ case "Edit": return "Edited " + n + " file" + (n > 1 ? "s" : "");
65
+ case "Write": return "Wrote " + n + " file" + (n > 1 ? "s" : "");
66
+ case "Bash": return "Ran " + n + " command" + (n > 1 ? "s" : "");
67
+ case "Grep": return "Searched " + n + " pattern" + (n > 1 ? "s" : "");
68
+ case "Glob": return "Found " + n + " pattern" + (n > 1 ? "s" : "");
69
+ case "Task": return "Ran " + n + " task" + (n > 1 ? "s" : "");
70
+ case "WebSearch": return "Searched " + n + " quer" + (n > 1 ? "ies" : "y");
71
+ case "WebFetch": return "Fetched " + n + " URL" + (n > 1 ? "s" : "");
72
+ default: return "Ran " + n + " tool" + (n > 1 ? "s" : "");
73
+ }
74
+ }
75
+ switch (name) {
76
+ case "Read": return "Reading " + n + " file" + (n > 1 ? "s" : "") + "...";
77
+ case "Edit": return "Editing " + n + " file" + (n > 1 ? "s" : "") + "...";
78
+ case "Write": return "Writing " + n + " file" + (n > 1 ? "s" : "") + "...";
79
+ case "Bash": return "Running " + n + " command" + (n > 1 ? "s" : "") + "...";
80
+ case "Grep": return "Searching " + n + " pattern" + (n > 1 ? "s" : "") + "...";
81
+ case "Glob": return "Finding " + n + " pattern" + (n > 1 ? "s" : "") + "...";
82
+ case "Task": return "Running " + n + " task" + (n > 1 ? "s" : "") + "...";
83
+ case "WebSearch": return "Searching " + n + " quer" + (n > 1 ? "ies" : "y") + "...";
84
+ case "WebFetch": return "Fetching " + n + " URL" + (n > 1 ? "s" : "") + "...";
85
+ default: return "Running " + n + " tool" + (n > 1 ? "s" : "") + "...";
86
+ }
87
+ }
88
+
89
+ // Mixed tools
90
+ if (allDone) return "Ran " + count + " tools";
91
+ return "Running " + count + " tools...";
92
+ }
93
+
94
+ function updateToolGroupHeader(group) {
95
+ if (!group || !group.el) return;
96
+ var label = group.el.querySelector(".tool-group-label");
97
+ if (label) label.textContent = toolGroupSummary(group);
98
+
99
+ var allDone = group.doneCount >= group.toolCount;
100
+ var statusIcon = group.el.querySelector(".tool-group-status-icon");
101
+ var bullet = group.el.querySelector(".tool-group-bullet");
102
+
103
+ if (allDone) {
104
+ group.el.classList.add("done");
105
+ if (group.errorCount > 0) {
106
+ statusIcon.innerHTML = '<span class="err-icon">' + iconHtml("alert-triangle") + '</span>';
107
+ if (bullet) bullet.classList.add("error");
108
+ } else {
109
+ statusIcon.innerHTML = '<span class="check">' + iconHtml("check") + '</span>';
110
+ }
111
+ refreshIcons();
112
+ }
113
+
114
+ // Show group header only when 2+ visible tools
115
+ var header = group.el.querySelector(".tool-group-header");
116
+ if (group.toolCount >= 2) {
117
+ header.style.display = "";
118
+ // When 2+ tools, ensure collapsed by default (unless user already toggled)
119
+ if (!group.userToggled && !group.el.classList.contains("expanded-by-user")) {
120
+ group.el.classList.add("collapsed");
121
+ }
122
+ } else {
123
+ header.style.display = "none";
124
+ group.el.classList.remove("collapsed");
125
+ }
126
+ }
127
+
29
128
  function isPlanFile(filePath) {
30
129
  return filePath && filePath.indexOf(".claude/plans/") !== -1;
31
130
  }
@@ -73,6 +172,7 @@ function shortPath(p) {
73
172
  export function renderAskUserQuestion(toolId, input) {
74
173
  ctx.finalizeAssistantBlock();
75
174
  stopThinking();
175
+ closeToolGroup();
76
176
 
77
177
  var questions = input.questions || [];
78
178
  if (questions.length === 0) return;
@@ -261,6 +361,7 @@ export function renderPermissionRequest(requestId, toolName, toolInput, decision
261
361
  if (pendingPermissions[requestId]) return;
262
362
  ctx.finalizeAssistantBlock();
263
363
  stopThinking();
364
+ closeToolGroup();
264
365
 
265
366
  // ExitPlanMode: render as plan confirmation instead of generic permission
266
367
  if (toolName === "ExitPlanMode") {
@@ -469,6 +570,7 @@ export function markPermissionCancelled(requestId) {
469
570
  export function renderPlanBanner(type) {
470
571
  ctx.finalizeAssistantBlock();
471
572
  stopThinking();
573
+ closeToolGroup();
472
574
 
473
575
  var el = document.createElement("div");
474
576
  el.className = "plan-banner";
@@ -497,6 +599,7 @@ export function renderPlanBanner(type) {
497
599
 
498
600
  export function renderPlanCard(content) {
499
601
  ctx.finalizeAssistantBlock();
602
+ closeToolGroup();
500
603
 
501
604
  var el = document.createElement("div");
502
605
  el.className = "plan-card";
@@ -519,7 +622,7 @@ export function renderPlanCard(content) {
519
622
  if (copyBtn) {
520
623
  copyBtn.addEventListener("click", function (e) {
521
624
  e.stopPropagation();
522
- navigator.clipboard.writeText(content).then(function () {
625
+ copyToClipboard(content).then(function () {
523
626
  copyBtn.innerHTML = iconHtml("check");
524
627
  refreshIcons();
525
628
  setTimeout(function () {
@@ -785,6 +888,40 @@ export function createToolItem(id, name) {
785
888
  ctx.finalizeAssistantBlock();
786
889
  stopThinking();
787
890
 
891
+ // Group management: create new group or reuse existing open group
892
+ if (!currentToolGroup || currentToolGroup.closed) {
893
+ toolGroupCounter++;
894
+ var groupEl = document.createElement("div");
895
+ groupEl.className = "tool-group";
896
+ groupEl.dataset.groupId = "g" + toolGroupCounter;
897
+ groupEl.innerHTML =
898
+ '<div class="tool-group-header" style="display:none">' +
899
+ '<span class="tool-group-chevron">' + iconHtml("chevron-right") + '</span>' +
900
+ '<span class="tool-group-bullet"></span>' +
901
+ '<span class="tool-group-label">Running...</span>' +
902
+ '<span class="tool-group-status-icon">' + iconHtml("loader", "icon-spin") + '</span>' +
903
+ '</div>' +
904
+ '<div class="tool-group-items"></div>';
905
+
906
+ groupEl.querySelector(".tool-group-header").addEventListener("click", function () {
907
+ groupEl.classList.toggle("collapsed");
908
+ });
909
+
910
+ ctx.addToMessages(groupEl);
911
+ refreshIcons();
912
+
913
+ currentToolGroup = {
914
+ el: groupEl,
915
+ id: "g" + toolGroupCounter,
916
+ toolNames: [],
917
+ toolCount: 0,
918
+ doneCount: 0,
919
+ errorCount: 0,
920
+ closed: false,
921
+ };
922
+ toolGroups[currentToolGroup.id] = currentToolGroup;
923
+ }
924
+
788
925
  var el = document.createElement("div");
789
926
  el.className = "tool-item";
790
927
  el.dataset.toolId = id;
@@ -803,11 +940,16 @@ export function createToolItem(id, name) {
803
940
 
804
941
  el.querySelector(".tool-name").textContent = name;
805
942
 
806
- ctx.addToMessages(el);
943
+ // Append to group instead of messages directly
944
+ currentToolGroup.el.querySelector(".tool-group-items").appendChild(el);
945
+ currentToolGroup.toolNames.push(name);
946
+ currentToolGroup.toolCount++;
947
+ updateToolGroupHeader(currentToolGroup);
948
+
807
949
  refreshIcons();
808
950
  ctx.scrollToBottom();
809
951
 
810
- tools[id] = { el: el, name: name, input: null, done: false };
952
+ tools[id] = { el: el, name: name, input: null, done: false, groupId: currentToolGroup.id };
811
953
  ctx.setActivity("Running " + name + "...");
812
954
  }
813
955
 
@@ -1095,6 +1237,16 @@ export function markToolDone(id, isError) {
1095
1237
  icon.innerHTML = '<span class="check">' + iconHtml("check") + '</span>';
1096
1238
  }
1097
1239
  refreshIcons();
1240
+
1241
+ // Update group state
1242
+ if (tool.groupId) {
1243
+ var group = findToolGroup(tool.groupId);
1244
+ if (group) {
1245
+ group.doneCount++;
1246
+ if (isError) group.errorCount++;
1247
+ updateToolGroupHeader(group);
1248
+ }
1249
+ }
1098
1250
  }
1099
1251
 
1100
1252
  export function markAllToolsDone() {
@@ -1105,7 +1257,71 @@ export function markAllToolsDone() {
1105
1257
  }
1106
1258
  }
1107
1259
 
1260
+ // --- Sub-agent (Task tool) log ---
1261
+ export function updateSubagentActivity(parentToolId, text) {
1262
+ var tool = tools[parentToolId];
1263
+ if (!tool || !tool.el) return;
1264
+
1265
+ // Update subtitle text with current activity
1266
+ var subtitleText = tool.el.querySelector(".tool-subtitle-text");
1267
+ if (subtitleText) subtitleText.textContent = text;
1268
+
1269
+ // Update or create the subagent log
1270
+ var log = tool.el.querySelector(".subagent-log");
1271
+ if (!log) {
1272
+ log = document.createElement("div");
1273
+ log.className = "subagent-log";
1274
+ tool.el.appendChild(log);
1275
+ }
1276
+
1277
+ ctx.setActivity(text);
1278
+ ctx.scrollToBottom();
1279
+ }
1280
+
1281
+ export function addSubagentToolEntry(parentToolId, toolName, toolId, text) {
1282
+ var tool = tools[parentToolId];
1283
+ if (!tool || !tool.el) return;
1284
+
1285
+ // Update subtitle
1286
+ var subtitleText = tool.el.querySelector(".tool-subtitle-text");
1287
+ if (subtitleText) subtitleText.textContent = text;
1288
+
1289
+ // Create log if needed
1290
+ var log = tool.el.querySelector(".subagent-log");
1291
+ if (!log) {
1292
+ log = document.createElement("div");
1293
+ log.className = "subagent-log";
1294
+ tool.el.appendChild(log);
1295
+ }
1296
+
1297
+ // Add entry
1298
+ var entry = document.createElement("div");
1299
+ entry.className = "subagent-log-entry";
1300
+ entry.innerHTML =
1301
+ '<span class="subagent-log-bullet"></span>' +
1302
+ '<span class="subagent-log-tool"></span>' +
1303
+ '<span class="subagent-log-text"></span>';
1304
+ entry.querySelector(".subagent-log-tool").textContent = toolName;
1305
+ entry.querySelector(".subagent-log-text").textContent = text;
1306
+ log.appendChild(entry);
1307
+
1308
+ // Auto-scroll to latest entry
1309
+ log.scrollTop = log.scrollHeight;
1310
+
1311
+ ctx.setActivity(text);
1312
+ ctx.scrollToBottom();
1313
+ }
1314
+
1315
+ export function markSubagentDone(parentToolId) {
1316
+ var tool = tools[parentToolId];
1317
+ if (!tool || !tool.el) return;
1318
+
1319
+ var subtitleText = tool.el.querySelector(".tool-subtitle-text");
1320
+ if (subtitleText) subtitleText.textContent = "Agent finished";
1321
+ }
1322
+
1108
1323
  export function addTurnMeta(cost, duration) {
1324
+ closeToolGroup();
1109
1325
  var div = document.createElement("div");
1110
1326
  div.className = "turn-meta";
1111
1327
  div.dataset.turn = ctx.turnCounter;
@@ -1119,6 +1335,22 @@ export function addTurnMeta(cost, duration) {
1119
1335
  }
1120
1336
  }
1121
1337
 
1338
+ // --- Tool group exports ---
1339
+ export { closeToolGroup };
1340
+
1341
+ export function removeToolFromGroup(toolId) {
1342
+ var tool = tools[toolId];
1343
+ if (!tool || !tool.groupId) return;
1344
+ var group = findToolGroup(tool.groupId);
1345
+ if (!group) return;
1346
+ group.toolCount--;
1347
+ // Remove tool name from the names array (remove first occurrence)
1348
+ var idx = group.toolNames.indexOf(tool.name);
1349
+ if (idx !== -1) group.toolNames.splice(idx, 1);
1350
+ if (tool.done) group.doneCount--;
1351
+ updateToolGroupHeader(group);
1352
+ }
1353
+
1122
1354
  // Expose state getters and reset
1123
1355
  export function getTools() { return tools; }
1124
1356
  export function isInPlanMode() { return inPlanMode; }
@@ -1136,6 +1368,9 @@ export function saveToolState() {
1136
1368
  todoWidgetEl: todoWidgetEl,
1137
1369
  inPlanMode: inPlanMode,
1138
1370
  planContent: planContent,
1371
+ currentToolGroup: currentToolGroup,
1372
+ toolGroupCounter: toolGroupCounter,
1373
+ toolGroups: toolGroups,
1139
1374
  };
1140
1375
  }
1141
1376
 
@@ -1145,6 +1380,9 @@ export function restoreToolState(saved) {
1145
1380
  todoWidgetEl = saved.todoWidgetEl;
1146
1381
  inPlanMode = saved.inPlanMode;
1147
1382
  planContent = saved.planContent;
1383
+ currentToolGroup = saved.currentToolGroup;
1384
+ toolGroupCounter = saved.toolGroupCounter;
1385
+ toolGroups = saved.toolGroups;
1148
1386
  if (todoWidgetEl) {
1149
1387
  setupTodoObserver();
1150
1388
  }
@@ -1160,6 +1398,9 @@ export function resetToolState() {
1160
1398
  todoWidgetVisible = true;
1161
1399
  if (todoObserver) { todoObserver.disconnect(); todoObserver = null; }
1162
1400
  pendingPermissions = {};
1401
+ currentToolGroup = null;
1402
+ toolGroupCounter = 0;
1403
+ toolGroups = {};
1163
1404
  var stickyEl = document.getElementById("todo-sticky");
1164
1405
  if (stickyEl) { stickyEl.classList.add("hidden"); stickyEl.innerHTML = ""; }
1165
1406
  }
@@ -18,19 +18,35 @@ export function showToast(message, level, detail) {
18
18
  }, duration);
19
19
  }
20
20
 
21
+ var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
22
+ (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
23
+
21
24
  export function copyToClipboard(text) {
22
25
  var p;
23
- if (navigator.clipboard && navigator.clipboard.writeText) {
24
- p = navigator.clipboard.writeText(text);
25
- } else {
26
+ if (isIOS) {
27
+ // iOS Safari URL-encodes clipboard text that contains colons via the
28
+ // Clipboard API. Use textarea + execCommand to copy raw text instead.
26
29
  var ta = document.createElement("textarea");
27
30
  ta.value = text;
28
- ta.style.cssText = "position:fixed;opacity:0";
31
+ ta.style.cssText = "position:fixed;left:-9999px;opacity:0";
29
32
  document.body.appendChild(ta);
30
- ta.select();
33
+ ta.focus();
34
+ ta.setSelectionRange(0, ta.value.length);
31
35
  document.execCommand("copy");
32
36
  document.body.removeChild(ta);
33
37
  p = Promise.resolve();
38
+ } else if (navigator.clipboard && navigator.clipboard.writeText) {
39
+ p = navigator.clipboard.writeText(text);
40
+ } else {
41
+ var ta2 = document.createElement("textarea");
42
+ ta2.value = text;
43
+ ta2.style.cssText = "position:fixed;left:-9999px;opacity:0";
44
+ document.body.appendChild(ta2);
45
+ ta2.focus();
46
+ ta2.setSelectionRange(0, ta2.value.length);
47
+ document.execCommand("copy");
48
+ document.body.removeChild(ta2);
49
+ p = Promise.resolve();
34
50
  }
35
51
  return p.then(function () { showToast("Copied to clipboard"); });
36
52
  }
@@ -7,3 +7,4 @@
7
7
  @import url("css/input.css");
8
8
  @import url("css/filebrowser.css");
9
9
  @import url("css/diff.css");
10
+ @import url("css/highlight.css");
package/lib/sdk-bridge.js CHANGED
@@ -137,6 +137,13 @@ function createSDKBridge(opts) {
137
137
  var input = {};
138
138
  try { input = JSON.parse(block.inputJson); } catch {}
139
139
  sendAndRecord(session, { type: "tool_executing", id: block.id, name: block.name, input: input });
140
+
141
+ // Track active Task tools for sub-agent done detection
142
+ if (block.name === "Task") {
143
+ if (!session.activeTaskToolIds) session.activeTaskToolIds = {};
144
+ session.activeTaskToolIds[block.id] = true;
145
+ }
146
+
140
147
  if (pushModule && block.name === "AskUserQuestion" && input.questions) {
141
148
  var q = input.questions[0];
142
149
  pushModule.sendPush({
@@ -155,6 +162,12 @@ function createSDKBridge(opts) {
155
162
  }
156
163
 
157
164
  } else if ((parsed.type === "assistant" || parsed.type === "user") && parsed.message && parsed.message.content) {
165
+ // Sub-agent messages: extract tool_use blocks for activity display
166
+ if (parsed.parent_tool_use_id) {
167
+ processSubagentMessage(session, parsed);
168
+ return;
169
+ }
170
+
158
171
  var content = parsed.message.content;
159
172
 
160
173
  // Fallback: if assistant text wasn't streamed via deltas, send it now
@@ -191,6 +204,14 @@ function createSDKBridge(opts) {
191
204
  for (var i = 0; i < content.length; i++) {
192
205
  var block = content[i];
193
206
  if (block.type === "tool_result" && !session.sentToolResults[block.tool_use_id]) {
207
+ // Clear active Task tool when its result arrives
208
+ if (session.activeTaskToolIds && session.activeTaskToolIds[block.tool_use_id]) {
209
+ sendAndRecord(session, {
210
+ type: "subagent_done",
211
+ parentToolId: block.tool_use_id,
212
+ });
213
+ delete session.activeTaskToolIds[block.tool_use_id];
214
+ }
194
215
  var resultText = "";
195
216
  if (typeof block.content === "string") {
196
217
  resultText = block.content;
@@ -216,6 +237,7 @@ function createSDKBridge(opts) {
216
237
  session.sentToolResults = {};
217
238
  session.pendingPermissions = {};
218
239
  session.pendingAskUser = {};
240
+ session.activeTaskToolIds = {};
219
241
  session.isProcessing = false;
220
242
  sendAndRecord(session, {
221
243
  type: "result",
@@ -250,10 +272,82 @@ function createSDKBridge(opts) {
250
272
  }
251
273
  session.compacting = parsed.status === "compacting";
252
274
 
275
+ } else if (parsed.type === "tool_progress") {
276
+ // Sub-agent tool_progress: forward as activity update
277
+ var parentId = parsed.parent_tool_use_id;
278
+ if (parentId) {
279
+ sendAndRecord(session, {
280
+ type: "subagent_activity",
281
+ parentToolId: parentId,
282
+ text: parsed.content || "",
283
+ });
284
+ }
285
+
286
+ } else if (parsed.type === "task_notification") {
287
+ // Sub-agent finished
288
+ var parentId = parsed.parent_tool_use_id;
289
+ if (parentId) {
290
+ sendAndRecord(session, {
291
+ type: "subagent_done",
292
+ parentToolId: parentId,
293
+ });
294
+ }
295
+
253
296
  } else if (parsed.type && parsed.type !== "system" && parsed.type !== "user") {
254
297
  }
255
298
  }
256
299
 
300
+ // --- Sub-agent message processing ---
301
+
302
+ function toolActivityTextForSubagent(name, input) {
303
+ if (name === "Bash" && input && input.description) return input.description;
304
+ if (name === "Read" && input && input.file_path) return "Reading " + input.file_path.split("/").pop();
305
+ if (name === "Edit" && input && input.file_path) return "Editing " + input.file_path.split("/").pop();
306
+ if (name === "Write" && input && input.file_path) return "Writing " + input.file_path.split("/").pop();
307
+ if (name === "Grep" && input && input.pattern) return "Searching for " + input.pattern;
308
+ if (name === "Glob" && input && input.pattern) return "Finding " + input.pattern;
309
+ if (name === "WebSearch" && input && input.query) return "Searching: " + input.query;
310
+ if (name === "WebFetch") return "Fetching URL...";
311
+ if (name === "Task" && input && input.description) return input.description;
312
+ return "Running " + name + "...";
313
+ }
314
+
315
+ function processSubagentMessage(session, parsed) {
316
+ var parentId = parsed.parent_tool_use_id;
317
+ var content = parsed.message.content;
318
+ if (!Array.isArray(content)) return;
319
+
320
+ if (parsed.type === "assistant") {
321
+ // Extract tool_use blocks from sub-agent assistant messages
322
+ for (var i = 0; i < content.length; i++) {
323
+ var block = content[i];
324
+ if (block.type === "tool_use") {
325
+ var activityText = toolActivityTextForSubagent(block.name, block.input);
326
+ sendAndRecord(session, {
327
+ type: "subagent_tool",
328
+ parentToolId: parentId,
329
+ toolName: block.name,
330
+ toolId: block.id,
331
+ text: activityText,
332
+ });
333
+ } else if (block.type === "thinking") {
334
+ sendAndRecord(session, {
335
+ type: "subagent_activity",
336
+ parentToolId: parentId,
337
+ text: "Thinking...",
338
+ });
339
+ } else if (block.type === "text" && block.text) {
340
+ sendAndRecord(session, {
341
+ type: "subagent_activity",
342
+ parentToolId: parentId,
343
+ text: "Writing response...",
344
+ });
345
+ }
346
+ }
347
+ }
348
+ // user messages with parent_tool_use_id contain tool_results — skip silently
349
+ }
350
+
257
351
  // --- SDK query lifecycle ---
258
352
 
259
353
  function handleCanUseTool(session, toolName, input, opts) {
@@ -404,6 +498,7 @@ function createSDKBridge(opts) {
404
498
  session.messageQueue = createMessageQueue();
405
499
  session.blocks = {};
406
500
  session.sentToolResults = {};
501
+ session.activeTaskToolIds = {};
407
502
  session.streamedText = false;
408
503
  session.responsePreview = "";
409
504
 
package/lib/server.js CHANGED
@@ -6,7 +6,11 @@ var { WebSocketServer } = require("ws");
6
6
  var { pinPageHtml, setupPageHtml, dashboardPageHtml } = require("./pages");
7
7
  var { createProjectContext } = require("./project");
8
8
 
9
+ var { CONFIG_DIR } = require("./config");
10
+
9
11
  var publicDir = path.join(__dirname, "public");
12
+ var bundledThemesDir = path.join(__dirname, "themes");
13
+ var userThemesDir = path.join(CONFIG_DIR, "themes");
10
14
 
11
15
  var MIME_TYPES = {
12
16
  ".html": "text/html",
@@ -124,6 +128,8 @@ function createServer(opts) {
124
128
  var debug = opts.debug || false;
125
129
  var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
126
130
  var lanHost = opts.lanHost || null;
131
+ var onAddProject = opts.onAddProject || null;
132
+ var onRemoveProject = opts.onRemoveProject || null;
127
133
 
128
134
  var authToken = pinHash || null;
129
135
  var realVersion = require("../package.json").version;
@@ -243,6 +249,39 @@ function createServer(opts) {
243
249
  return;
244
250
  }
245
251
 
252
+ // Theme list: bundled (lib/themes/) + user (~/.claude-relay/themes/)
253
+ if (req.method === "GET" && fullUrl === "/api/themes") {
254
+ var bundled = {};
255
+ var custom = {};
256
+ // Read bundled themes
257
+ try {
258
+ var bFiles = fs.readdirSync(bundledThemesDir);
259
+ for (var i = 0; i < bFiles.length; i++) {
260
+ if (!bFiles[i].endsWith(".json")) continue;
261
+ try {
262
+ var raw = fs.readFileSync(path.join(bundledThemesDir, bFiles[i]), "utf8");
263
+ var id = bFiles[i].replace(/\.json$/, "");
264
+ bundled[id] = JSON.parse(raw);
265
+ } catch (e) {}
266
+ }
267
+ } catch (e) {}
268
+ // Read user themes (override bundled if same id)
269
+ try {
270
+ var uFiles = fs.readdirSync(userThemesDir);
271
+ for (var j = 0; j < uFiles.length; j++) {
272
+ if (!uFiles[j].endsWith(".json")) continue;
273
+ try {
274
+ var uRaw = fs.readFileSync(path.join(userThemesDir, uFiles[j]), "utf8");
275
+ var uid = uFiles[j].replace(/\.json$/, "");
276
+ custom[uid] = JSON.parse(uRaw);
277
+ } catch (e) {}
278
+ }
279
+ } catch (e) {}
280
+ res.writeHead(200, { "Content-Type": "application/json" });
281
+ res.end(JSON.stringify({ bundled: bundled, custom: custom }));
282
+ return;
283
+ }
284
+
246
285
  // Root path — dashboard or redirect
247
286
  if (fullUrl === "/" && req.method === "GET") {
248
287
  if (!isAuthed(req, authToken)) {
@@ -472,6 +511,8 @@ function createServer(opts) {
472
511
  projects.forEach(function (ctx) { list.push(ctx.getStatus()); });
473
512
  return list;
474
513
  },
514
+ onAddProject: onAddProject,
515
+ onRemoveProject: onRemoveProject,
475
516
  });
476
517
  projects.set(slug, ctx);
477
518
  ctx.warmup();
package/lib/sessions.js CHANGED
@@ -253,7 +253,20 @@ function createSessionManager(opts) {
253
253
  }
254
254
  }
255
255
 
256
- function resumeSession(cliSessionId) {
256
+ function resumeSession(cliSessionId, opts) {
257
+ // If a session with this cliSessionId already exists, just switch to it
258
+ var existing = null;
259
+ sessions.forEach(function (s) {
260
+ if (s.cliSessionId === cliSessionId) existing = s;
261
+ });
262
+ if (existing) {
263
+ existing.lastActivity = Date.now();
264
+ switchSession(existing.localId);
265
+ return existing;
266
+ }
267
+
268
+ var cliHistory = (opts && opts.history) || [];
269
+ var title = (opts && opts.title) || "Resumed session";
257
270
  var localId = nextLocalId++;
258
271
  var session = {
259
272
  localId: localId,
@@ -266,9 +279,9 @@ function createSessionManager(opts) {
266
279
  pendingAskUser: {},
267
280
  allowedTools: {},
268
281
  isProcessing: false,
269
- title: "Resumed session",
282
+ title: title,
270
283
  createdAt: Date.now(),
271
- history: [],
284
+ history: cliHistory,
272
285
  messageUUIDs: [],
273
286
  };
274
287
  sessions.set(localId, session);
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "Ayu Light",
3
+ "author": "Ike Ku",
4
+ "variant": "light",
5
+ "base00": "FAFAFA", "base01": "EDEFF1", "base02": "D2D4D8", "base03": "A0A6AC",
6
+ "base04": "8A9199", "base05": "5C6166", "base06": "4E5257", "base07": "404447",
7
+ "base08": "F07171", "base09": "FA8D3E", "base0A": "F2AE49", "base0B": "6CBF49",
8
+ "base0C": "4CBF99", "base0D": "399EE6", "base0E": "A37ACC", "base0F": "E6BA7E"
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "Catppuccin Latte",
3
+ "author": "catppuccin",
4
+ "variant": "light",
5
+ "base00": "eff1f5", "base01": "e6e9ef", "base02": "ccd0da", "base03": "9ca0b0",
6
+ "base04": "8c8fa1", "base05": "5c5f77", "base06": "4c4f69", "base07": "303446",
7
+ "base08": "d20f39", "base09": "fe640b", "base0A": "df8e1d", "base0B": "40a02b",
8
+ "base0C": "179299", "base0D": "1e66f5", "base0E": "8839ef", "base0F": "dd7878"
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "Catppuccin Mocha",
3
+ "author": "catppuccin",
4
+ "variant": "dark",
5
+ "base00": "1e1e2e", "base01": "181825", "base02": "313244", "base03": "45475a",
6
+ "base04": "585b70", "base05": "cdd6f4", "base06": "f5e0dc", "base07": "b4befe",
7
+ "base08": "f38ba8", "base09": "fab387", "base0A": "f9e2af", "base0B": "a6e3a1",
8
+ "base0C": "94e2d5", "base0D": "89b4fa", "base0E": "cba6f7", "base0F": "f2cdcd"
9
+ }