claude-relay 2.3.0 → 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 (54) hide show
  1. package/README.md +21 -5
  2. package/bin/cli.js +214 -9
  3. package/lib/cli-sessions.js +270 -0
  4. package/lib/config.js +3 -2
  5. package/lib/daemon.js +45 -1
  6. package/lib/pages.js +8 -1
  7. package/lib/project.js +121 -12
  8. package/lib/public/app.js +411 -87
  9. package/lib/public/css/base.css +41 -7
  10. package/lib/public/css/diff.css +6 -6
  11. package/lib/public/css/filebrowser.css +62 -52
  12. package/lib/public/css/highlight.css +144 -0
  13. package/lib/public/css/input.css +11 -9
  14. package/lib/public/css/menus.css +82 -23
  15. package/lib/public/css/messages.css +183 -35
  16. package/lib/public/css/overlays.css +166 -50
  17. package/lib/public/css/rewind.css +17 -17
  18. package/lib/public/css/sidebar.css +210 -137
  19. package/lib/public/index.html +75 -42
  20. package/lib/public/modules/filebrowser.js +2 -1
  21. package/lib/public/modules/markdown.js +10 -10
  22. package/lib/public/modules/notifications.js +38 -1
  23. package/lib/public/modules/sidebar.js +109 -31
  24. package/lib/public/modules/terminal.js +84 -23
  25. package/lib/public/modules/theme.js +622 -0
  26. package/lib/public/modules/tools.js +247 -4
  27. package/lib/public/modules/utils.js +21 -5
  28. package/lib/public/style.css +1 -0
  29. package/lib/sdk-bridge.js +95 -0
  30. package/lib/server.js +45 -3
  31. package/lib/sessions.js +16 -3
  32. package/lib/themes/ayu-light.json +9 -0
  33. package/lib/themes/catppuccin-latte.json +9 -0
  34. package/lib/themes/catppuccin-mocha.json +9 -0
  35. package/lib/themes/claude-light.json +9 -0
  36. package/lib/themes/claude.json +9 -0
  37. package/lib/themes/dracula.json +9 -0
  38. package/lib/themes/everforest-light.json +9 -0
  39. package/lib/themes/everforest.json +9 -0
  40. package/lib/themes/github-light.json +9 -0
  41. package/lib/themes/gruvbox-dark.json +9 -0
  42. package/lib/themes/gruvbox-light.json +9 -0
  43. package/lib/themes/monokai.json +9 -0
  44. package/lib/themes/nord-light.json +9 -0
  45. package/lib/themes/nord.json +9 -0
  46. package/lib/themes/one-dark.json +9 -0
  47. package/lib/themes/one-light.json +9 -0
  48. package/lib/themes/rose-pine-dawn.json +9 -0
  49. package/lib/themes/rose-pine.json +9 -0
  50. package/lib/themes/solarized-dark.json +9 -0
  51. package/lib/themes/solarized-light.json +9 -0
  52. package/lib/themes/tokyo-night-light.json +9 -0
  53. package/lib/themes/tokyo-night.json +9 -0
  54. 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;
@@ -258,8 +358,10 @@ function permissionInputSummary(toolName, input) {
258
358
  }
259
359
 
