@cryptiklemur/lattice 1.28.1 → 1.28.3
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,6 +1,5 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
-
import {
|
|
3
|
-
import { X, Copy, Check } from "lucide-react";
|
|
1
|
+
import { useState, useEffect, useCallback, useRef, memo } from "react";
|
|
2
|
+
import { X, Copy, Check, Loader2 } from "lucide-react";
|
|
4
3
|
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
5
4
|
import { useMesh } from "../../hooks/useMesh";
|
|
6
5
|
import { clearInvite } from "../../stores/mesh";
|
|
@@ -14,7 +13,7 @@ interface PairingDialogProps {
|
|
|
14
13
|
onClose: () => void;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
export function PairingDialog(props: PairingDialogProps) {
|
|
16
|
+
export var PairingDialog = memo(function PairingDialog(props: PairingDialogProps) {
|
|
18
17
|
var ws = useWebSocket();
|
|
19
18
|
var mesh = useMesh();
|
|
20
19
|
var [tab, setTab] = useState<Tab>("generate");
|
|
@@ -22,9 +21,9 @@ export function PairingDialog(props: PairingDialogProps) {
|
|
|
22
21
|
var [pairStatus, setPairStatus] = useState<PairStatus>("idle");
|
|
23
22
|
var [pairError, setPairError] = useState<string | null>(null);
|
|
24
23
|
var [copied, setCopied] = useState(false);
|
|
24
|
+
var [generating, setGenerating] = useState(false);
|
|
25
25
|
var modalRef = useRef<HTMLDivElement>(null);
|
|
26
|
-
var
|
|
27
|
-
useFocusTrap(modalRef, stableOnClose, props.isOpen);
|
|
26
|
+
var inputRef = useRef<HTMLInputElement>(null);
|
|
28
27
|
|
|
29
28
|
useEffect(function () {
|
|
30
29
|
if (!props.isOpen) {
|
|
@@ -33,30 +32,42 @@ export function PairingDialog(props: PairingDialogProps) {
|
|
|
33
32
|
setPairStatus("idle");
|
|
34
33
|
setPairError(null);
|
|
35
34
|
setCopied(false);
|
|
35
|
+
setGenerating(false);
|
|
36
36
|
setTab("generate");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
40
|
+
if (e.key === "Escape") props.onClose();
|
|
37
41
|
}
|
|
42
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
43
|
+
return function () { document.removeEventListener("keydown", handleKeyDown); };
|
|
38
44
|
}, [props.isOpen]);
|
|
39
45
|
|
|
40
46
|
useEffect(function () {
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
function handleInvite(msg: ServerMessage) {
|
|
48
|
+
if (msg.type === "mesh:invite_code") {
|
|
49
|
+
setGenerating(false);
|
|
50
|
+
}
|
|
43
51
|
}
|
|
44
52
|
|
|
45
|
-
function
|
|
53
|
+
function handlePaired(msg: ServerMessage) {
|
|
46
54
|
if (msg.type === "mesh:paired") {
|
|
47
55
|
setPairStatus("paired");
|
|
48
56
|
setPairError(null);
|
|
49
57
|
}
|
|
50
58
|
}
|
|
51
59
|
|
|
52
|
-
ws.subscribe("mesh:
|
|
60
|
+
ws.subscribe("mesh:invite_code", handleInvite);
|
|
61
|
+
ws.subscribe("mesh:paired", handlePaired);
|
|
53
62
|
return function () {
|
|
54
|
-
ws.unsubscribe("mesh:
|
|
63
|
+
ws.unsubscribe("mesh:invite_code", handleInvite);
|
|
64
|
+
ws.unsubscribe("mesh:paired", handlePaired);
|
|
55
65
|
};
|
|
56
|
-
}, [
|
|
66
|
+
}, []);
|
|
57
67
|
|
|
58
68
|
function handleGenerateInvite() {
|
|
59
69
|
clearInvite();
|
|
70
|
+
setGenerating(true);
|
|
60
71
|
mesh.generateInvite();
|
|
61
72
|
}
|
|
62
73
|
|
|
@@ -152,7 +163,7 @@ export function PairingDialog(props: PairingDialogProps) {
|
|
|
152
163
|
The code encodes this node's address and a one-time auth token.
|
|
153
164
|
</div>
|
|
154
165
|
|
|
155
|
-
{!mesh.inviteCode && (
|
|
166
|
+
{!mesh.inviteCode && !generating && (
|
|
156
167
|
<button
|
|
157
168
|
onClick={handleGenerateInvite}
|
|
158
169
|
className="btn btn-primary btn-sm"
|
|
@@ -161,6 +172,13 @@ export function PairingDialog(props: PairingDialogProps) {
|
|
|
161
172
|
</button>
|
|
162
173
|
)}
|
|
163
174
|
|
|
175
|
+
{generating && !mesh.inviteCode && (
|
|
176
|
+
<div className="flex items-center gap-2 text-[13px] text-base-content/40">
|
|
177
|
+
<Loader2 size={14} className="animate-spin text-primary" />
|
|
178
|
+
Generating invite code...
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
164
182
|
{mesh.inviteCode && (
|
|
165
183
|
<div>
|
|
166
184
|
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded bg-base-100 border border-base-300 mb-4">
|
|
@@ -180,17 +198,6 @@ export function PairingDialog(props: PairingDialogProps) {
|
|
|
180
198
|
</button>
|
|
181
199
|
</div>
|
|
182
200
|
|
|
183
|
-
{mesh.inviteQr && (
|
|
184
|
-
<div className="flex justify-center mb-4">
|
|
185
|
-
<img
|
|
186
|
-
src={mesh.inviteQr}
|
|
187
|
-
alt="QR code for invite"
|
|
188
|
-
className="w-40 h-40 rounded border border-base-300"
|
|
189
|
-
style={{ imageRendering: "pixelated" }}
|
|
190
|
-
/>
|
|
191
|
-
</div>
|
|
192
|
-
)}
|
|
193
|
-
|
|
194
201
|
<button
|
|
195
202
|
onClick={handleGenerateInvite}
|
|
196
203
|
className="text-[12px] text-base-content/40 underline cursor-pointer"
|
|
@@ -209,6 +216,7 @@ export function PairingDialog(props: PairingDialogProps) {
|
|
|
209
216
|
</div>
|
|
210
217
|
|
|
211
218
|
<input
|
|
219
|
+
ref={inputRef}
|
|
212
220
|
type="text"
|
|
213
221
|
value={pairCode}
|
|
214
222
|
onChange={function (e) {
|
|
@@ -224,6 +232,7 @@ export function PairingDialog(props: PairingDialogProps) {
|
|
|
224
232
|
}
|
|
225
233
|
}}
|
|
226
234
|
placeholder="LTCE-XXXX-XXXX"
|
|
235
|
+
autoFocus
|
|
227
236
|
disabled={pairStatus === "connecting" || pairStatus === "paired"}
|
|
228
237
|
className="input input-bordered w-full bg-base-100 text-base-content font-mono text-[14px] tracking-[0.06em] mb-3 focus:border-primary"
|
|
229
238
|
/>
|
|
@@ -267,4 +276,4 @@ export function PairingDialog(props: PairingDialogProps) {
|
|
|
267
276
|
</div>
|
|
268
277
|
</div>
|
|
269
278
|
);
|
|
270
|
-
}
|
|
279
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useState, useCallback, memo } from "react";
|
|
2
2
|
import { Plus, CircleDot, Circle } from "lucide-react";
|
|
3
3
|
import { useWebSocket } from "../../hooks/useWebSocket";
|
|
4
4
|
import { useMesh } from "../../hooks/useMesh";
|
|
@@ -83,6 +83,8 @@ export function MeshStatus() {
|
|
|
83
83
|
var { nodes } = useMesh();
|
|
84
84
|
var [pairingOpen, setPairingOpen] = useState(false);
|
|
85
85
|
|
|
86
|
+
var handleClosePairing = useCallback(function () { setPairingOpen(false); }, []);
|
|
87
|
+
|
|
86
88
|
function handleUnpair(nodeId: string) {
|
|
87
89
|
ws.send({ type: "mesh:unpair", nodeId });
|
|
88
90
|
}
|
|
@@ -138,7 +140,7 @@ export function MeshStatus() {
|
|
|
138
140
|
|
|
139
141
|
<PairingDialog
|
|
140
142
|
isOpen={pairingOpen}
|
|
141
|
-
onClose={
|
|
143
|
+
onClose={handleClosePairing}
|
|
142
144
|
/>
|
|
143
145
|
</div>
|
|
144
146
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cryptiklemur/lattice",
|
|
3
|
-
"version": "1.28.
|
|
3
|
+
"version": "1.28.3",
|
|
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,6 +4,22 @@ 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 { networkInterfaces } from "node:os";
|
|
8
|
+
|
|
9
|
+
function getLocalAddress(): string {
|
|
10
|
+
var interfaces = networkInterfaces();
|
|
11
|
+
var keys = Object.keys(interfaces);
|
|
12
|
+
for (var i = 0; i < keys.length; i++) {
|
|
13
|
+
var addrs = interfaces[keys[i]];
|
|
14
|
+
if (!addrs) continue;
|
|
15
|
+
for (var j = 0; j < addrs.length; j++) {
|
|
16
|
+
if (!addrs[j].internal && addrs[j].family === "IPv4") {
|
|
17
|
+
return addrs[j].address;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return "localhost";
|
|
22
|
+
}
|
|
7
23
|
import { addPeer, removePeer, loadPeers } from "../mesh/peers";
|
|
8
24
|
import type { PeerInfo } from "@lattice/shared";
|
|
9
25
|
|
|
@@ -42,7 +58,8 @@ export function buildNodesMessage(): NodeInfo[] {
|
|
|
42
58
|
registerHandler("mesh", function (clientId: string, message: ClientMessage) {
|
|
43
59
|
if (message.type === "mesh:generate_invite") {
|
|
44
60
|
var config = loadConfig();
|
|
45
|
-
|
|
61
|
+
var address = getLocalAddress();
|
|
62
|
+
generateInviteCode(address, config.port).then(function (result) {
|
|
46
63
|
sendTo(clientId, {
|
|
47
64
|
type: "mesh:invite_code",
|
|
48
65
|
code: result.code,
|
|
@@ -58,11 +75,11 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
|
|
|
58
75
|
var pairMsg = message as MeshPairMessage;
|
|
59
76
|
var parsed = parseInviteCode(pairMsg.code);
|
|
60
77
|
if (!parsed) {
|
|
61
|
-
|
|
78
|
+
sendTo(clientId, { type: "chat:error", message: "Invalid invite code format" });
|
|
62
79
|
return;
|
|
63
80
|
}
|
|
64
81
|
if (!validatePairingToken(parsed.token)) {
|
|
65
|
-
|
|
82
|
+
sendTo(clientId, { type: "chat:error", message: "Invite code is invalid or expired" });
|
|
66
83
|
return;
|
|
67
84
|
}
|
|
68
85
|
consumePairingToken(parsed.token);
|
|
@@ -110,6 +127,7 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
|
|
|
110
127
|
});
|
|
111
128
|
ws.addEventListener("error", function () {
|
|
112
129
|
console.error("[lattice] mesh:pair — failed to connect to", wsUrl);
|
|
130
|
+
sendTo(clientId, { type: "chat:error", message: "Failed to connect to " + parsed!.address + ":" + parsed!.port });
|
|
113
131
|
});
|
|
114
132
|
return;
|
|
115
133
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import QRCode from "qrcode";
|
|
3
2
|
|
|
4
3
|
var BASE62_CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz";
|
|
4
|
+
var CODE_LENGTH = 16;
|
|
5
5
|
|
|
6
6
|
var PAIRING_TOKEN_TTL = 300000;
|
|
7
7
|
var CLEANUP_INTERVAL = 60000;
|
|
@@ -16,7 +16,10 @@ function base62Encode(buf: Buffer): string {
|
|
|
16
16
|
result = BASE62_CHARS[Number(n % base)] + result;
|
|
17
17
|
n = n / base;
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
while (result.length < CODE_LENGTH) {
|
|
20
|
+
result = BASE62_CHARS[0] + result;
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
function base62Decode(s: string): Buffer {
|
|
@@ -36,11 +39,29 @@ function base62Decode(s: string): Buffer {
|
|
|
36
39
|
return Buffer.from(hex, "hex");
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
function packPayload(address: string, port: number, token: Buffer): Buffer {
|
|
43
|
+
var parts = address.split(".");
|
|
44
|
+
var buf = Buffer.alloc(4 + 2 + token.length);
|
|
45
|
+
for (var i = 0; i < 4; i++) {
|
|
46
|
+
buf[i] = parseInt(parts[i] || "0", 10);
|
|
47
|
+
}
|
|
48
|
+
buf.writeUInt16BE(port, 4);
|
|
49
|
+
token.copy(buf, 6);
|
|
50
|
+
return buf;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function unpackPayload(buf: Buffer): { address: string; port: number; token: string } | null {
|
|
54
|
+
if (buf.length < 8) return null;
|
|
55
|
+
var address = buf[0] + "." + buf[1] + "." + buf[2] + "." + buf[3];
|
|
56
|
+
var port = buf.readUInt16BE(4);
|
|
57
|
+
var token = buf.subarray(6).toString("hex");
|
|
58
|
+
return { address, port, token };
|
|
59
|
+
}
|
|
60
|
+
|
|
39
61
|
function formatCode(raw: string): string {
|
|
40
|
-
var upper = raw.toUpperCase();
|
|
41
62
|
var chunks: string[] = [];
|
|
42
|
-
for (var i = 0; i <
|
|
43
|
-
chunks.push(
|
|
63
|
+
for (var i = 0; i < raw.length; i += 4) {
|
|
64
|
+
chunks.push(raw.slice(i, i + 4));
|
|
44
65
|
}
|
|
45
66
|
return "LTCE-" + chunks.join("-");
|
|
46
67
|
}
|
|
@@ -53,16 +74,15 @@ export async function generateInviteCode(
|
|
|
53
74
|
address: string,
|
|
54
75
|
port: number
|
|
55
76
|
): Promise<{ code: string; token: string; qrDataUrl: string }> {
|
|
56
|
-
var
|
|
57
|
-
var
|
|
77
|
+
var tokenBuf = randomBytes(4);
|
|
78
|
+
var token = tokenBuf.toString("hex");
|
|
79
|
+
var payload = packPayload(address, port, tokenBuf);
|
|
58
80
|
var encoded = base62Encode(payload);
|
|
59
81
|
var code = formatCode(encoded);
|
|
60
82
|
|
|
61
83
|
pendingTokens.set(token, Date.now());
|
|
62
84
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return { code, token, qrDataUrl };
|
|
85
|
+
return { code, token, qrDataUrl: "" };
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
export function parseInviteCode(
|
|
@@ -70,19 +90,8 @@ export function parseInviteCode(
|
|
|
70
90
|
): { address: string; port: number; token: string } | null {
|
|
71
91
|
try {
|
|
72
92
|
var stripped = stripCode(code);
|
|
73
|
-
var decoded = base62Decode(stripped)
|
|
74
|
-
|
|
75
|
-
if (parts.length < 3) {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
var token = parts[parts.length - 1];
|
|
79
|
-
var portStr = parts[parts.length - 2];
|
|
80
|
-
var address = parts.slice(0, parts.length - 2).join(":");
|
|
81
|
-
var port = parseInt(portStr, 10);
|
|
82
|
-
if (isNaN(port)) {
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
return { address, port, token };
|
|
93
|
+
var decoded = base62Decode(stripped);
|
|
94
|
+
return unpackPayload(decoded);
|
|
86
95
|
} catch {
|
|
87
96
|
return null;
|
|
88
97
|
}
|