loopsy 1.0.10 → 1.0.11

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/dist/cli/index.js CHANGED
@@ -90,6 +90,6 @@ yargs(hideBin(process.argv))
90
90
  .command('doctor', 'Run health checks on your Loopsy installation', {}, doctorCommand)
91
91
  .demandCommand(1, 'You need at least one command')
92
92
  .help()
93
- .version('1.0.10')
93
+ .version('1.0.11')
94
94
  .parse();
95
95
  //# sourceMappingURL=index.js.map
@@ -581,7 +581,10 @@ select option { background: var(--bg-base); color: var(--text-primary); }
581
581
  transition: border-color 0.15s;
582
582
  }
583
583
 
584
+ .peer-card { cursor: pointer; }
584
585
  .peer-card:hover { border-color: var(--border-bright); }
586
+ .peer-card.peer-selected { border-color: var(--red-dim); background: rgba(255,51,102,0.05); }
587
+ .peer-check { accent-color: var(--red); cursor: pointer; width: 14px; height: 14px; }
585
588
 
586
589
  /* ═══ Message Rows ═══ */
587
590
  .msg-row {
@@ -11,6 +11,8 @@ function platformSvg(platform) {
11
11
  }
12
12
 
13
13
  let refreshTimer = null;
14
+ let allPeers = []; // cached peer list for selection
15
+ let selectedPeers = new Set(); // nodeIds of selected peers
14
16
 
15
17
  function mount(container) {
16
18
  container.innerHTML = `
@@ -20,7 +22,10 @@ function mount(container) {
20
22
  <div class="form-group">
21
23
  <select class="input" id="peers-session"></select>
22
24
  </div>
23
- <button class="btn btn-primary btn-sm" id="btn-peers-refresh">Refresh</button>
25
+ <div class="flex gap-sm items-center">
26
+ <button class="btn btn-primary btn-sm" id="btn-peers-refresh">Refresh</button>
27
+ <button class="btn btn-danger btn-sm" id="btn-delete-peers" style="display:none">Delete Selected</button>
28
+ </div>
24
29
  </div>
25
30
 
26
31
  <div class="peer-grid" id="peer-grid"></div>
@@ -39,12 +44,15 @@ function mount(container) {
39
44
  document.getElementById('btn-peers-refresh').addEventListener('click', loadPeers);
40
45
  document.getElementById('btn-add-peer').addEventListener('click', addPeer);
41
46
  document.getElementById('peers-session').addEventListener('change', loadPeers);
47
+ document.getElementById('btn-delete-peers').addEventListener('click', deleteSelectedPeers);
42
48
 
43
49
  refreshTimer = setInterval(loadPeers, 15000);
44
50
  }
45
51
 
46
52
  function unmount() {
47
53
  if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
54
+ selectedPeers.clear();
55
+ allPeers = [];
48
56
  }
49
57
 
50
58
  async function loadSessions() {
@@ -76,19 +84,30 @@ async function loadPeers() {
76
84
  peers = data.peers || [];
77
85
  }
78
86
 
87
+ allPeers = peers;
88
+
89
+ // Clean up selected peers that no longer exist
90
+ const currentNodeIds = new Set(peers.map(p => p.nodeId));
91
+ for (const id of selectedPeers) {
92
+ if (!currentNodeIds.has(id)) selectedPeers.delete(id);
93
+ }
94
+
79
95
  if (peers.length === 0) {
80
96
  grid.innerHTML = '<div class="empty">No peers discovered</div>';
97
+ updateDeleteButton();
81
98
  return;
82
99
  }
83
100
 
84
101
  grid.innerHTML = peers.map(p => {
85
102
  const dotClass = p.status === 'online' ? 'online' : p.status === 'offline' ? 'offline' : 'unknown';
86
103
  const platformIcon = platformSvg(p.platform);
104
+ const isSelected = selectedPeers.has(p.nodeId);
87
105
 
88
106
  return `
89
- <div class="peer-card">
107
+ <div class="peer-card ${isSelected ? 'peer-selected' : ''}" data-node-id="${escapeHtml(p.nodeId)}" onclick="window.__togglePeer('${escapeHtml(p.nodeId)}')">
90
108
  <div class="flex items-center justify-between mb-1">
91
109
  <div class="flex items-center gap-sm">
110
+ <input type="checkbox" class="peer-check" ${isSelected ? 'checked' : ''} onclick="event.stopPropagation(); window.__togglePeer('${escapeHtml(p.nodeId)}')">
92
111
  <span class="status-dot ${dotClass}"></span>
93
112
  <span class="font-mono text-sm" style="font-weight:600">${escapeHtml(p.hostname)}</span>
94
113
  </div>
@@ -104,11 +123,65 @@ async function loadPeers() {
104
123
  </div>
105
124
  `;
106
125
  }).join('');
126
+
127
+ updateDeleteButton();
107
128
  } catch (err) {
108
129
  grid.innerHTML = `<div class="empty">${escapeHtml(err.message)}</div>`;
109
130
  }
110
131
  }
111
132
 
133
+ function updateDeleteButton() {
134
+ const btn = document.getElementById('btn-delete-peers');
135
+ if (!btn) return;
136
+ if (selectedPeers.size > 0) {
137
+ btn.style.display = '';
138
+ btn.textContent = `Delete Selected (${selectedPeers.size})`;
139
+ } else {
140
+ btn.style.display = 'none';
141
+ }
142
+ }
143
+
144
+ window.__togglePeer = (nodeId) => {
145
+ if (selectedPeers.has(nodeId)) {
146
+ selectedPeers.delete(nodeId);
147
+ } else {
148
+ selectedPeers.add(nodeId);
149
+ }
150
+ // Update UI without full reload
151
+ const card = document.querySelector(`[data-node-id="${nodeId}"]`);
152
+ if (card) {
153
+ card.classList.toggle('peer-selected', selectedPeers.has(nodeId));
154
+ const check = card.querySelector('.peer-check');
155
+ if (check) check.checked = selectedPeers.has(nodeId);
156
+ }
157
+ updateDeleteButton();
158
+ };
159
+
160
+ async function deleteSelectedPeers() {
161
+ if (selectedPeers.size === 0) return;
162
+ if (!confirm(`Delete ${selectedPeers.size} peer(s) from all sessions?`)) return;
163
+
164
+ const btn = document.getElementById('btn-delete-peers');
165
+ if (btn) btn.disabled = true;
166
+
167
+ const peersToDelete = allPeers
168
+ .filter(p => selectedPeers.has(p.nodeId))
169
+ .map(p => ({ nodeId: p.nodeId, address: p.address, port: p.port, _seenBySessions: p._seenBySessions }));
170
+
171
+ try {
172
+ await dashboardApi('/peers/delete', {
173
+ method: 'POST',
174
+ body: JSON.stringify({ peers: peersToDelete }),
175
+ });
176
+ selectedPeers.clear();
177
+ await loadPeers();
178
+ } catch (err) {
179
+ alert('Failed: ' + err.message);
180
+ }
181
+
182
+ if (btn) btn.disabled = false;
183
+ }
184
+
112
185
  async function addPeer() {
113
186
  const portVal = document.getElementById('peers-session').value;
114
187
  if (portVal === 'all') {
@@ -1 +1 @@
1
- {"version":3,"file":"peers-all.d.ts","sourceRoot":"","sources":["../../src/routes/peers-all.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG/C,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,QAK/G"}
1
+ {"version":3,"file":"peers-all.d.ts","sourceRoot":"","sources":["../../src/routes/peers-all.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAgB/C,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,QA2D/G"}
@@ -1,8 +1,63 @@
1
1
  import { fetchAndDeduplicatePeers } from './peer-utils.js';
2
+ import { listSessions } from '../session-manager.js';
3
+ function resolveApiKey(address, localApiKey, allowedKeys) {
4
+ if (address === '127.0.0.1' || address === 'localhost')
5
+ return localApiKey;
6
+ for (const key of Object.values(allowedKeys)) {
7
+ if (key !== localApiKey)
8
+ return key;
9
+ }
10
+ return localApiKey;
11
+ }
2
12
  export function registerPeersAllRoute(app, apiKey, allowedKeys) {
3
13
  app.get('/dashboard/api/peers/all', async () => {
4
14
  const peers = await fetchAndDeduplicatePeers(apiKey, allowedKeys);
5
15
  return { peers, timestamp: Date.now() };
6
16
  });
17
+ // Delete peers by nodeId across all local sessions and optionally remote machines
18
+ app.post('/dashboard/api/peers/delete', async (request) => {
19
+ const { peers: peersToDelete } = request.body;
20
+ if (!peersToDelete?.length)
21
+ return { deleted: 0 };
22
+ const { main, sessions } = await listSessions();
23
+ const localPorts = [];
24
+ if (main && main.status === 'running')
25
+ localPorts.push(main.port);
26
+ for (const s of sessions.filter((s) => s.status === 'running')) {
27
+ localPorts.push(s.port);
28
+ }
29
+ let deleted = 0;
30
+ for (const peer of peersToDelete) {
31
+ // Delete from all local sessions
32
+ for (const port of localPorts) {
33
+ try {
34
+ const res = await fetch(`http://127.0.0.1:${port}/api/v1/peers/${encodeURIComponent(peer.nodeId)}`, {
35
+ method: 'DELETE',
36
+ headers: { Authorization: `Bearer ${apiKey}` },
37
+ signal: AbortSignal.timeout(3000),
38
+ });
39
+ if (res.ok)
40
+ deleted++;
41
+ }
42
+ catch { }
43
+ }
44
+ // If this peer is on a remote machine, also tell that machine to remove it
45
+ const isRemote = peer.address !== '127.0.0.1' && peer.address !== 'localhost';
46
+ if (isRemote && allowedKeys) {
47
+ const remoteKey = resolveApiKey(peer.address, apiKey, allowedKeys);
48
+ try {
49
+ const res = await fetch(`http://${peer.address}:${peer.port}/api/v1/peers/${encodeURIComponent(peer.nodeId)}`, {
50
+ method: 'DELETE',
51
+ headers: { Authorization: `Bearer ${remoteKey}` },
52
+ signal: AbortSignal.timeout(3000),
53
+ });
54
+ if (res.ok)
55
+ deleted++;
56
+ }
57
+ catch { }
58
+ }
59
+ }
60
+ return { deleted, requested: peersToDelete.length };
61
+ });
7
62
  }
8
63
  //# sourceMappingURL=peers-all.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"peers-all.js","sourceRoot":"","sources":["../../src/routes/peers-all.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,wBAAwB,EAAE,MAAM,iBAAiB,CAAC;AAE3D,MAAM,UAAU,qBAAqB,CAAC,GAAoB,EAAE,MAAc,EAAE,WAAoC;IAC9G,GAAG,CAAC,GAAG,CAAC,0BAA0B,EAAE,KAAK,IAAI,EAAE;QAC7C,MAAM,KAAK,GAAG,MAAM,wBAAwB,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;QAClE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;IAC1C,CAAC,CAAC,CAAC;AACL,CAAC"}
1
+ {"version":3,"file":"peers-all.js","sourceRoot":"","sources":["../../src/routes/peers-all.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopsy",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "Cross-machine communication for AI coding agents — run commands, transfer files, and share context between machines",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,7 +38,7 @@ for (const stub of stubs) {
38
38
  join(stubDir, 'package.json'),
39
39
  JSON.stringify({
40
40
  name: `@loopsy/${stub.name}`,
41
- version: '1.0.10',
41
+ version: '1.0.11',
42
42
  type: 'module',
43
43
  main: 'index.js',
44
44
  exports: { '.': './index.js' },