claudeck 1.3.1 → 1.4.1

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 (60) hide show
  1. package/README.md +13 -9
  2. package/db/sqlite.js +1697 -0
  3. package/db.js +3 -1645
  4. package/package.json +2 -1
  5. package/plugins/claude-editor/manifest.json +10 -0
  6. package/plugins/linear/manifest.json +10 -0
  7. package/plugins/repos/manifest.json +10 -0
  8. package/public/css/ui/messages.css +25 -0
  9. package/public/css/ui/right-panel.css +207 -0
  10. package/public/css/ui/settings.css +75 -0
  11. package/public/index.html +7 -0
  12. package/public/js/components/settings-modal.js +65 -0
  13. package/public/js/core/api.js +23 -6
  14. package/public/js/core/events.js +11 -0
  15. package/public/js/core/plugin-loader.js +96 -11
  16. package/public/js/core/store.js +11 -0
  17. package/public/js/core/ws.js +12 -0
  18. package/public/js/features/chat.js +4 -0
  19. package/public/js/features/sessions.js +102 -10
  20. package/public/js/main.js +1 -0
  21. package/public/js/panels/assistant-bot.js +16 -0
  22. package/public/js/panels/dev-docs.js +2 -2
  23. package/public/js/panels/memory.js +1 -0
  24. package/public/js/ui/context-gauge.js +10 -1
  25. package/public/js/ui/header-dropdowns.js +30 -0
  26. package/public/js/ui/input-meta.js +13 -6
  27. package/public/js/ui/max-turns.js +6 -3
  28. package/public/js/ui/messages.js +42 -0
  29. package/public/js/ui/model-selector.js +1 -0
  30. package/public/js/ui/parallel.js +2 -4
  31. package/public/js/ui/permissions.js +1 -0
  32. package/public/js/ui/tab-sdk.js +395 -176
  33. package/public/style.css +1 -0
  34. package/server/agent-loop.js +26 -26
  35. package/server/memory-extractor.js +4 -4
  36. package/server/memory-injector.js +11 -11
  37. package/server/memory-optimizer.js +19 -15
  38. package/server/notification-logger.js +5 -5
  39. package/server/orchestrator.js +15 -15
  40. package/server/push-sender.js +2 -2
  41. package/server/routes/agents.js +2 -2
  42. package/server/routes/marketplace.js +316 -0
  43. package/server/routes/memory.js +20 -20
  44. package/server/routes/messages.js +41 -10
  45. package/server/routes/notifications.js +20 -20
  46. package/server/routes/sessions.js +17 -17
  47. package/server/routes/stats.js +37 -37
  48. package/server/routes/worktrees.js +9 -9
  49. package/server/summarizer.js +3 -3
  50. package/server/ws-handler.js +163 -58
  51. package/server.js +20 -2
  52. package/plugins/event-stream/client.css +0 -207
  53. package/plugins/event-stream/client.js +0 -271
  54. package/plugins/sudoku/client.css +0 -196
  55. package/plugins/sudoku/client.js +0 -329
  56. package/plugins/tasks/client.css +0 -414
  57. package/plugins/tasks/client.js +0 -394
  58. package/plugins/tasks/server.js +0 -116
  59. package/plugins/tic-tac-toe/client.css +0 -167
  60. package/plugins/tic-tac-toe/client.js +0 -241
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeck",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "description": "A browser-based UI for Claude Code — chat, run workflows, manage MCP servers, track costs, and orchestrate autonomous agents from a local web interface. Installable as a PWA.",
6
6
  "main": "server.js",
@@ -11,6 +11,7 @@
11
11
  "cli.js",
12
12
  "server.js",
13
13
  "db.js",
14
+ "db/",
14
15
  "server/",
15
16
  "public/",
16
17
  "config/",
