@cryptiklemur/lattice 1.31.1 → 1.32.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.
@@ -1,5 +1,5 @@
1
1
  import { useState, useCallback, memo } from "react";
2
- import { Plus, CircleDot, Circle } from "lucide-react";
2
+ import { Plus, CircleDot, Circle, RefreshCw, Loader2 } from "lucide-react";
3
3
  import { useWebSocket } from "../../hooks/useWebSocket";
4
4
  import { useMesh } from "../../hooks/useMesh";
5
5
  import { PairingDialog } from "../mesh/PairingDialog";
@@ -8,10 +8,12 @@ import type { NodeInfo } from "@lattice/shared";
8
8
  interface NodeRowProps {
9
9
  node: NodeInfo;
10
10
  onUnpair: (nodeId: string) => void;
11
+ onReconnect: (nodeId: string) => void;
11
12
  }
12
13
 
13
14
  function NodeRow(props: NodeRowProps) {
14
15
  var [confirming, setConfirming] = useState(false);
16
+ var [reconnecting, setReconnecting] = useState(false);
15
17
 
16
18
  function handleUnpair() {
17
19
  if (!confirming) {
@@ -56,6 +58,27 @@ function NodeRow(props: NodeRowProps) {
56
58
 
57
59
  {!props.node.isLocal && (
58
60
  <div className="flex gap-1.5 flex-shrink-0">
61
+ {!props.node.online && (
62
+ reconnecting ? (
63
+ <span className="flex items-center gap-1 text-[11px] text-base-content/40">
64
+ <Loader2 size={10} className="animate-spin" />
65
+ Connecting...
66
+ </span>
67
+ ) : (
68
+ <button
69
+ onClick={function () {
70
+ setReconnecting(true);
71
+ props.onReconnect(props.node.id);
72
+ setTimeout(function () { setReconnecting(false); }, 5000);
73
+ }}
74
+ className="btn btn-ghost btn-xs border border-base-content/20 hover:btn-info hover:border-info gap-1"
75
+ title="Attempt to reconnect"
76
+ >
77
+ <RefreshCw size={10} />
78
+ Reconnect
79
+ </button>
80
+ )
81
+ )}
59
82
  {confirming ? (
60
83
  <>
61
84
  <button
@@ -96,6 +119,10 @@ export function MeshStatus() {
96
119
  ws.send({ type: "mesh:unpair", nodeId });
97
120
  }
98
121
 
122
+ function handleReconnect(nodeId: string) {
123
+ ws.send({ type: "mesh:reconnect", nodeId } as any);
124
+ }
125
+
99
126
  var localNode = nodes.find(function (n) { return n.isLocal; });
100
127
  var remoteNodes = nodes.filter(function (n) { return !n.isLocal; });
101
128
 
@@ -106,7 +133,7 @@ export function MeshStatus() {
106
133
  This Node
107
134
  </div>
108
135
  {localNode ? (
109
- <NodeRow node={localNode} onUnpair={handleUnpair} />
136
+ <NodeRow node={localNode} onUnpair={handleUnpair} onReconnect={handleReconnect} />
110
137
  ) : (
111
138
  <div className="text-[12px] text-base-content/40 italic">
112
139
  Waiting for node info...
@@ -139,6 +166,7 @@ export function MeshStatus() {
139
166
  key={node.id}
140
167
  node={node}
141
168
  onUnpair={handleUnpair}
169
+ onReconnect={handleReconnect}
142
170
  />
143
171
  );
144
172
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptiklemur/lattice",
3
- "version": "1.31.1",
3
+ "version": "1.32.1",
4
4
  "description": "Multi-machine agentic dashboard for Claude Code. Monitor sessions, manage MCP servers and skills, orchestrate across mesh-networked nodes.",
5
5
  "license": "MIT",
6
6
  "author": "Aaron Scherer <me@aaronscherer.me>",
@@ -4,8 +4,8 @@ import { sendTo, broadcast } from "../ws/broadcast";
4
4
  import { loadConfig } from "../config";
5
5
  import { loadOrCreateIdentity } from "../identity";
6
6
  import { generateInviteCode, parseInviteCode, validatePairingToken, consumePairingToken } from "../mesh/pairing";
7
- import { addPeer, removePeer, loadPeers } from "../mesh/peers";
8
- import { getConnectedPeerIds, connectToPeer } from "../mesh/connector";
7
+ import { addPeer, removePeer, loadPeers, getPeer } from "../mesh/peers";
8
+ import { getConnectedPeerIds, connectToPeer, reconnectPeer } from "../mesh/connector";
9
9
  import type { PeerInfo } from "@lattice/shared";
10
10
  import { networkInterfaces } from "node:os";
11
11
 
@@ -172,6 +172,20 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
172
172
  if ((message as any).type === "mesh:hello") {
173
173
  var hello = message as any as { type: "mesh:hello"; nodeId: string; name: string; token?: string; port?: number; addresses?: string[]; projects: Array<{ slug: string; title: string }> };
174
174
 
175
+ var knownPeer = hello.nodeId ? getPeer(hello.nodeId) : undefined;
176
+
177
+ if (knownPeer) {
178
+ var identity = loadOrCreateIdentity();
179
+ sendTo(clientId, {
180
+ type: "mesh:hello" as any,
181
+ nodeId: identity.id,
182
+ name: loadConfig().name,
183
+ projects: [],
184
+ });
185
+ broadcast({ type: "mesh:nodes", nodes: buildNodesMessage() });
186
+ return;
187
+ }
188
+
175
189
  if (!hello.token || !validatePairingToken(hello.token)) {
176
190
  sendTo(clientId, { type: "mesh:hello_rejected" as any, error: "Invalid or expired invite code" });
177
191
  return;
@@ -193,10 +207,10 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
193
207
  connectToPeer(peer.id, peerAddresses[0]);
194
208
  }
195
209
 
196
- var identity = loadOrCreateIdentity();
210
+ var identity2 = loadOrCreateIdentity();
197
211
  sendTo(clientId, {
198
212
  type: "mesh:hello" as any,
199
- nodeId: identity.id,
213
+ nodeId: identity2.id,
200
214
  name: loadConfig().name,
201
215
  projects: [],
202
216
  });
@@ -205,6 +219,15 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
205
219
  return;
206
220
  }
207
221
 
222
+ if (message.type === "mesh:reconnect") {
223
+ var reconnectMsg = message as { type: "mesh:reconnect"; nodeId: string };
224
+ reconnectPeer(reconnectMsg.nodeId);
225
+ setTimeout(function () {
226
+ broadcast({ type: "mesh:nodes", nodes: buildNodesMessage() });
227
+ }, 2000);
228
+ return;
229
+ }
230
+
208
231
  if (message.type === "mesh:unpair") {
209
232
  var unpairMsg = message as MeshUnpairMessage;
210
233
  var removed = removePeer(unpairMsg.nodeId);
@@ -35,10 +35,25 @@ interface CircuitState {
35
35
 
36
36
  var circuitBreakers = new Map<string, CircuitState>();
37
37
 
38
+ var RECONNECT_INTERVAL_MS = 15000;
39
+
38
40
  export function startMeshConnections(): void {
41
+ reconcilePeers();
42
+ setInterval(reconcilePeers, RECONNECT_INTERVAL_MS);
43
+ }
44
+
45
+ function reconcilePeers(): void {
39
46
  var peers = loadPeers();
40
47
  for (var i = 0; i < peers.length; i++) {
41
- connectToPeer(peers[i].id, peers[i].addresses[0]);
48
+ var peer = peers[i];
49
+ if (!peer.addresses || peer.addresses.length === 0) continue;
50
+ var existing = connections.get(peer.id);
51
+ if (existing && !existing.dead && existing.ws.readyState === WebSocket.OPEN) continue;
52
+ if (existing && !existing.dead && existing.retryTimer !== null) continue;
53
+ if (!existing || existing.dead) {
54
+ connections.delete(peer.id);
55
+ connectToPeer(peer.id, peer.addresses[0]);
56
+ }
42
57
  }
43
58
  }
44
59
 
@@ -223,6 +238,26 @@ export function getPeerConnection(nodeId: string): WebSocket | undefined {
223
238
  return conn.ws;
224
239
  }
225
240
 
241
+ export function reconnectPeer(nodeId: string): void {
242
+ var existing = connections.get(nodeId);
243
+ if (existing) {
244
+ existing.dead = true;
245
+ if (existing.retryTimer !== null) {
246
+ clearTimeout(existing.retryTimer);
247
+ existing.retryTimer = null;
248
+ }
249
+ existing.ws.close();
250
+ connections.delete(nodeId);
251
+ }
252
+ circuitBreakers.delete(nodeId);
253
+
254
+ var peers = loadPeers();
255
+ var peer = peers.find(function (p) { return p.id === nodeId; });
256
+ if (peer && peer.addresses && peer.addresses.length > 0) {
257
+ connectToPeer(nodeId, peer.addresses[0]);
258
+ }
259
+ }
260
+
226
261
  export function getConnectedPeerIds(): string[] {
227
262
  var ids: string[] = [];
228
263
  for (var [nodeId, conn] of connections) {
@@ -218,6 +218,11 @@ export interface MeshUnpairMessage {
218
218
  nodeId: string;
219
219
  }
220
220
 
221
+ export interface MeshReconnectMessage {
222
+ type: "mesh:reconnect";
223
+ nodeId: string;
224
+ }
225
+
221
226
  export interface LoopStartMessage {
222
227
  type: "loop:start";
223
228
  projectSlug: string;
@@ -537,6 +542,7 @@ export type ClientMessage =
537
542
  | MeshPairMessage
538
543
  | MeshGenerateInviteMessage
539
544
  | MeshUnpairMessage
545
+ | MeshReconnectMessage
540
546
  | LoopStartMessage
541
547
  | LoopStopMessage
542
548
  | LoopStatusRequestMessage