@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.
|
|
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:
|
|
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: [
|
|
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:
|
|
145
|
-
addresses: [
|
|
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
|
-
|
|
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 =
|
|
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) {
|
package/shared/src/messages.ts
CHANGED
|
@@ -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
|