@@ -0,0 +1,10 @@
1
+ {
2
+ "id": "claude-editor",
3
+ "name": "CLAUDE.md Editor",
4
+ "version": "1.0.0",
5
+ "description": "Edit CLAUDE.md project instructions directly in the UI",
6
+ "author": "claudeck",
7
+ "icon": "📝",
8
+ "hasServer": false,
9
+ "minClaudeckVersion": "1.4.0"
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "id": "linear",
3
+ "name": "Linear",
4
+ "version": "1.0.0",
5
+ "description": "Linear issue tracking with settings and team management",
6
+ "author": "claudeck",
7
+ "icon": "📋",
8
+ "hasServer": true,
9
+ "minClaudeckVersion": "1.4.0"
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "id": "repos",
3
+ "name": "Repos",
4
+ "version": "1.0.0",
5
+ "description": "Git repository and group management with tree view",
6
+ "author": "claudeck",
7
+ "icon": "📁",
8
+ "hasServer": true,
9
+ "minClaudeckVersion": "1.4.0"
10
+ }
@@ -25,6 +25,31 @@
25
25
  gap: 18px;
26
26
  }
27
27
 
28
+ /* ── Load-more indicator (lazy loading) ─────────────── */
29
+ .load-more-indicator {
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ gap: 8px;
34
+ padding: 10px 0;
35
+ color: var(--text-muted);
36
+ font-size: 0.8rem;
37
+ }
38
+
39
+ .load-more-spinner {
40
+ display: inline-block;
41
+ width: 14px;
42
+ height: 14px;
43
+ border: 2px solid var(--border);
44
+ border-top-color: var(--accent);
45
+ border-radius: 50%;
46
+ animation: load-more-spin 0.6s linear infinite;
47
+ }
48
+
49
+ @keyframes load-more-spin {
50
+ to { transform: rotate(360deg); }
51
+ }
52
+
28
53
  .messages:empty::after {
29
54
  content: "";
30
55
  display: none;
@@ -486,3 +486,210 @@
486
486
  filter: brightness(1.1);
487
487
  box-shadow: var(--glow-strong);
488
488
  }
