shell-mirror 1.5.54 → 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.54",
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
@@ -28,16 +28,13 @@ const server = http.createServer(app);
28
28
  const wss = new WebSocket.Server({ server });
29
29
 
30
30
  // --- Session Management ---
31
- const sessionLifetime = isProduction ? (30 * 24 * 60 * 60 * 1000) : (7 * 24 * 60 * 60 * 1000); // 30 days production, 7 days development
32
31
  const sessionParser = session({
33
32
  secret: process.env.SESSION_SECRET,
34
33
  resave: false,
35
34
  saveUninitialized: false,
36
35
  cookie: {
37
- secure: isProduction,
38
- maxAge: sessionLifetime,
39
- httpOnly: true,
40
- sameSite: 'lax'
36
+ secure: process.env.NODE_ENV === 'production',
37
+ maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days in milliseconds
41
38
  }
42
39
  });
43
40
  app.use(sessionParser);
@@ -47,6 +44,7 @@ app.use(passport.session());
47
44
  // --- In-memory data stores ---
48
45
  const agents = new Map(); // Stores connected agent sockets
49
46
  const clients = new Map(); // Stores connected client (browser) sockets
47
+ const dashboards = new Map(); // Stores connected dashboard sockets
50
48
  const sessions = new Map(); // Maps session IDs to agent/client pairs
51
49
 
52
50
  // --- Environment Detection ---
@@ -153,7 +151,53 @@ wss.on('connection', (ws, req) => {
153
151
  const role = params.get('role');
154
152
  const agentId = params.get('agentId');
155
153
 
156
- 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') {
157
201
  // --- Agent Discovery Connection ---
158
202
  logToFile('📍 Discovery client connected');
159
203
 
@@ -172,11 +216,16 @@ wss.on('connection', (ws, req) => {
172
216
  logToFile(`🖥️ Agent connected: ${agentId}`);
173
217
  ws.agentId = agentId;
174
218
  agents.set(agentId, ws);
219
+
220
+ // Notify all dashboards of new agent
221
+ notifyAgentConnected(agentId);
175
222
 
176
223
  ws.on('close', () => {
177
224
  logToFile(`🔌 Agent disconnected: ${agentId}`);
178
225
  agents.delete(agentId);
179
- // Notify client if in a session
226
+
227
+ // Notify all dashboards of agent disconnection
228
+ notifyAgentDisconnected(agentId);
180
229
  });
181
230
 
182
231
  ws.on('message', (message) => handleSignalingMessage(ws, message, 'agent'));
@@ -223,6 +272,39 @@ wss.on('connection', (ws, req) => {
223
272
  }
224
273
  });
225
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
+
226
308
  function handleSignalingMessage(ws, rawMessage, senderRole) {
227
309
  try {
228
310
  const message = JSON.parse(rawMessage);