@cryptiklemur/lattice 1.38.0 → 1.40.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.
@@ -31,7 +31,7 @@ function groupProjectsBySlug(projects: ProjectInfo[], nodes: NodeInfo[]): Projec
31
31
  var nodeEntry = {
32
32
  nodeId: p.nodeId,
33
33
  nodeName: p.nodeName,
34
- online: node ? node.online : false,
34
+ online: node ? node.online : (p.online ?? !p.isRemote),
35
35
  path: p.path,
36
36
  };
37
37
  if (existing) {
@@ -0,0 +1,35 @@
1
+ import { WifiOff, Loader2 } from "lucide-react";
2
+ import { useProjects } from "../../hooks/useProjects";
3
+ import { useMesh } from "../../hooks/useMesh";
4
+ import { useSidebar } from "../../hooks/useSidebar";
5
+
6
+ export function NodeDisconnectedOverlay() {
7
+ var { activeProject } = useProjects();
8
+ var { nodes } = useMesh();
9
+ var sidebar = useSidebar();
10
+
11
+ if (!activeProject || !activeProject.isRemote) return null;
12
+ if (sidebar.activeView.type !== "chat") return null;
13
+
14
+ var remoteNode = nodes.find(function (n) { return n.id === activeProject!.nodeId; });
15
+ if (!remoteNode || remoteNode.online) return null;
16
+
17
+ return (
18
+ <div className="absolute inset-0 z-40 flex items-center justify-center bg-base-100/80 backdrop-blur-sm">
19
+ <div className="bg-base-300 border border-base-content/15 rounded-2xl shadow-2xl px-8 py-6 max-w-sm text-center">
20
+ <WifiOff size={28} className="text-warning mx-auto mb-3" />
21
+ <h3 className="text-[15px] font-mono font-bold text-base-content mb-1">Node Disconnected</h3>
22
+ <p className="text-[13px] text-base-content/50 mb-3">
23
+ <span className="font-semibold text-base-content/70">{remoteNode.name}</span> is unreachable.
24
+ </p>
25
+ <div className="flex items-center justify-center gap-2 text-[12px] text-base-content/40">
26
+ <Loader2 size={12} className="animate-spin" />
27
+ Attempting to reconnect...
28
+ </div>
29
+ <p className="text-[11px] text-base-content/25 mt-3">
30
+ Session state preserved. Operations will resume on reconnect.
31
+ </p>
32
+ </div>
33
+ </div>
34
+ );
35
+ }
@@ -22,14 +22,30 @@ export function useProjects(): UseProjectsResult {
22
22
  useEffect(function () {
23
23
  handleRef.current = function (msg: ServerMessage) {
24
24
  if (msg.type === "projects:list") {
25
- var list = (msg as ProjectsListMessage).projects;
26
- setProjects(list);
25
+ var incoming = (msg as ProjectsListMessage).projects;
26
+ setProjects(function (prev) {
27
+ var incomingKeys = new Set(incoming.map(function (p) { return p.slug + "@" + p.nodeId; }));
28
+ var kept = prev.filter(function (p) {
29
+ if (!p.isRemote) return false;
30
+ return !incomingKeys.has(p.slug + "@" + p.nodeId);
31
+ });
32
+ for (var i = 0; i < kept.length; i++) {
33
+ (kept[i] as any).online = false;
34
+ }
35
+ return incoming.concat(kept);
36
+ });
27
37
  var storeState = getSidebarStore().state;
28
38
  var currentSlug = storeState.activeProjectSlug;
29
39
  if (currentSlug !== null) {
30
- var found = list.find(function (p: typeof list[number]) { return p.slug === currentSlug; });
31
- if (!found && list.length > 0) {
32
- setActiveProjectSlug(list[0].slug);
40
+ var found = incoming.find(function (p: typeof incoming[number]) { return p.slug === currentSlug; });
41
+ if (!found) {
42
+ setProjects(function (current) {
43
+ var stillExists = current.find(function (p) { return p.slug === currentSlug; });
44
+ if (!stillExists && current.length > 0) {
45
+ setActiveProjectSlug(current[0].slug);
46
+ }
47
+ return current;
48
+ });
33
49
  }
34
50
  }
35
51
  }
@@ -16,6 +16,7 @@ import { useSidebar } from "./hooks/useSidebar";
16
16
  import { useWorkspace } from "./hooks/useWorkspace";
17
17
  import { useWebSocket } from "./hooks/useWebSocket";
18
18
  import { UpdateBanner } from "./components/ui/UpdateBanner";
19
+ import { NodeDisconnectedOverlay } from "./components/ui/NodeDisconnectedOverlay";
19
20
  import { useSwipeDrawer } from "./hooks/useSwipeDrawer";
20
21
  import { exitSettings, getSidebarStore, handlePopState, closeDrawer, toggleDrawer } from "./stores/sidebar";
21
22
 
@@ -422,9 +423,10 @@ function RootLayout() {
422
423
  readOnly
423
424
  />
424
425
 
425
- <main id="main-content" className="drawer-content flex flex-col h-full min-w-0 overflow-hidden">
426
+ <main id="main-content" className="drawer-content flex flex-col h-full min-w-0 overflow-hidden relative">
426
427
  <UpdateBanner />
427
428
  <Outlet />
429
+ <NodeDisconnectedOverlay />
428
430
  </main>
429
431
 
430
432
  <div ref={drawerSideRef} className="drawer-side z-50 h-full">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptiklemur/lattice",
3
- "version": "1.38.0",
3
+ "version": "1.40.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>",
@@ -401,7 +401,7 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
401
401
  var remoteProjects = getAllRemoteProjects(currentIdentity.id);
402
402
  broadcast({
403
403
  type: "projects:list",
404
- projects: localProjects.concat(remoteProjects as typeof localProjects),
404
+ projects: localProjects.concat(remoteProjects as unknown as typeof localProjects),
405
405
  });
406
406
  var updateInfo = getCachedUpdateInfo();
407
407
  if (updateInfo && updateInfo.updateAvailable) {
@@ -1,4 +1,5 @@
1
1
  import type { ClientMessage, MeshPairMessage, MeshUnpairMessage, NodeInfo } from "@lattice/shared";
2
+ import { log } from "../logger";
2
3
  import { registerHandler } from "../ws/router";
3
4
  import { sendTo, broadcast } from "../ws/broadcast";
4
5
  import { loadConfig } from "../config";
@@ -122,6 +123,8 @@ export function buildNodesMessage(): NodeInfo[] {
122
123
  }
123
124
 
124
125
  registerHandler("mesh", function (clientId: string, message: ClientMessage) {
126
+ log.meshHello("mesh message: %s from %s", (message as any).type, clientId.slice(0, 8));
127
+
125
128
  if (message.type === "mesh:generate_invite") {
126
129
  var genMsg = message as any as { type: "mesh:generate_invite"; address?: string };
127
130
  var config = loadConfig();
@@ -232,14 +235,17 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
232
235
  var hello = message as any as { type: "mesh:hello"; nodeId: string; name: string; publicKey?: string; token?: string; port?: number; addresses?: string[]; projects: Array<{ slug: string; title: string }> };
233
236
 
234
237
  var knownPeer = hello.nodeId ? getPeer(hello.nodeId) : undefined;
238
+ log.meshHello("mesh:hello from nodeId=%s name=%s known=%s", hello.nodeId?.slice(0, 8), hello.name, !!knownPeer);
235
239
 
236
240
  if (knownPeer) {
237
241
  if (knownPeer.publicKey && hello.publicKey && knownPeer.publicKey !== hello.publicKey) {
242
+ log.meshHello(" ✗ public key mismatch for %s", hello.name);
238
243
  sendTo(clientId, { type: "mesh:hello_rejected" as any, error: "Public key mismatch — possible impersonation" });
239
244
  return;
240
245
  }
241
246
 
242
247
  var inboundWs = getClientWebSocket(clientId);
248
+ log.meshHello(" registering inbound connection for %s (ws=%s)", hello.name, !!inboundWs);
243
249
  if (inboundWs) {
244
250
  registerInboundPeer(hello.nodeId, inboundWs as any);
245
251
  }
@@ -224,6 +224,9 @@ function runHelp(): void {
224
224
  console.log(" Environment:");
225
225
  console.log(" LATTICE_HOME Data directory (default: ~/.lattice)");
226
226
  console.log(" LATTICE_PORT Server port (default: 7654)");
227
+ console.log(" DEBUG Enable debug logging (e.g. DEBUG=lattice:*)");
228
+ console.log(" Scopes: server,ws,router,mesh,mesh:connect,mesh:hello,");
229
+ console.log(" mesh:proxy,broadcast,chat,session,plugins,update");
227
230
  console.log("");
228
231
  }
229
232
 
@@ -6,7 +6,16 @@ export var log = {
6
6
  chat: createDebug("lattice:chat"),
7
7
  session: createDebug("lattice:session"),
8
8
  mesh: createDebug("lattice:mesh"),
9
+ meshConnect: createDebug("lattice:mesh:connect"),
10
+ meshHello: createDebug("lattice:mesh:hello"),
11
+ meshProxy: createDebug("lattice:mesh:proxy"),
12
+ router: createDebug("lattice:router"),
13
+ broadcast: createDebug("lattice:broadcast"),
9
14
  auth: createDebug("lattice:auth"),
10
15
  fs: createDebug("lattice:fs"),
11
16
  analytics: createDebug("lattice:analytics"),
17
+ plugins: createDebug("lattice:plugins"),
18
+ update: createDebug("lattice:update"),
19
+ terminal: createDebug("lattice:terminal"),
20
+ settings: createDebug("lattice:settings"),
12
21
  };
@@ -17,6 +17,7 @@ interface PeerConnection {
17
17
  }
18
18
 
19
19
  var connections = new Map<string, PeerConnection>();
20
+ var lastKnownProjects = new Map<string, Array<{ slug: string; title: string }>>();
20
21
  var connectedCallbacks: Array<(nodeId: string) => void> = [];
21
22
  var disconnectedCallbacks: Array<(nodeId: string) => void> = [];
22
23
  var messageCallbacks: Array<(nodeId: string, msg: MeshMessage) => void> = [];
@@ -46,11 +47,18 @@ function reconcilePeers(): void {
46
47
  var peers = loadPeers();
47
48
  for (var i = 0; i < peers.length; i++) {
48
49
  var peer = peers[i];
49
- if (!peer.addresses || peer.addresses.length === 0) continue;
50
+ if (!peer.addresses || peer.addresses.length === 0) {
51
+ log.meshConnect("skip %s — no addresses", peer.name);
52
+ continue;
53
+ }
50
54
  var existing = connections.get(peer.id);
51
55
  if (existing && !existing.dead && existing.ws.readyState === WebSocket.OPEN) continue;
52
- if (existing && !existing.dead && existing.retryTimer !== null) continue;
56
+ if (existing && !existing.dead && existing.retryTimer !== null) {
57
+ log.meshConnect("skip %s — retry pending", peer.name);
58
+ continue;
59
+ }
53
60
  if (!existing || existing.dead) {
61
+ log.meshConnect("connecting to %s at %s", peer.name, peer.addresses[0]);
54
62
  connections.delete(peer.id);
55
63
  connectToPeer(peer.id, peer.addresses[0]);
56
64
  }
@@ -103,6 +111,7 @@ function openConnection(conn: PeerConnection, url: string): void {
103
111
  var circuit = circuitBreakers.get(conn.nodeId);
104
112
  if (circuit && circuit.failures >= CIRCUIT_BREAKER_THRESHOLD && !circuit.halfOpen) {
105
113
  if (Date.now() < circuit.openUntil) {
114
+ log.meshConnect("circuit breaker open for %s, retry in %dms", conn.nodeId.slice(0, 8), circuit.openUntil - Date.now());
106
115
  conn.retryTimer = setTimeout(function () {
107
116
  if (conn.dead) return;
108
117
  conn.retryTimer = null;
@@ -114,12 +123,13 @@ function openConnection(conn: PeerConnection, url: string): void {
114
123
  circuit.halfOpen = true;
115
124
  }
116
125
 
126
+ log.meshConnect("opening WebSocket to %s", url);
117
127
  var ws = new WebSocket(url);
118
128
  conn.ws = ws;
119
129
 
120
130
  var connectionTimer = setTimeout(function () {
121
131
  if (ws.readyState !== WebSocket.OPEN) {
122
- log.mesh("Connection timeout for peer: %s", conn.nodeId);
132
+ log.meshConnect("connection timeout for %s at %s", conn.nodeId.slice(0, 8), url);
123
133
  ws.close();
124
134
  }
125
135
  }, CONNECTION_TIMEOUT_MS);
@@ -167,6 +177,9 @@ function openConnection(conn: PeerConnection, url: string): void {
167
177
 
168
178
  if (msg.type === "mesh:hello") {
169
179
  conn.projects = msg.projects;
180
+ if (msg.projects.length > 0) {
181
+ lastKnownProjects.set(conn.nodeId, msg.projects);
182
+ }
170
183
  } else if (msg.type === "mesh:session_sync") {
171
184
  handleSessionSync(conn.nodeId, msg as MeshSessionSyncMessage);
172
185
  } else if (msg.type === "mesh:session_request") {
@@ -242,8 +255,10 @@ export function getPeerConnection(nodeId: string): WebSocket | undefined {
242
255
  export function registerInboundPeer(nodeId: string, ws: { send: (data: string) => void; readyState: number }): void {
243
256
  var existing = connections.get(nodeId);
244
257
  if (existing && !existing.dead && existing.ws.readyState === WebSocket.OPEN) {
258
+ log.meshConnect("inbound peer %s already connected, skipping", nodeId.slice(0, 8));
245
259
  return;
246
260
  }
261
+ log.meshConnect("registering inbound peer %s", nodeId.slice(0, 8));
247
262
 
248
263
  if (existing) {
249
264
  existing.dead = true;
@@ -347,21 +362,33 @@ export function getConnectedPeerProjects(nodeId: string): Array<{ slug: string;
347
362
  return conn.projects;
348
363
  }
349
364
 
350
- export function getAllRemoteProjects(localNodeId: string): Array<{ slug: string; path: string; title: string; nodeId: string; nodeName: string; isRemote: boolean }> {
351
- var results: Array<{ slug: string; path: string; title: string; nodeId: string; nodeName: string; isRemote: boolean }> = [];
352
- for (var [nodeId, conn] of connections) {
353
- if (conn.ws.readyState !== WebSocket.OPEN) continue;
354
- var peers = require("./peers") as typeof import("./peers");
355
- var peer = peers.getPeer(nodeId);
356
- var peerName = peer ? peer.name : nodeId;
357
- for (var i = 0; i < conn.projects.length; i++) {
365
+ export function getAllRemoteProjects(localNodeId: string): Array<{ slug: string; path: string; title: string; nodeId: string; nodeName: string; isRemote: boolean; online: boolean }> {
366
+ var peersModule = require("./peers") as typeof import("./peers");
367
+ var allPeers = peersModule.loadPeers();
368
+ var connectedIds = new Set(getConnectedPeerIds());
369
+ var results: Array<{ slug: string; path: string; title: string; nodeId: string; nodeName: string; isRemote: boolean; online: boolean }> = [];
370
+
371
+ for (var p = 0; p < allPeers.length; p++) {
372
+ var peer = allPeers[p];
373
+ var isOnline = connectedIds.has(peer.id);
374
+ var projects: Array<{ slug: string; title: string }> = [];
375
+
376
+ var conn = connections.get(peer.id);
377
+ if (conn && conn.projects.length > 0) {
378
+ projects = conn.projects;
379
+ } else if (lastKnownProjects.has(peer.id)) {
380
+ projects = lastKnownProjects.get(peer.id)!;
381
+ }
382
+
383
+ for (var i = 0; i < projects.length; i++) {
358
384
  results.push({
359
- slug: conn.projects[i].slug,
385
+ slug: projects[i].slug,
360
386
  path: "",
361
- title: conn.projects[i].title,
362
- nodeId: nodeId,
363
- nodeName: peerName,
387
+ title: projects[i].title,
388
+ nodeId: peer.id,
389
+ nodeName: peer.name,
364
390
  isRemote: true,
391
+ online: isOnline,
365
392
  });
366
393
  }
367
394
  }
@@ -3,19 +3,22 @@ import type { ClientMessage, MeshProxyRequestMessage, MeshProxyResponseMessage,
3
3
  import { getPeerConnection } from "./connector";
4
4
  import { sendTo, broadcast, registerVirtualClient, removeVirtualClient } from "../ws/broadcast";
5
5
  import { routeMessage } from "../ws/router";
6
+ import { log } from "../logger";
6
7
 
7
8
  var pendingRequests = new Map<string, string>();
8
9
 
9
10
  export function proxyToRemoteNode(nodeId: string, projectSlug: string, clientId: string, message: ClientMessage): void {
11
+ log.meshProxy("→ proxy %s to node %s for project %s", (message as any).type, nodeId.slice(0, 8), projectSlug);
10
12
  var ws = getPeerConnection(nodeId);
11
13
  if (!ws) {
12
- console.warn("[mesh/proxy] No connection to peer: " + nodeId);
13
- sendTo(clientId, { type: "chat:error", message: "Remote node " + nodeId + " is not connected" });
14
+ log.meshProxy(" no connection to node %s", nodeId.slice(0, 8));
15
+ sendTo(clientId, { type: "chat:error", message: "Remote node is not connected" });
14
16
  return;
15
17
  }
16
18
 
17
19
  var requestId = randomUUID();
18
20
  pendingRequests.set(requestId, clientId);
21
+ log.meshProxy(" envelope requestId=%s", requestId.slice(0, 8));
19
22
 
20
23
  var envelope: MeshProxyRequestMessage = {
21
24
  type: "mesh:proxy_request",
@@ -29,8 +32,10 @@ export function proxyToRemoteNode(nodeId: string, projectSlug: string, clientId:
29
32
 
30
33
  export function handleProxyRequest(sourceNodeId: string, msg: MeshProxyRequestMessage): void {
31
34
  var proxyClientId = "mesh-proxy:" + sourceNodeId + ":" + msg.requestId;
35
+ log.meshProxy("← proxy_request from %s: %s for %s (reqId=%s)", sourceNodeId.slice(0, 8), (msg.payload as any).type, msg.projectSlug, msg.requestId.slice(0, 8));
32
36
 
33
37
  registerVirtualClient(proxyClientId, function (response: object) {
38
+ log.meshProxy(" → proxy_response %s back to %s", (response as any).type, sourceNodeId.slice(0, 8));
34
39
  var ws = getPeerConnection(sourceNodeId);
35
40
  if (!ws) {
36
41
  console.warn("[mesh/proxy] Cannot send response, no connection to: " + sourceNodeId);
@@ -53,9 +58,10 @@ export function handleProxyRequest(sourceNodeId: string, msg: MeshProxyRequestMe
53
58
  }
54
59
 
55
60
  export function handleProxyResponse(msg: MeshProxyResponseMessage): void {
61
+ log.meshProxy("← proxy_response %s (reqId=%s)", (msg.payload as any).type, msg.requestId.slice(0, 8));
56
62
  var clientId = pendingRequests.get(msg.requestId);
57
63
  if (!clientId) {
58
- console.warn("[mesh/proxy] No pending request for id: " + msg.requestId);
64
+ log.meshProxy(" no pending request for %s", msg.requestId.slice(0, 8));
59
65
  return;
60
66
  }
61
67
 
@@ -37,6 +37,11 @@ export function sendTo(id: string, message: object): void {
37
37
  var virtualHandler = virtualSendHandlers.get(id);
38
38
  if (virtualHandler) {
39
39
  virtualHandler(message);
40
+ return;
41
+ }
42
+ if (id.startsWith("mesh-proxy:")) {
43
+ var { log } = require("../logger");
44
+ log.broadcast(" ✗ sendTo %s but no virtual handler registered (msg=%s)", id.slice(0, 30), (message as any).type);
40
45
  }
41
46
  }
42
47
 
@@ -53,6 +53,8 @@ export function getClientRemoteNode(clientId: string): { nodeId: string; project
53
53
  export function routeMessage(clientId: string, message: ClientMessage): void {
54
54
  var prefix = message.type.split(":")[0];
55
55
 
56
+ log.router("→ %s from client %s (prefix=%s)", message.type, clientId.slice(0, 8), prefix);
57
+
56
58
  if (PROXIED_PREFIXES.has(prefix)) {
57
59
  var remote = clientRemoteNode.get(clientId);
58
60
 
@@ -60,17 +62,21 @@ export function routeMessage(clientId: string, message: ClientMessage): void {
60
62
 
61
63
  if (msgSlug) {
62
64
  var localProject = getLocalProject(msgSlug);
65
+ log.router(" slug=%s local=%s", msgSlug, localProject);
63
66
  if (!localProject) {
64
67
  var remoteEntry = getRemoteNodeForProject(msgSlug);
65
68
  if (remoteEntry) {
69
+ log.router(" → proxying to remote node %s for project %s", remoteEntry.nodeId.slice(0, 8), msgSlug);
66
70
  setClientRemoteNode(clientId, remoteEntry.nodeId, msgSlug);
67
71
  proxyMessage(clientId, remoteEntry.nodeId, msgSlug, message);
68
72
  return;
69
73
  }
74
+ log.router(" ✗ no remote node found for slug %s", msgSlug);
70
75
  } else if (message.type === "session:activate" || message.type === "session:list_request") {
71
76
  clearClientRemoteNode(clientId);
72
77
  }
73
78
  } else if (remote) {
79
+ log.router(" → proxying via cached remote node %s", remote.nodeId.slice(0, 8));
74
80
  proxyMessage(clientId, remote.nodeId, remote.projectSlug, message);
75
81
  return;
76
82
  }
@@ -78,6 +84,7 @@ export function routeMessage(clientId: string, message: ClientMessage): void {
78
84
 
79
85
  var handler = handlers.get(prefix);
80
86
  if (handler) {
87
+ log.router(" → dispatching to %s handler", prefix);
81
88
  try {
82
89
  var result = handler(clientId, message);
83
90
  if (result && typeof result.then === "function") {
@@ -94,7 +101,7 @@ export function routeMessage(clientId: string, message: ClientMessage): void {
94
101
  }
95
102
  return;
96
103
  }
97
- log.ws("No handler for message type: %s", message.type);
104
+ log.router(" no handler for %s", message.type);
98
105
  sendTo(clientId, { type: "error", message: `Unknown message type: ${message.type}` });
99
106
  }
100
107
 
@@ -21,6 +21,7 @@ export interface ProjectSummary {
21
21
  export interface ProjectInfo extends ProjectSummary {
22
22
  nodeName: string;
23
23
  isRemote: boolean;
24
+ online?: boolean;
24
25
  ideProjectName?: string;
25
26
  }
26
27