260
360
  export function renderPermissionRequest(requestId, toolName, toolInput, decisionReason) {
361
+ if (pendingPermissions[requestId]) return;
261
362
  ctx.finalizeAssistantBlock();
262
363
  stopThinking();
364
+ closeToolGroup();
263
365
 
264
366
  // ExitPlanMode: render as plan confirmation instead of generic permission
265
367
  if (toolName === "ExitPlanMode") {
@@ -353,6 +455,7 @@ export function renderPermissionRequest(requestId, toolName, toolInput, decision
353
455
  }
354
456
 
355
457
  function renderPlanPermission(requestId) {
458
+ if (pendingPermissions[requestId]) return;
356
459
  var container = document.createElement("div");
357
460
  container.className = "permission-container plan-permission";
358
461
  container.dataset.requestId = requestId;
@@ -467,6 +570,7 @@ export function markPermissionCancelled(requestId) {
467
570
  export function renderPlanBanner(type) {
468
571
  ctx.finalizeAssistantBlock();
469
572
  stopThinking();
573
+ closeToolGroup();
470
574
 
471
575
  var el = document.createElement("div");
472
576
  el.className = "plan-banner";
@@ -495,6 +599,7 @@ export function renderPlanBanner(type) {
495
599
 
496
600
  export function renderPlanCard(content) {
497
601
  ctx.finalizeAssistantBlock();
602
+ closeToolGroup();
498
603
 
499
604
  var el = document.createElement("div");
500
605
  el.className = "plan-card";
@@ -517,7 +622,7 @@ export function renderPlanCard(content) {
517
622
  if (copyBtn) {
518
623
  copyBtn.addEventListener("click", function (e) {
519
624
  e.stopPropagation();
520
- navigator.clipboard.writeText(content).then(function () {
625
+ copyToClipboard(content).then(function () {
521
626
  copyBtn.innerHTML = iconHtml("check");
522
627
  refreshIcons();
523
628
  setTimeout(function () {
@@ -783,6 +888,40 @@ export function createToolItem(id, name) {
783
888
  ctx.finalizeAssistantBlock();
784
889
  stopThinking();
785
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
+
786
925
  var el = document.createElement("div");
787
926
  el.className = "tool-item";
788
927
  el.dataset.toolId = id;
@@ -801,11 +940,16 @@ export function createToolItem(id, name) {
801
940
 
802
941
  el.querySelector(".tool-name").textContent = name;
803
942
 
804
- 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
+
805
949
  refreshIcons();
806
950
  ctx.scrollToBottom();
807
951
 
808
- tools[id] = { el: el, name: name, input: null, done: false };
952
+ tools[id] = { el: el, name: name, input: null, done: false, groupId: currentToolGroup.id };
809
953
  ctx.setActivity("Running " + name + "...");
810
954
  }
811
955
 
@@ -1093,6 +1237,16 @@ export function markToolDone(id, isError) {
1093
1237
  icon.innerHTML = '<span class="check">' + iconHtml("check") + '</span>';
1094
1238
  }
1095
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
+ }
1096
1250
  }
1097
1251
 
1098
1252
  export function markAllToolsDone() {
@@ -1103,7 +1257,71 @@ export function markAllToolsDone() {
1103
1257
  }
1104
1258
  }
1105
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
+
1106
1323
  export function addTurnMeta(cost, duration) {
1324
+ closeToolGroup();
1107
1325
  var div = document.createElement("div");
1108
1326
  div.className = "turn-meta";
1109
1327
  div.dataset.turn = ctx.turnCounter;
@@ -1117,6 +1335,22 @@ export function addTurnMeta(cost, duration) {
1117
1335
  }
1118
1336
  }
1119
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
+
1120
1354
  // Expose state getters and reset
1121
1355
  export function getTools() { return tools; }
1122
1356
  export function isInPlanMode() { return inPlanMode; }
@@ -1134,6 +1368,9 @@ export function saveToolState() {
1134
1368
  todoWidgetEl: todoWidgetEl,
1135
1369
  inPlanMode: inPlanMode,
1136
1370
  planContent: planContent,
1371
+ currentToolGroup: currentToolGroup,
1372
+ toolGroupCounter: toolGroupCounter,
1373
+ toolGroups: toolGroups,
1137
1374
  };
1138
1375
  }
1139
1376
 
@@ -1143,6 +1380,9 @@ export function restoreToolState(saved) {
1143
1380
  todoWidgetEl = saved.todoWidgetEl;
1144
1381
  inPlanMode = saved.inPlanMode;
1145
1382
  planContent = saved.planContent;
1383
+ currentToolGroup = saved.currentToolGroup;
1384
+ toolGroupCounter = saved.toolGroupCounter;
1385
+ toolGroups = saved.toolGroups;
1146
1386
  if (todoWidgetEl) {
1147
1387
  setupTodoObserver();
1148
1388
  }
@@ -1158,6 +1398,9 @@ export function resetToolState() {
1158
1398
  todoWidgetVisible = true;
1159
1399
  if (todoObserver) { todoObserver.disconnect(); todoObserver = null; }
1160
1400
  pendingPermissions = {};
1401
+ currentToolGroup = null;
1402
+ toolGroupCounter = 0;
1403
+ toolGroups = {};
1161
1404
  var stickyEl = document.getElementById("todo-sticky");
1162
1405
  if (stickyEl) { stickyEl.classList.add("hidden"); stickyEl.innerHTML = ""; }
1163
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)) {
@@ -250,7 +289,8 @@ function createServer(opts) {
250
289
  res.end(pinPage);
251
290
  return;
252
291
  }
253
- if (projects.size === 1) {
292
+ var hasGoneParam = req.url.indexOf("gone=") !== -1;
293
+ if (projects.size === 1 && !hasGoneParam) {
254
294
  var slug = projects.keys().next().value;
255
295
  res.writeHead(302, { "Location": "/p/" + slug + "/" });
256
296
  res.end();
@@ -297,8 +337,8 @@ function createServer(opts) {
297
337
 
298
338
  var ctx = projects.get(slug);
299
339
  if (!ctx) {
300
- res.writeHead(404);
301
- res.end("Project not found: " + slug);
340
+ res.writeHead(302, { "Location": "/?gone=" + encodeURIComponent(slug) });
341
+ res.end();
302
342
  return;
303
343
  }
304
344
 
@@ -471,6 +511,8 @@ function createServer(opts) {
471
511
  projects.forEach(function (ctx) { list.push(ctx.getStatus()); });
472
512
  return list;
473
513
  },
514
+ onAddProject: onAddProject,
515
+ onRemoveProject: onRemoveProject,
474
516
  });
475
517
  projects.set(slug, ctx);
476
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
+ }