@cryptiklemur/lattice 1.31.0 → 1.32.0

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 } 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,6 +8,7 @@ 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) {
@@ -56,6 +57,16 @@ function NodeRow(props: NodeRowProps) {
56
57
 
57
58
  {!props.node.isLocal && (
58
59
  <div className="flex gap-1.5 flex-shrink-0">
60
+ {!props.node.online && (
61
+ <button
62
+ onClick={function () { props.onReconnect(props.node.id); }}
63
+ className="btn btn-ghost btn-xs border border-base-content/20 hover:btn-info hover:border-info gap-1"
64
+ title="Attempt to reconnect"
65
+ >
66
+ <RefreshCw size={10} />
67
+ Reconnect
68
+ </button>
69
+ )}
59
70
  {confirming ? (
60
71
  <>
61
72
  <button
@@ -96,6 +107,10 @@ export function MeshStatus() {
96
107
  ws.send({ type: "mesh:unpair", nodeId });
97
108
  }
98
109
 
110
+ function handleReconnect(nodeId: string) {
111
+ ws.send({ type: "mesh:reconnect", nodeId } as any);
112
+ }
113
+
99
114
  var localNode = nodes.find(function (n) { return n.isLocal; });
100
115
  var remoteNodes = nodes.filter(function (n) { return !n.isLocal; });
101
116
 
@@ -106,7 +121,7 @@ export function MeshStatus() {
106
121
  This Node
107
122
  </div>
108
123
  {localNode ? (
109
- <NodeRow node={localNode} onUnpair={handleUnpair} />
124
+ <NodeRow node={localNode} onUnpair={handleUnpair} onReconnect={handleReconnect} />
110
125
  ) : (
111
126
  <div className="text-[12px] text-base-content/40 italic">
112
127
  Waiting for node info...
@@ -139,6 +154,7 @@ export function MeshStatus() {
139
154
  key={node.id}
140
155
  node={node}
141
156
  onUnpair={handleUnpair}
157
+ onReconnect={handleReconnect}
142
158
  />
143
159
  );
144
160
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptiklemur/lattice",
3
- "version": "1.31.0",
3
+ "version": "1.32.0",
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>",
@@ -5,7 +5,7 @@ import { loadConfig } from "../config";
5
5
  import { loadOrCreateIdentity } from "../identity";
6
6
  import { generateInviteCode, parseInviteCode, validatePairingToken, consumePairingToken } from "../mesh/pairing";
7
7
  import { addPeer, removePeer, loadPeers } from "../mesh/peers";
8
- import { getConnectedPeerIds } from "../mesh/connector";
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
 
@@ -106,11 +106,14 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
106
106
 
107
107
  pairWs.addEventListener("open", function () {
108
108
  var identity = loadOrCreateIdentity();
109
+ var pairConfig = loadConfig();
109
110
  pairWs.send(JSON.stringify({
110
111
  type: "mesh:hello",
111
112
  nodeId: identity.id,
112
- name: loadConfig().name,
113
+ name: pairConfig.name,
113
114
  token: parsed!.token,
115
+ port: pairConfig.port,
116
+ addresses: getAllAddresses().map(function (a) { return a.address + ":" + pairConfig.port; }),
114
117
  projects: [],
115
118
  }));
116
119
  });
@@ -128,21 +131,24 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
128
131
 
129
132
  if (data.type === "mesh:hello" && data.nodeId && data.name) {
130
133
  clearTimeout(pairTimeout);
134
+ var peerAddr = parsed!.address + ":" + parsed!.port;
131
135
  var peer: PeerInfo = {
132
136
  id: data.nodeId,
133
137
  name: data.name,
134
- addresses: [parsed!.address],
138
+ addresses: [peerAddr],
135
139
  publicKey: "",
136
140
  pairedAt: Date.now(),
137
141
  };
138
142
  addPeer(peer);
139
143
  pairWs.close();
140
144
 
145
+ connectToPeer(peer.id, peerAddr);
146
+
141
147
  var nodeInfo: NodeInfo = {
142
148
  id: peer.id,
143
149
  name: peer.name,
144
- address: parsed!.address,
145
- addresses: [parsed!.address + ":" + parsed!.port],
150
+ address: peerAddr,
151
+ addresses: [peerAddr],
146
152
  port: parsed!.port,
147
153
  online: true,
148
154
  isLocal: false,
@@ -164,7 +170,7 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
164
170
  }
165
171
 
166
172
  if ((message as any).type === "mesh:hello") {
167
- var hello = message as any as { type: "mesh:hello"; nodeId: string; name: string; token?: string; projects: Array<{ slug: string; title: string }> };
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 }> };
168
174
 
169
175
  if (!hello.token || !validatePairingToken(hello.token)) {
170
176
  sendTo(clientId, { type: "mesh:hello_rejected" as any, error: "Invalid or expired invite code" });
@@ -172,15 +178,21 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
172
178
  }
173
179
  consumePairingToken(hello.token);
174
180
 
181
+ var peerAddresses = hello.addresses ?? [];
182
+
175
183
  var peer: PeerInfo = {
176
184
  id: hello.nodeId,
177
185
  name: hello.name,
178
- addresses: [],
186
+ addresses: peerAddresses,
179
187
  publicKey: "",
180
188
  pairedAt: Date.now(),
181
189
  };
182
190
  addPeer(peer);
183
191
 
192
+ if (peerAddresses.length > 0) {
193
+ connectToPeer(peer.id, peerAddresses[0]);
194
+ }
195
+
184
196
  var identity = loadOrCreateIdentity();
185
197
  sendTo(clientId, {
186
198
  type: "mesh:hello" as any,
@@ -193,6 +205,15 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
193
205
  return;
194
206
  }
195
207
 
208
+ if (message.type === "mesh:reconnect") {
209
+ var reconnectMsg = message as { type: "mesh:reconnect"; nodeId: string };
210
+ reconnectPeer(reconnectMsg.nodeId);
211
+ setTimeout(function () {
212
+ broadcast({ type: "mesh:nodes", nodes: buildNodesMessage() });
213
+ }, 2000);
214
+ return;
215
+ }
216
+
196
217
  if (message.type === "mesh:unpair") {
197
218
  var unpairMsg = message as MeshUnpairMessage;
198
219
  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
 
@@ -66,9 +81,10 @@ export function connectToPeer(nodeId: string, address: string): void {
66
81
  }
67
82
 
68
83
  var config = loadConfig();
69
- var port = config.port;
70
84
  var protocol = config.tls ? "wss" : "ws";
71
- var url = protocol + "://" + address + ":" + port + "/ws";
85
+ var url = address.includes(":")
86
+ ? protocol + "://" + address + "/ws"
87
+ : protocol + "://" + address + ":" + config.port + "/ws";
72
88
 
73
89
  var conn: PeerConnection = {
74
90
  nodeId: nodeId,
@@ -222,6 +238,26 @@ export function getPeerConnection(nodeId: string): WebSocket | undefined {
222
238
  return conn.ws;
223
239
  }
224
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
+
225
261
  export function getConnectedPeerIds(): string[] {
226
262
  var ids: string[] = [];
227
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