489
+
490
+ /* ── Marketplace tabs ── */
491
+
492
+ .marketplace-tabs {
493
+ display: flex;
494
+ gap: 4px;
495
+ margin-top: 12px;
496
+ }
497
+
498
+ .marketplace-tab {
499
+ padding: 6px 16px;
500
+ border-radius: var(--radius-md);
501
+ font-size: 12px;
502
+ font-weight: 500;
503
+ font-family: var(--font-sans);
504
+ cursor: pointer;
505
+ transition: all 0.2s var(--ease-smooth);
506
+ border: 1px solid transparent;
507
+ background: none;
508
+ color: var(--text-dim);
509
+ }
510
+
511
+ .marketplace-tab:hover {
512
+ color: var(--text);
513
+ background: var(--bg-tertiary);
514
+ }
515
+
516
+ .marketplace-tab.active {
517
+ color: var(--accent);
518
+ background: var(--accent-dim);
519
+ border-color: var(--accent);
520
+ }
521
+
522
+ .marketplace-tab-content {
523
+ flex: 1;
524
+ overflow-y: auto;
525
+ display: flex;
526
+ flex-direction: column;
527
+ }
528
+
529
+ .marketplace-tab-content::-webkit-scrollbar {
530
+ width: 4px;
531
+ }
532
+ .marketplace-tab-content::-webkit-scrollbar-thumb {
533
+ background: var(--border);
534
+ border-radius: 2px;
535
+ }
536
+
537
+ .marketplace-tab-content > .marketplace-subtitle {
538
+ padding: 12px 16px 4px;
539
+ }
540
+
541
+ .marketplace-tab-content > .marketplace-list {
542
+ flex: 1;
543
+ }
544
+
545
+ .marketplace-tab-content > .marketplace-empty {
546
+ flex: 1;
547
+ display: flex;
548
+ flex-direction: column;
549
+ align-items: center;
550
+ justify-content: center;
551
+ }
552
+
553
+ .marketplace-tab-content > .marketplace-empty a {
554
+ color: var(--accent);
555
+ text-decoration: none;
556
+ margin-top: 8px;
557
+ }
558
+
559
+ .marketplace-tab-content > .marketplace-empty a:hover {
560
+ text-decoration: underline;
561
+ }
562
+
563
+ /* ── Community plugin items ── */
564
+
565
+ .marketplace-community-item {
566
+ cursor: default;
567
+ padding: 12px;
568
+ }
569
+
570
+ .marketplace-item-meta {
571
+ display: flex;
572
+ gap: 8px;
573
+ margin-top: 4px;
574
+ font-size: 10px;
575
+ color: var(--text-dim);
576
+ font-family: var(--font-mono);
577
+ }
578
+
579
+ .marketplace-author {
580
+ opacity: 0.7;
581
+ }
582
+
583
+ .marketplace-version {
584
+ opacity: 0.5;
585
+ }
586
+
587
+ .marketplace-version-old {
588
+ opacity: 0.4;
589
+ text-decoration: line-through;
590
+ }
591
+
592
+ .marketplace-source {
593
+ font-size: 9px;
594
+ font-family: var(--font-display);
595
+ padding: 1px 6px;
596
+ border-radius: 4px;
597
+ text-transform: uppercase;
598
+ letter-spacing: 0.06em;
599
+ vertical-align: middle;
600
+ }
601
+
602
+ .marketplace-source.community {
603
+ color: var(--purple);
604
+ background: color-mix(in srgb, var(--purple) 15%, transparent);
605
+ border: 1px solid color-mix(in srgb, var(--purple) 30%, transparent);
606
+ }
607
+
608
+ .marketplace-source.server {
609
+ color: var(--orange, #f0a040);
610
+ background: color-mix(in srgb, var(--orange, #f0a040) 15%, transparent);
611
+ border: 1px solid color-mix(in srgb, var(--orange, #f0a040) 30%, transparent);
612
+ }
613
+
614
+ /* ── Action buttons (install / uninstall / update) ── */
615
+
616
+ .marketplace-item-actions {
617
+ flex-shrink: 0;
618
+ }
619
+
620
+ .marketplace-action-btn {
621
+ padding: 5px 14px;
622
+ border-radius: var(--radius-md);
623
+ font-size: 11px;
624
+ font-weight: 500;
625
+ font-family: var(--font-sans);
626
+ cursor: pointer;
627
+ transition: all 0.2s var(--ease-smooth);
628
+ border: 1px solid var(--border);
629
+ white-space: nowrap;
630
+ }
631
+
632
+ .marketplace-action-btn:disabled {
633
+ opacity: 0.5;
634
+ cursor: not-allowed;
635
+ }
636
+
637
+ .marketplace-install-btn {
638
+ background: var(--accent);
639
+ color: #fff;
640
+ border-color: var(--accent);
641
+ }
642
+
643
+ .marketplace-install-btn:hover:not(:disabled) {
644
+ filter: brightness(1.1);
645
+ box-shadow: var(--glow);
646
+ }
647
+
648
+ .marketplace-uninstall-btn {
649
+ background: none;
650
+ color: var(--text-dim);
651
+ }
652
+
653
+ .marketplace-uninstall-btn:hover:not(:disabled) {
654
+ color: var(--red, #e54);
655
+ border-color: var(--red, #e54);
656
+ background: color-mix(in srgb, var(--red, #e54) 10%, transparent);
657
+ }
658
+
659
+ .marketplace-update-btn {
660
+ background: var(--purple);
661
+ color: #fff;
662
+ border-color: var(--purple);
663
+ }
664
+
665
+ .marketplace-update-btn:hover:not(:disabled) {
666
+ filter: brightness(1.1);
667
+ box-shadow: 0 0 8px color-mix(in srgb, var(--purple) 40%, transparent);
668
+ }
669
+
670
+ /* ── Loading state ── */
671
+
672
+ .marketplace-loading {
673
+ display: flex;
674
+ flex-direction: column;
675
+ align-items: center;
676
+ justify-content: center;
677
+ gap: 12px;
678
+ padding: 48px 16px;
679
+ color: var(--text-dim);
680
+ font-size: 12px;
681
+ font-family: var(--font-sans);
682
+ }
683
+
684
+ .marketplace-spinner {
685
+ width: 24px;
686
+ height: 24px;
687
+ border: 2px solid var(--border);
688
+ border-top-color: var(--accent);
689
+ border-radius: 50%;
690
+ animation: spin 0.8s linear infinite;
691
+ }
692
+
693
+ @keyframes spin {
694
+ to { transform: rotate(360deg); }
695
+ }
@@ -0,0 +1,75 @@
1
+ .settings-modal {
2
+ width: 380px;
3
+ max-width: 90vw;
4
+ }
5
+
6
+ .settings-list {
7
+ padding: 16px;
8
+ }
9
+
10
+ .settings-row {
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: space-between;
14
+ gap: 12px;
15
+ padding: 10px 0;
16
+ cursor: pointer;
17
+ }
18
+
19
+ .settings-row + .settings-row {
20
+ border-top: 1px solid var(--border);
21
+ }
22
+
23
+ .settings-label {
24
+ display: flex;
25
+ flex-direction: column;
26
+ gap: 2px;
27
+ }
28
+
29
+ .settings-label strong {
30
+ font-size: 13px;
31
+ color: var(--text-primary);
32
+ }
33
+
34
+ .settings-label small {
35
+ font-size: 11px;
36
+ color: var(--text-secondary);
37
+ }
38
+
39
+ .settings-toggle {
40
+ appearance: none;
41
+ width: 36px;
42
+ height: 20px;
43
+ background: var(--bg-tertiary);
44
+ border-radius: 10px;
45
+ position: relative;
46
+ cursor: pointer;
47
+ flex-shrink: 0;
48
+ transition: background 0.2s;
49
+ }
50
+
51
+ .settings-toggle:focus-visible {
52
+ outline: 2px solid var(--accent);
53
+ outline-offset: 2px;
54
+ }
55
+
56
+ .settings-toggle::after {
57
+ content: '';
58
+ position: absolute;
59
+ top: 2px;
60
+ left: 2px;
61
+ width: 16px;
62
+ height: 16px;
63
+ border-radius: 50%;
64
+ background: var(--text-secondary);
65
+ transition: transform 0.2s, background 0.2s;
66
+ }
67
+
68
+ .settings-toggle:checked {
69
+ background: var(--accent);
70
+ }
71
+
72
+ .settings-toggle:checked::after {
73
+ transform: translateX(16px);
74
+ background: #fff;
75
+ }
package/public/index.html CHANGED
@@ -166,6 +166,12 @@
166
166
  <span id="telegram-label">Telegram</span>
167
167
  </button>
168
168
  <div style="height:1px;background:var(--border);margin:4px 8px;"></div>
169
+ <button class="header-dropdown-item" id="settings-btn" title="Settings">
170
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
171
+ <line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>
172
+ </svg>
173
+ <span>Settings</span>
174
+ </button>
169
175
  <button class="header-dropdown-item" id="dev-docs-btn" title="Developer Documentation">
170
176
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
171
177
  <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
@@ -519,6 +525,7 @@
519
525
  <claudeck-linear-create></claudeck-linear-create>
520
526
  <claudeck-telegram-modal></claudeck-telegram-modal>
521
527
  <claudeck-mcp-modal></claudeck-mcp-modal>
528
+ <claudeck-settings-modal></claudeck-settings-modal>
522
529
  <claudeck-add-project></claudeck-add-project>
523
530
 
524
531
  <!-- Status Bar (Web Component) -->
@@ -0,0 +1,65 @@
1
+ // Web Component: Settings Modal
2
+ const SETTINGS_KEY = 'claudeck-settings';
3
+
4
+ function loadSettings() {
5
+ try { return JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}'); } catch { return {}; }
6
+ }
7
+
8
+ function saveSettings(s) {
9
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(s));
10
+ }
11
+
12
+ export function getSetting(key, fallback = true) {
13
+ const s = loadSettings();
14
+ return s[key] !== undefined ? s[key] : fallback;
15
+ }
16
+
17
+ class ClaudeckSettingsModal extends HTMLElement {
18
+ connectedCallback() {
19
+ this.innerHTML = `
20
+ <div id="settings-modal" class="modal-overlay hidden">
21
+ <div class="modal settings-modal">
22
+ <div class="modal-header">
23
+ <h3>Settings</h3>
24
+ <button id="settings-modal-close" class="modal-close">&times;</button>
25
+ </div>
26
+ <div class="settings-list">
27
+ <label class="settings-row">
28
+ <span class="settings-label">
29
+ <strong>Assistant Bot</strong>
30
+ <small>Show the floating assistant bot bubble</small>
31
+ </span>
32
+ <input type="checkbox" id="setting-assistant-bot" class="settings-toggle">
33
+ </label>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ `;
38
+
39
+ const overlay = this.querySelector('#settings-modal');
40
+ const closeBtn = this.querySelector('#settings-modal-close');
41
+ const botToggle = this.querySelector('#setting-assistant-bot');
42
+
43
+ // Init toggle state
44
+ botToggle.checked = getSetting('assistantBot', true);
45
+
46
+ closeBtn.addEventListener('click', () => overlay.classList.add('hidden'));
47
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.add('hidden'); });
48
+
49
+ botToggle.addEventListener('change', () => {
50
+ const s = loadSettings();
51
+ s.assistantBot = botToggle.checked;
52
+ saveSettings(s);
53
+ // Dispatch event so the bot module can react
54
+ window.dispatchEvent(new CustomEvent('setting:assistantBot', { detail: botToggle.checked }));
55
+ });
56
+
57
+ // Open from settings button
58
+ document.getElementById('settings-btn')?.addEventListener('click', () => {
59
+ botToggle.checked = getSetting('assistantBot', true);
60
+ overlay.classList.remove('hidden');
61
+ });
62
+ }
63
+ }
64
+
65
+ customElements.define('claudeck-settings-modal', ClaudeckSettingsModal);
@@ -36,18 +36,35 @@ export async function fetchActiveSessionIds() {
36
36
  return data.activeSessionIds || [];
37
37
  }
38
38
 
39
- export async function fetchMessages(sessionId) {
40
- const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages`);
39
+ function _appendPaginationParams(url, { limit, before } = {}) {
40
+ const params = new URLSearchParams();
41
+ if (limit) params.set("limit", limit);
42
+ if (before) params.set("before", before);
43
+ const qs = params.toString();
44
+ return qs ? `${url}?${qs}` : url;
45
+ }
46
+
47
+ export async function fetchMessages(sessionId, opts) {
48
+ const url = _appendPaginationParams(
49
+ `/api/sessions/${encodeURIComponent(sessionId)}/messages`, opts
50
+ );
51
+ const res = await fetch(url);
41
52
  return res.json();
42
53
  }
43
54
 
44
- export async function fetchMessagesByChatId(sessionId, chatId) {
45
- const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(chatId)}`);
55
+ export async function fetchMessagesByChatId(sessionId, chatId, opts) {
56
+ const url = _appendPaginationParams(
57
+ `/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(chatId)}`, opts
58
+ );
59
+ const res = await fetch(url);
46
60
  return res.json();
47
61
  }
48
62
 
49
- export async function fetchSingleMessages(sessionId) {
50
- const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages-single`);
63
+ export async function fetchSingleMessages(sessionId, opts) {
64
+ const url = _appendPaginationParams(
65
+ `/api/sessions/${encodeURIComponent(sessionId)}/messages-single`, opts
66
+ );
67
+ const res = await fetch(url);
51
68
  return res.json();
52
69
  }
53
70
 
@@ -5,6 +5,17 @@ export function emit(event, data) {
5
5
  (bus[event] || []).forEach((fn) => fn(data));
6
6
  }
7
7
 
8
+ /** Subscribe to an event. Returns an unsubscribe function. */
8
9
  export function on(event, fn) {
9
10
  (bus[event] ||= []).push(fn);
11
+ return () => {
12
+ const arr = bus[event];
13
+ if (arr) bus[event] = arr.filter(f => f !== fn);
14
+ };
15
+ }
16
+
17
+ /** Remove a specific listener for an event. */
18
+ export function off(event, fn) {
19
+ const arr = bus[event];
20
+ if (arr) bus[event] = arr.filter(f => f !== fn);
10
21
  }
@@ -12,21 +12,13 @@
12
12
  const STORAGE_KEY = 'claudeck-enabled-plugins';
13
13
  const ORDER_KEY = 'claudeck-plugin-order';
14
14
  let availablePlugins = [];
15
+ let marketplaceRegistry = null;
15
16
  const loadedPlugins = new Set();
16
17
 
17
18
  /** Maps plugin file name → tab ID registered by that plugin */
18
19
  const pluginTabIds = new Map();
19
20
 
20
- /** Plugin descriptions for the marketplace. order: lower = higher in the list */
21
- const pluginMeta = {
22
- 'claude-editor': { description: 'Edit CLAUDE.md project instructions directly in the UI', icon: '📝', order: 5 },
23
- 'event-stream': { description: 'Real-time WebSocket event viewer with filtering and search', icon: '⚡', order: 10 },
24
- 'repos': { description: 'Git repository and group management with tree view', icon: '📁', order: 20 },
25
- 'linear': { description: 'Linear issue tracking with settings and team management', icon: '📋', order: 25 },
26
- 'tasks': { description: 'Todo list with priority levels and brag tracking', icon: '✅', order: 30 },
27
- 'tic-tac-toe': { description: 'Classic tic-tac-toe game', icon: '🎮', order: 90 },
28
- 'sudoku': { description: 'Sudoku puzzle game', icon: '🧩', order: 91 },
29
- };
21
+ /** Fallback meta for plugins without manifest.json */
30
22
  const defaultMeta = { description: 'A tab-sdk plugin', icon: '🧩', order: 100 };
31
23
 
32
24
  export function getAvailablePlugins() {
@@ -45,7 +37,15 @@ export function setEnabledPluginNames(names) {
45
37
  }
46
38
 
47
39
  export function getPluginMeta(name) {
48
- return pluginMeta[name] || defaultMeta;
40
+ const plugin = availablePlugins.find(p => p.name === name);
41
+ if (plugin?.manifest) {
42
+ return {
43
+ description: plugin.manifest.description || defaultMeta.description,
44
+ icon: plugin.manifest.icon || defaultMeta.icon,
45
+ order: defaultMeta.order,
46
+ };
47
+ }
48
+ return defaultMeta;
49
49
  }
50
50
 
51
51
  export function getPluginOrder() {
@@ -151,3 +151,88 @@ export async function loadPlugins() {
151
151
  console.error('Plugin loader error:', err);
152
152
  }
153
153
  }
154
+
155
+ // ── Marketplace ─────────────────────────────────────────
156
+
157
+ export function getMarketplaceRegistry() {
158
+ return marketplaceRegistry;
159
+ }
160
+
161
+ /** Fetch the community plugin registry from the server (which proxies GitHub) */
162
+ export async function fetchMarketplace(refresh = false) {
163
+ try {
164
+ const url = refresh ? '/api/marketplace?refresh=true' : '/api/marketplace';
165
+ const res = await fetch(url);
166
+ if (!res.ok) { console.warn('Marketplace fetch failed:', res.status); return null; }
167
+ marketplaceRegistry = await res.json();
168
+ return marketplaceRegistry;
169
+ } catch (err) {
170
+ console.error('Marketplace error:', err);
171
+ return null;
172
+ }
173
+ }
174
+
175
+ /** Install a community plugin and auto-enable it */
176
+ export async function installMarketplacePlugin(plugin) {
177
+ const res = await fetch('/api/marketplace/install', {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({ id: plugin.id, repo: plugin.repo, source: plugin.source }),
181
+ });
182
+ if (!res.ok) {
183
+ const err = await res.json().catch(() => ({ error: 'Install failed' }));
184
+ throw new Error(err.error);
185
+ }
186
+ const result = await res.json();
187
+
188
+ // Refresh local plugins list to include the newly installed plugin
189
+ const pluginsRes = await fetch('/api/plugins');
190
+ if (pluginsRes.ok) {
191
+ availablePlugins = await pluginsRes.json();
192
+ }
193
+
194
+ // Auto-enable the newly installed plugin
195
+ const enabled = getEnabledPluginNames();
196
+ if (!enabled.includes(plugin.id)) {
197
+ enabled.push(plugin.id);
198
+ setEnabledPluginNames(enabled);
199
+ }
200
+
201
+ // Load the plugin immediately
202
+ await loadPluginByName(plugin.id);
203
+
204
+ return result;
205
+ }
206
+
207
+ /** Uninstall a community plugin */
208
+ export async function uninstallMarketplacePlugin(id) {
209
+ const res = await fetch('/api/marketplace/uninstall', {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json' },
212
+ body: JSON.stringify({ id }),
213
+ });
214
+ if (!res.ok) {
215
+ const err = await res.json().catch(() => ({ error: 'Uninstall failed' }));
216
+ throw new Error(err.error);
217
+ }
218
+
219
+ // Remove from enabled list
220
+ const enabled = getEnabledPluginNames().filter(n => n !== id);
221
+ setEnabledPluginNames(enabled);
222
+
223
+ // Clean up CSS link from DOM
224
+ const cssLink = document.head.querySelector(`link[data-plugin="${id}"]`);
225
+ if (cssLink) cssLink.remove();
226
+
227
+ // Clear loaded/tab tracking state
228
+ loadedPlugins.delete(id);
229
+ pluginTabIds.delete(id);
230
+
231
+ // Refresh local plugins list
232
+ const pluginsRes = await fetch('/api/plugins');
233
+ if (pluginsRes.ok) {
234
+ availablePlugins = await pluginsRes.json();
235
+ }
236
+
237
+ return await res.json();
238
+ }
@@ -30,8 +30,19 @@ export function setState(key, val) {
30
30
  emit(key, val);
31
31
  }
32
32
 
33
+ /** Subscribe to state changes for a key. Returns an unsubscribe function. */
33
34
  export function on(key, fn) {
34
35
  (listeners[key] ||= []).push(fn);
36
+ return () => {
37
+ const arr = listeners[key];
38
+ if (arr) listeners[key] = arr.filter(f => f !== fn);
39
+ };
40
+ }
41
+
42
+ /** Remove a specific listener for a key. */
43
+ export function off(key, fn) {
44
+ const arr = listeners[key];
45
+ if (arr) listeners[key] = arr.filter(f => f !== fn);
35
46
  }
36
47
 
37
48
  function emit(key, val) {
@@ -3,6 +3,12 @@ import { $ } from './dom.js';
3
3
  import { getState, setState } from './store.js';
4
4
  import { emit } from './events.js';
5
5
 
6
+ export function subscribeToSession(sessionId) {
7
+ const ws = getState("ws");
8
+ if (!ws || ws.readyState !== 1 || !sessionId) return;
9
+ ws.send(JSON.stringify({ type: "subscribe", sessionId }));
10
+ }
11
+
6
12
  let backoffAttempt = 0;
7
13
  let hasConnectedBefore = false;
8
14
 
@@ -37,6 +43,12 @@ export function connectWebSocket() {
37
43
  hasConnectedBefore = true;
38
44
  emit("ws:connected");
39
45
  }
46
+
47
+ // Subscribe to current session for multi-client broadcast
48
+ const currentSession = getState("sessionId");
49
+ if (currentSession) {
50
+ ws.send(JSON.stringify({ type: "subscribe", sessionId: currentSession }));
51
+ }
40
52
  };
41
53
 
42
54
  ws.onmessage = (event) => {