shell-mirror 1.5.55 → 1.5.56

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shell-mirror",
3
- "version": "1.5.55",
3
+ "version": "1.5.56",
4
4
  "description": "Access your Mac shell from any device securely. Perfect for mobile coding with Claude Code CLI, Gemini CLI, and any shell tool.",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -42,12 +42,28 @@ body {
42
42
  opacity: 0.8;
43
43
  }
44
44
 
45
+ .header-right {
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 20px;
49
+ }
50
+
45
51
  .user-section {
46
52
  display: flex;
47
53
  align-items: center;
48
54
  gap: 15px;
49
55
  }
50
56
 
57
+ .dashboard-controls {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 12px;
61
+ padding: 8px 16px;
62
+ background: rgba(255, 255, 255, 0.15);
63
+ border-radius: 8px;
64
+ backdrop-filter: blur(10px);
65
+ }
66
+
51
67
  .user-info {
52
68
  display: flex;
53
69
  align-items: center;
@@ -106,6 +122,64 @@ body {
106
122
  display: block;
107
123
  }
108
124
 
125
+ /* Dashboard Controls */
126
+ .refresh-btn {
127
+ background: rgba(255, 255, 255, 0.2);
128
+ border: 1px solid rgba(255, 255, 255, 0.3);
129
+ border-radius: 50%;
130
+ color: white;
131
+ width: 36px;
132
+ height: 36px;
133
+ cursor: pointer;
134
+ transition: all 0.2s ease;
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ font-size: 1rem;
139
+ }
140
+
141
+ .refresh-btn:hover:not(:disabled) {
142
+ background: rgba(255, 255, 255, 0.3);
143
+ transform: scale(1.05);
144
+ }
145
+
146
+ .refresh-btn.loading {
147
+ opacity: 0.7;
148
+ cursor: not-allowed;
149
+ }
150
+
151
+ .refresh-btn.loading .refresh-icon {
152
+ animation: spin 1s linear infinite;
153
+ }
154
+
155
+ .connection-status {
156
+ font-size: 0.8rem;
157
+ font-weight: 500;
158
+ white-space: nowrap;
159
+ padding: 4px 8px;
160
+ border-radius: 12px;
161
+ background: rgba(255, 255, 255, 0.1);
162
+ }
163
+
164
+ .connection-status.connected {
165
+ color: #4caf50;
166
+ }
167
+
168
+ .connection-status.disconnected {
169
+ color: #ff9800;
170
+ }
171
+
172
+ .connection-status.error,
173
+ .connection-status.failed {
174
+ color: #f44336;
175
+ }
176
+
177
+ .refresh-status {
178
+ font-size: 0.75rem;
179
+ opacity: 0.8;
180
+ white-space: nowrap;
181
+ }
182
+
109
183
  /* Buttons */
110
184
  .btn-primary {
111
185
  background: #4285F4;
@@ -524,6 +598,28 @@ body {
524
598
  text-align: center;
525
599
  }
526
600
 
601
+ .header-right {
602
+ flex-direction: column;
603
+ gap: 10px;
604
+ width: 100%;
605
+ }
606
+
607
+ .dashboard-controls {
608
+ justify-content: center;
609
+ flex-wrap: wrap;
610
+ }
611
+
612
+ .refresh-status,
613
+ .connection-status {
614
+ font-size: 0.7rem;
615
+ }
616
+
617
+ .agent-notification {
618
+ right: 10px;
619
+ left: 10px;
620
+ text-align: center;
621
+ }
622
+
527
623
  .dashboard-main {
528
624
  padding: 20px 15px;
529
625
  }
@@ -694,4 +790,47 @@ body {
694
790
  border-top: 1px solid #eee;
695
791
  padding-top: 16px;
696
792
  text-align: center;
793
+ }
794
+
795
+ /* Agent Notifications */
796
+ .agent-notification {
797
+ position: fixed;
798
+ top: 100px;
799
+ right: 20px;
800
+ background: white;
801
+ color: #333;
802
+ padding: 12px 20px;
803
+ border-radius: 8px;
804
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
805
+ font-size: 0.9rem;
806
+ font-weight: 500;
807
+ z-index: 3000;
808
+ animation: slideInRight 0.3s ease-out, fadeOut 0.3s ease-in 4.7s forwards;
809
+ border-left: 4px solid;
810
+ }
811
+
812
+ .agent-notification.connected {
813
+ border-left-color: #4caf50;
814
+ }
815
+
816
+ .agent-notification.disconnected {
817
+ border-left-color: #f44336;
818
+ }
819
+
820
+ @keyframes slideInRight {
821
+ from {
822
+ transform: translateX(100%);
823
+ opacity: 0;
824
+ }
825
+ to {
826
+ transform: translateX(0);
827
+ opacity: 1;
828
+ }
829
+ }
830
+
831
+ @keyframes fadeOut {
832
+ to {
833
+ opacity: 0;
834
+ transform: translateX(100%);
835
+ }
697
836
  }
@@ -61,8 +61,10 @@
61
61
  <h1>Shell Mirror</h1>
62
62
  <span class="subtitle">Dashboard</span>
63
63
  </div>
64
- <div class="user-section" id="user-section">
65
- <!-- Dynamic content based on auth status -->
64
+ <div class="header-right">
65
+ <div class="user-section" id="user-section">
66
+ <!-- Dynamic content based on auth status -->
67
+ </div>
66
68
  </div>
67
69
  </div>
68
70
  </header>
@@ -6,6 +6,12 @@ class ShellMirrorDashboard {
6
6
  this.agents = [];
7
7
  this.sessions = [];
8
8
  this.agentSessions = {}; // Maps agentId to sessions array
9
+ this.websocket = null;
10
+ this.reconnectAttempts = 0;
11
+ this.maxReconnectAttempts = 10;
12
+ this.refreshInterval = null;
13
+ this.lastRefresh = null;
14
+ this.isRefreshing = false;
9
15
  this.init();
10
16
  }
11
17
 
@@ -38,6 +44,7 @@ class ShellMirrorDashboard {
38
44
  this.user = authStatus.user;
39
45
  await this.loadDashboardData();
40
46
  this.renderAuthenticatedDashboard();
47
+ this.setupWebSocket(); // Setup real-time connection
41
48
  this.startAutoRefresh(); // Start auto-refresh for authenticated users
42
49
  } else {
43
50
  this.renderUnauthenticatedDashboard();
@@ -51,14 +58,216 @@ class ShellMirrorDashboard {
51
58
  }
52
59
 
53
60
  startAutoRefresh() {
54
- // Refresh agent data every 30 seconds to detect disconnected agents
61
+ // Clear any existing interval
62
+ if (this.refreshInterval) {
63
+ clearInterval(this.refreshInterval);
64
+ }
65
+
66
+ // Refresh agent data every 10 seconds (reduced from 30s)
55
67
  this.refreshInterval = setInterval(async () => {
56
- if (this.isAuthenticated) {
57
- await this.loadDashboardData();
58
- // Only re-render the agents section to avoid full page flash
59
- this.updateAgentsDisplay();
68
+ if (this.isAuthenticated && !this.isRefreshing) {
69
+ await this.refreshDashboardData();
70
+ }
71
+ }, 10000);
72
+ }
73
+
74
+ async refreshDashboardData() {
75
+ this.isRefreshing = true;
76
+ try {
77
+ await this.loadDashboardData();
78
+ this.updateAgentsDisplay();
79
+ this.updateLastRefreshTime();
80
+ } catch (error) {
81
+ console.error('Auto-refresh failed:', error);
82
+ // Implement exponential backoff on failure
83
+ clearInterval(this.refreshInterval);
84
+ const backoffDelay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
85
+ setTimeout(() => {
86
+ if (this.isAuthenticated) {
87
+ this.startAutoRefresh();
88
+ this.reconnectAttempts++;
89
+ }
90
+ }, backoffDelay);
91
+ } finally {
92
+ this.isRefreshing = false;
93
+ }
94
+ }
95
+
96
+ updateLastRefreshTime() {
97
+ this.lastRefresh = Date.now();
98
+ const refreshStatus = document.getElementById('refresh-status');
99
+ if (refreshStatus) {
100
+ refreshStatus.textContent = `Last updated: ${new Date(this.lastRefresh).toLocaleTimeString()}`;
101
+ }
102
+ }
103
+
104
+ setupWebSocket() {
105
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
106
+ const wsUrl = `${protocol}//${window.location.host}/?role=dashboard`;
107
+
108
+ console.log('[DASHBOARD] 🔌 Connecting to WebSocket:', wsUrl);
109
+
110
+ try {
111
+ this.websocket = new WebSocket(wsUrl);
112
+
113
+ this.websocket.onopen = () => {
114
+ console.log('[DASHBOARD] ✅ WebSocket connected');
115
+ this.reconnectAttempts = 0;
116
+ this.updateConnectionStatus('connected');
117
+ };
118
+
119
+ this.websocket.onmessage = (event) => {
120
+ this.handleWebSocketMessage(event.data);
121
+ };
122
+
123
+ this.websocket.onclose = (event) => {
124
+ console.log('[DASHBOARD] 🔌 WebSocket closed:', event.code, event.reason);
125
+ this.updateConnectionStatus('disconnected');
126
+ this.attemptReconnect();
127
+ };
128
+
129
+ this.websocket.onerror = (error) => {
130
+ console.error('[DASHBOARD] ❌ WebSocket error:', error);
131
+ this.updateConnectionStatus('error');
132
+ };
133
+
134
+ // Send periodic ping to keep connection alive
135
+ setInterval(() => {
136
+ if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
137
+ this.websocket.send(JSON.stringify({ type: 'ping' }));
138
+ }
139
+ }, 30000);
140
+
141
+ } catch (error) {
142
+ console.error('[DASHBOARD] ❌ Failed to setup WebSocket:', error);
143
+ this.updateConnectionStatus('error');
144
+ }
145
+ }
146
+
147
+ handleWebSocketMessage(data) {
148
+ try {
149
+ const message = JSON.parse(data);
150
+ console.log('[DASHBOARD] 📨 WebSocket message:', message);
151
+
152
+ switch (message.type) {
153
+ case 'agent-list':
154
+ // Initial agent list or refresh
155
+ this.handleAgentListUpdate(message.agents);
156
+ break;
157
+ case 'agent-connected':
158
+ this.handleAgentConnected(message.agentId);
159
+ break;
160
+ case 'agent-disconnected':
161
+ this.handleAgentDisconnected(message.agentId);
162
+ break;
163
+ case 'pong':
164
+ console.log('[DASHBOARD] 🏓 Received pong');
165
+ break;
166
+ default:
167
+ console.log('[DASHBOARD] ❓ Unknown message type:', message.type);
168
+ }
169
+ } catch (error) {
170
+ console.error('[DASHBOARD] ❌ Error handling WebSocket message:', error);
171
+ }
172
+ }
173
+
174
+ handleAgentListUpdate(agentIds) {
175
+ console.log('[DASHBOARD] 📋 Agent list update:', agentIds);
176
+ // This is just the initial list of agent IDs, we still need to load full data
177
+ this.refreshDashboardData();
178
+ }
179
+
180
+ handleAgentConnected(agentId) {
181
+ console.log('[DASHBOARD] ✅ Agent connected:', agentId);
182
+ this.showAgentNotification(agentId, 'connected');
183
+ // Refresh data to get the new agent's details
184
+ this.refreshDashboardData();
185
+ }
186
+
187
+ handleAgentDisconnected(agentId) {
188
+ console.log('[DASHBOARD] ❌ Agent disconnected:', agentId);
189
+ this.showAgentNotification(agentId, 'disconnected');
190
+ // Refresh data to update agent status
191
+ this.refreshDashboardData();
192
+ }
193
+
194
+ showAgentNotification(agentId, status) {
195
+ const message = status === 'connected'
196
+ ? `🟢 Agent ${agentId} connected`
197
+ : `🔴 Agent ${agentId} disconnected`;
198
+
199
+ // Create notification element
200
+ const notification = document.createElement('div');
201
+ notification.className = `agent-notification ${status}`;
202
+ notification.textContent = message;
203
+
204
+ // Add to page
205
+ document.body.appendChild(notification);
206
+
207
+ // Auto-remove after 5 seconds
208
+ setTimeout(() => {
209
+ if (document.body.contains(notification)) {
210
+ document.body.removeChild(notification);
60
211
  }
61
- }, 30000);
212
+ }, 5000);
213
+ }
214
+
215
+ attemptReconnect() {
216
+ if (this.reconnectAttempts < this.maxReconnectAttempts && this.isAuthenticated) {
217
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
218
+ console.log(`[DASHBOARD] 🔄 Attempting reconnect in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
219
+
220
+ setTimeout(() => {
221
+ this.reconnectAttempts++;
222
+ this.setupWebSocket();
223
+ }, delay);
224
+ } else {
225
+ console.log('[DASHBOARD] ❌ Max reconnection attempts reached or not authenticated');
226
+ this.updateConnectionStatus('failed');
227
+ }
228
+ }
229
+
230
+ updateConnectionStatus(status) {
231
+ const connectionStatus = document.getElementById('connection-status');
232
+ if (connectionStatus) {
233
+ connectionStatus.className = `connection-status ${status}`;
234
+ const statusText = {
235
+ connected: '🟢 Live',
236
+ disconnected: '🟡 Reconnecting...',
237
+ error: '🔴 Connection Error',
238
+ failed: '🔴 Offline'
239
+ };
240
+ connectionStatus.textContent = statusText[status] || '❓ Unknown';
241
+ }
242
+ }
243
+
244
+ async manualRefresh() {
245
+ if (this.isRefreshing) {
246
+ console.log('[DASHBOARD] ⚠️ Refresh already in progress, ignoring manual request');
247
+ return;
248
+ }
249
+
250
+ console.log('[DASHBOARD] 🔄 Manual refresh triggered');
251
+
252
+ // Show loading state on refresh button
253
+ const refreshBtn = document.getElementById('refresh-btn');
254
+ if (refreshBtn) {
255
+ refreshBtn.classList.add('loading');
256
+ refreshBtn.disabled = true;
257
+ }
258
+
259
+ try {
260
+ await this.refreshDashboardData();
261
+ console.log('[DASHBOARD] ✅ Manual refresh completed');
262
+ } catch (error) {
263
+ console.error('[DASHBOARD] ❌ Manual refresh failed:', error);
264
+ } finally {
265
+ // Remove loading state
266
+ if (refreshBtn) {
267
+ refreshBtn.classList.remove('loading');
268
+ refreshBtn.disabled = false;
269
+ }
270
+ }
62
271
  }
63
272
 
64
273
  updateAgentsDisplay() {
@@ -157,8 +366,15 @@ class ShellMirrorDashboard {
157
366
  }
158
367
 
159
368
  renderAuthenticatedDashboard() {
160
- // Update user section
369
+ // Update user section with refresh button and status
161
370
  document.getElementById('user-section').innerHTML = `
371
+ <div class="dashboard-controls">
372
+ <span id="connection-status" class="connection-status">🟡 Connecting...</span>
373
+ <span id="refresh-status" class="refresh-status">Initializing...</span>
374
+ <button id="refresh-btn" class="refresh-btn" onclick="dashboard.manualRefresh()" title="Refresh agents">
375
+ <span class="refresh-icon">🔄</span>
376
+ </button>
377
+ </div>
162
378
  <div class="user-info">
163
379
  <span class="user-name">${this.user.name || this.user.email}</span>
164
380
  <div class="user-dropdown">
@@ -669,4 +885,14 @@ function handleLogin() {
669
885
  let dashboard;
670
886
  document.addEventListener('DOMContentLoaded', () => {
671
887
  dashboard = new ShellMirrorDashboard();
888
+ });
889
+
890
+ // Cleanup on page unload
891
+ window.addEventListener('beforeunload', () => {
892
+ if (dashboard && dashboard.websocket) {
893
+ dashboard.websocket.close();
894
+ }
895
+ if (dashboard && dashboard.refreshInterval) {
896
+ clearInterval(dashboard.refreshInterval);
897
+ }
672
898
  });
package/server.js CHANGED
@@ -44,6 +44,7 @@ app.use(passport.session());
44
44
  // --- In-memory data stores ---
45
45
  const agents = new Map(); // Stores connected agent sockets
46
46
  const clients = new Map(); // Stores connected client (browser) sockets
47
+ const dashboards = new Map(); // Stores connected dashboard sockets
47
48
  const sessions = new Map(); // Maps session IDs to agent/client pairs
48
49
 
49
50
  // --- Environment Detection ---
@@ -150,7 +151,53 @@ wss.on('connection', (ws, req) => {
150
151
  const role = params.get('role');
151
152
  const agentId = params.get('agentId');
152
153
 
153
- if (role === 'discovery') {
154
+ if (role === 'dashboard') {
155
+ // --- Dashboard Connection ---
156
+ sessionParser(req, {}, () => {
157
+ let userId;
158
+ let isAuthenticated = false;
159
+
160
+ if (req.session && req.session.passport && req.session.passport.user) {
161
+ userId = req.session.passport.user.id;
162
+ isAuthenticated = true;
163
+ logToFile(`✅ Authenticated dashboard connected: ${userId}`);
164
+ } else if (isLocalEnvironment && !isProduction) {
165
+ // LOCAL_TESTING_ONLY: Allow unauthenticated connections for local testing
166
+ userId = `local-test-dashboard-${uuidv4()}`;
167
+ logToFile(`🔧 LOCAL TESTING: Unauthenticated dashboard connected: ${userId}`);
168
+ } else {
169
+ // Production: Reject unauthenticated connections
170
+ logToFile('❌ Unauthenticated dashboard rejected in production environment');
171
+ ws.close(1008, 'Authentication required');
172
+ return;
173
+ }
174
+
175
+ ws.userId = userId;
176
+ dashboards.set(userId, ws);
177
+
178
+ // Send initial agent list
179
+ const agentsList = Array.from(agents.keys()).map(id => ({ id }));
180
+ ws.send(JSON.stringify({ type: 'agent-list', agents: agentsList }));
181
+ logToFile(`📊 Sent initial agent list to dashboard: ${agentsList.length} agents`);
182
+
183
+ ws.on('close', () => {
184
+ logToFile(`🔌 Dashboard disconnected: ${userId}`);
185
+ dashboards.delete(userId);
186
+ });
187
+
188
+ ws.on('message', (message) => {
189
+ try {
190
+ const data = JSON.parse(message);
191
+ if (data.type === 'ping') {
192
+ ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
193
+ }
194
+ } catch (error) {
195
+ logToFile(`❌ Error handling dashboard message: ${error.message}`);
196
+ }
197
+ });
198
+ });
199
+
200
+ } else if (role === 'discovery') {
154
201
  // --- Agent Discovery Connection ---
155
202
  logToFile('📍 Discovery client connected');
156
203
 
@@ -169,11 +216,16 @@ wss.on('connection', (ws, req) => {
169
216
  logToFile(`🖥️ Agent connected: ${agentId}`);
170
217
  ws.agentId = agentId;
171
218
  agents.set(agentId, ws);
219
+
220
+ // Notify all dashboards of new agent
221
+ notifyAgentConnected(agentId);
172
222
 
173
223
  ws.on('close', () => {
174
224
  logToFile(`🔌 Agent disconnected: ${agentId}`);
175
225
  agents.delete(agentId);
176
- // Notify client if in a session
226
+
227
+ // Notify all dashboards of agent disconnection
228
+ notifyAgentDisconnected(agentId);
177
229
  });
178
230
 
179
231
  ws.on('message', (message) => handleSignalingMessage(ws, message, 'agent'));
@@ -220,6 +272,39 @@ wss.on('connection', (ws, req) => {
220
272
  }
221
273
  });
222
274
 
275
+ // --- Dashboard Broadcast Functions ---
276
+ function broadcastToDashboards(message) {
277
+ const messageStr = JSON.stringify(message);
278
+ dashboards.forEach((dashboardWs, userId) => {
279
+ if (dashboardWs.readyState === WebSocket.OPEN) {
280
+ dashboardWs.send(messageStr);
281
+ logToFile(`📡 Broadcasted to dashboard ${userId}: ${message.type}`);
282
+ } else {
283
+ // Clean up closed connections
284
+ dashboards.delete(userId);
285
+ logToFile(`🔌 Removed closed dashboard connection: ${userId}`);
286
+ }
287
+ });
288
+ }
289
+
290
+ function notifyAgentConnected(agentId) {
291
+ broadcastToDashboards({
292
+ type: 'agent-connected',
293
+ agentId: agentId,
294
+ timestamp: Date.now()
295
+ });
296
+ logToFile(`📢 Notified dashboards of agent connection: ${agentId}`);
297
+ }
298
+
299
+ function notifyAgentDisconnected(agentId) {
300
+ broadcastToDashboards({
301
+ type: 'agent-disconnected',
302
+ agentId: agentId,
303
+ timestamp: Date.now()
304
+ });
305
+ logToFile(`📢 Notified dashboards of agent disconnection: ${agentId}`);
306
+ }
307
+
223
308
  function handleSignalingMessage(ws, rawMessage, senderRole) {
224
309
  try {
225
310
  const message = JSON.parse(rawMessage);