@cryptiklemur/lattice 1.14.0 → 1.14.2
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.
- package/client/src/components/chat/ChatView.tsx +2 -8
- package/client/src/components/sidebar/SessionList.tsx +4 -0
- package/client/src/hooks/useSession.ts +10 -2
- package/client/src/hooks/useWebSocket.ts +1 -0
- package/client/src/providers/WebSocketProvider.tsx +17 -3
- package/package.json +1 -1
- package/server/src/auth/passphrase.ts +23 -3
- package/server/src/daemon.ts +21 -0
- package/server/src/handlers/attachment.ts +17 -1
- package/server/src/handlers/session.ts +62 -34
- package/server/src/index.ts +27 -8
- package/server/src/mesh/connector.ts +50 -1
- package/server/src/mesh/pairing.ts +23 -3
- package/server/src/project/sdk-bridge.ts +20 -3
- package/server/src/ws/broadcast.ts +7 -0
- package/server/src/ws/router.ts +31 -17
|
@@ -92,7 +92,7 @@ export function ChatView() {
|
|
|
92
92
|
if (msg.type === "user") return 100;
|
|
93
93
|
return 200;
|
|
94
94
|
},
|
|
95
|
-
overscan:
|
|
95
|
+
overscan: isMobile ? 10 : 20,
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
var scrollToBottom = useCallback(function () {
|
|
@@ -122,18 +122,12 @@ export function ChatView() {
|
|
|
122
122
|
requestAnimationFrame(function () {
|
|
123
123
|
var el = scrollParentRef.current;
|
|
124
124
|
if (el) el.scrollTop = el.scrollHeight;
|
|
125
|
-
requestAnimationFrame(function () {
|
|
126
|
-
if (el) el.scrollTop = el.scrollHeight;
|
|
127
|
-
});
|
|
128
125
|
});
|
|
129
126
|
} else {
|
|
130
127
|
var count = messages.length;
|
|
131
128
|
var virt = virtualizer;
|
|
132
129
|
requestAnimationFrame(function () {
|
|
133
130
|
virt.scrollToIndex(count - 1, { align: "end" });
|
|
134
|
-
requestAnimationFrame(function () {
|
|
135
|
-
virt.scrollToIndex(count - 1, { align: "end" });
|
|
136
|
-
});
|
|
137
131
|
});
|
|
138
132
|
}
|
|
139
133
|
return;
|
|
@@ -252,7 +246,7 @@ export function ChatView() {
|
|
|
252
246
|
filled = contextUsage.inputTokens + contextUsage.cacheReadTokens + contextUsage.cacheCreationTokens;
|
|
253
247
|
percent = Math.min(100, Math.round((filled / contextUsage.contextWindow) * 100));
|
|
254
248
|
}
|
|
255
|
-
var autocompact = contextBreakdown ? Math.round((contextBreakdown.autocompactAt / contextBreakdown.contextWindow) * 100) : 90;
|
|
249
|
+
var autocompact = contextBreakdown && contextBreakdown.contextWindow > 0 ? Math.round((contextBreakdown.autocompactAt / contextBreakdown.contextWindow) * 100) : 90;
|
|
256
250
|
return {
|
|
257
251
|
contextPercent: percent,
|
|
258
252
|
contextFilled: filled,
|
|
@@ -248,6 +248,10 @@ export function SessionList(props: SessionListProps) {
|
|
|
248
248
|
var previewMsg = msg as SessionPreviewMessage;
|
|
249
249
|
setPreviews(function (prev3) {
|
|
250
250
|
var next = new Map(prev3);
|
|
251
|
+
if (next.size >= 100 && !next.has(previewMsg.sessionId)) {
|
|
252
|
+
var oldest = next.keys().next().value;
|
|
253
|
+
if (oldest !== undefined) next.delete(oldest);
|
|
254
|
+
}
|
|
251
255
|
next.set(previewMsg.sessionId, previewMsg.preview);
|
|
252
256
|
return next;
|
|
253
257
|
});
|
|
@@ -57,6 +57,7 @@ import type { SessionState } from "../stores/session";
|
|
|
57
57
|
|
|
58
58
|
var subscriptionsActive = 0;
|
|
59
59
|
var activeStreamGeneration = 0;
|
|
60
|
+
var streamSessionId: string | null = null;
|
|
60
61
|
var lastSentText: string | null = null;
|
|
61
62
|
var lastUsedModel: string | undefined = undefined;
|
|
62
63
|
var lastUsedEffort: string | undefined = undefined;
|
|
@@ -104,6 +105,7 @@ export function useSession(): UseSessionReturn {
|
|
|
104
105
|
msg.effort = effort;
|
|
105
106
|
}
|
|
106
107
|
activeStreamGeneration = getStreamGeneration();
|
|
108
|
+
streamSessionId = currentSessionId;
|
|
107
109
|
lastSentText = text;
|
|
108
110
|
lastUsedModel = model;
|
|
109
111
|
lastUsedEffort = effort;
|
|
@@ -123,7 +125,10 @@ export function useSession(): UseSessionReturn {
|
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
function isStaleStream(): boolean {
|
|
126
|
-
|
|
128
|
+
if (activeStreamGeneration !== getStreamGeneration()) return true;
|
|
129
|
+
var currentActiveId = getSessionStore().state.activeSessionId;
|
|
130
|
+
if (streamSessionId && currentActiveId && streamSessionId !== currentActiveId) return true;
|
|
131
|
+
return false;
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
function handleUserMessage(msg: ServerMessage) {
|
|
@@ -272,7 +277,10 @@ export function useSession(): UseSessionReturn {
|
|
|
272
277
|
if (m.sessionId) {
|
|
273
278
|
var projectSlug = m.projectSlug || getSessionStore().state.activeProjectSlug;
|
|
274
279
|
setSidebarSessionId(m.sessionId);
|
|
275
|
-
|
|
280
|
+
streamSessionId = m.sessionId;
|
|
281
|
+
if (m.busy) {
|
|
282
|
+
activeStreamGeneration = getStreamGeneration();
|
|
283
|
+
}
|
|
276
284
|
getSessionStore().setState(function (state) {
|
|
277
285
|
return {
|
|
278
286
|
...state,
|
|
@@ -8,6 +8,7 @@ export interface WebSocketContextValue {
|
|
|
8
8
|
send: (msg: ClientMessage) => void;
|
|
9
9
|
subscribe: (type: string, callback: (msg: ServerMessage) => void) => void;
|
|
10
10
|
unsubscribe: (type: string, callback: (msg: ServerMessage) => void) => void;
|
|
11
|
+
reconnectNow: () => void;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export var WebSocketContext = createContext<WebSocketContextValue | null>(null);
|
|
@@ -80,7 +80,8 @@ export function WebSocketProvider(props: WebSocketProviderProps) {
|
|
|
80
80
|
cb(msg);
|
|
81
81
|
});
|
|
82
82
|
}
|
|
83
|
-
} catch {
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.warn("[lattice] Failed to parse WebSocket message:", err);
|
|
84
85
|
}
|
|
85
86
|
};
|
|
86
87
|
|
|
@@ -91,7 +92,7 @@ export function WebSocketProvider(props: WebSocketProviderProps) {
|
|
|
91
92
|
setStatus("disconnected");
|
|
92
93
|
wsRef.current = null;
|
|
93
94
|
if (hasConnectedRef.current) {
|
|
94
|
-
showToast("Disconnected from daemon. Reconnecting...", "warning");
|
|
95
|
+
showToast("Disconnected from daemon. Reconnecting automatically...", "warning");
|
|
95
96
|
sendNotification("Lattice", "Lost connection to daemon", "connection");
|
|
96
97
|
}
|
|
97
98
|
scheduleReconnect();
|
|
@@ -113,6 +114,19 @@ export function WebSocketProvider(props: WebSocketProviderProps) {
|
|
|
113
114
|
}, delay);
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
function reconnectNow() {
|
|
118
|
+
if (retryTimerRef.current !== null) {
|
|
119
|
+
clearTimeout(retryTimerRef.current);
|
|
120
|
+
retryTimerRef.current = null;
|
|
121
|
+
}
|
|
122
|
+
backoffRef.current = 1000;
|
|
123
|
+
if (wsRef.current) {
|
|
124
|
+
wsRef.current.close();
|
|
125
|
+
wsRef.current = null;
|
|
126
|
+
}
|
|
127
|
+
connect();
|
|
128
|
+
}
|
|
129
|
+
|
|
116
130
|
function send(msg: ClientMessage) {
|
|
117
131
|
var ws = wsRef.current;
|
|
118
132
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
@@ -157,7 +171,7 @@ export function WebSocketProvider(props: WebSocketProviderProps) {
|
|
|
157
171
|
}, []);
|
|
158
172
|
|
|
159
173
|
return (
|
|
160
|
-
<WebSocketContext.Provider value={{ status, send, subscribe, unsubscribe }}>
|
|
174
|
+
<WebSocketContext.Provider value={{ status, send, subscribe, unsubscribe, reconnectNow }}>
|
|
161
175
|
{props.children}
|
|
162
176
|
</WebSocketContext.Provider>
|
|
163
177
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cryptiklemur/lattice",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.2",
|
|
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>",
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
var
|
|
3
|
+
var TOKEN_TTL = 86400000;
|
|
4
|
+
var CLEANUP_INTERVAL = 600000;
|
|
5
|
+
|
|
6
|
+
var activeSessions = new Map<string, number>();
|
|
4
7
|
|
|
5
8
|
export function hashPassphrase(passphrase: string): string {
|
|
6
9
|
var salt = randomBytes(16).toString("hex");
|
|
@@ -32,7 +35,7 @@ export function generateSessionToken(): string {
|
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
export function addSession(token: string): void {
|
|
35
|
-
activeSessions.
|
|
38
|
+
activeSessions.set(token, Date.now());
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
export function removeSession(token: string): void {
|
|
@@ -40,9 +43,26 @@ export function removeSession(token: string): void {
|
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
export function isValidSession(token: string): boolean {
|
|
43
|
-
|
|
46
|
+
var createdAt = activeSessions.get(token);
|
|
47
|
+
if (createdAt === undefined) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (Date.now() - createdAt > TOKEN_TTL) {
|
|
51
|
+
activeSessions.delete(token);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
export function clearSessions(): void {
|
|
47
58
|
activeSessions.clear();
|
|
48
59
|
}
|
|
60
|
+
|
|
61
|
+
setInterval(function () {
|
|
62
|
+
var now = Date.now();
|
|
63
|
+
activeSessions.forEach(function (createdAt, token) {
|
|
64
|
+
if (now - createdAt > TOKEN_TTL) {
|
|
65
|
+
activeSessions.delete(token);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}, CLEANUP_INTERVAL);
|
package/server/src/daemon.ts
CHANGED
|
@@ -17,6 +17,8 @@ import "./handlers/chat";
|
|
|
17
17
|
import "./handlers/attachment";
|
|
18
18
|
import { loadInterruptedSessions, unwatchSessionLock, cleanupClientPermissions } from "./project/sdk-bridge";
|
|
19
19
|
import { clearActiveSession, getActiveSession } from "./handlers/chat";
|
|
20
|
+
import { clearActiveProject } from "./handlers/fs";
|
|
21
|
+
import { clearClientRemoteNode } from "./ws/router";
|
|
20
22
|
import "./handlers/fs";
|
|
21
23
|
import "./handlers/terminal";
|
|
22
24
|
import "./handlers/settings";
|
|
@@ -38,6 +40,10 @@ interface WsData {
|
|
|
38
40
|
id: string;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
var RATE_LIMIT_WINDOW = 10000;
|
|
44
|
+
var RATE_LIMIT_MAX = 100;
|
|
45
|
+
var clientRateLimits = new Map<string, { count: number; windowStart: number }>();
|
|
46
|
+
|
|
41
47
|
function parseCookies(cookieHeader: string): Map<string, string> {
|
|
42
48
|
var map = new Map<string, string>();
|
|
43
49
|
var parts = cookieHeader.split(";");
|
|
@@ -295,6 +301,18 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
|
|
|
295
301
|
sendTo(ws.data.id, { type: "mesh:nodes", nodes: buildNodesMessage() });
|
|
296
302
|
},
|
|
297
303
|
message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
|
|
304
|
+
var now = Date.now();
|
|
305
|
+
var limit = clientRateLimits.get(ws.data.id);
|
|
306
|
+
if (!limit || now - limit.windowStart > RATE_LIMIT_WINDOW) {
|
|
307
|
+
limit = { count: 0, windowStart: now };
|
|
308
|
+
clientRateLimits.set(ws.data.id, limit);
|
|
309
|
+
}
|
|
310
|
+
limit.count++;
|
|
311
|
+
if (limit.count > RATE_LIMIT_MAX) {
|
|
312
|
+
sendTo(ws.data.id, { type: "chat:error", message: "Rate limit exceeded, please slow down" });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
298
316
|
var text = typeof message === "string" ? message : message.toString();
|
|
299
317
|
try {
|
|
300
318
|
var msg = JSON.parse(text) as ClientMessage;
|
|
@@ -309,10 +327,13 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
|
|
|
309
327
|
unwatchSessionLock(activeSession.sessionId);
|
|
310
328
|
}
|
|
311
329
|
clearActiveSession(ws.data.id);
|
|
330
|
+
clearActiveProject(ws.data.id);
|
|
331
|
+
clearClientRemoteNode(ws.data.id);
|
|
312
332
|
removeClient(ws.data.id);
|
|
313
333
|
cleanupClientTerminals(ws.data.id);
|
|
314
334
|
cleanupClientAttachments(ws.data.id);
|
|
315
335
|
cleanupClientPermissions(ws.data.id);
|
|
336
|
+
clientRateLimits.delete(ws.data.id);
|
|
316
337
|
console.log(`[lattice] Client disconnected: ${ws.data.id}`);
|
|
317
338
|
},
|
|
318
339
|
},
|
|
@@ -3,10 +3,13 @@ import type { AttachmentChunkMessage, AttachmentCompleteMessage, ClientMessage }
|
|
|
3
3
|
import { registerHandler } from "../ws/router";
|
|
4
4
|
import { sendTo } from "../ws/broadcast";
|
|
5
5
|
|
|
6
|
+
var MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
|
7
|
+
|
|
6
8
|
interface PendingUpload {
|
|
7
9
|
chunks: Map<number, Buffer>;
|
|
8
10
|
totalChunks: number;
|
|
9
11
|
receivedCount: number;
|
|
12
|
+
totalBytes: number;
|
|
10
13
|
createdAt: number;
|
|
11
14
|
}
|
|
12
15
|
|
|
@@ -45,6 +48,7 @@ registerHandler("attachment", function (clientId: string, message: ClientMessage
|
|
|
45
48
|
chunks: new Map(),
|
|
46
49
|
totalChunks: msg.totalChunks,
|
|
47
50
|
receivedCount: 0,
|
|
51
|
+
totalBytes: 0,
|
|
48
52
|
createdAt: Date.now(),
|
|
49
53
|
};
|
|
50
54
|
store.set(msg.attachmentId, pending);
|
|
@@ -59,8 +63,20 @@ registerHandler("attachment", function (clientId: string, message: ClientMessage
|
|
|
59
63
|
return;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
|
|
66
|
+
var chunkBuffer = Buffer.from(msg.data, "base64");
|
|
67
|
+
if (pending.totalBytes + chunkBuffer.length > MAX_ATTACHMENT_SIZE) {
|
|
68
|
+
store.delete(msg.attachmentId);
|
|
69
|
+
sendTo(clientId, {
|
|
70
|
+
type: "attachment:error",
|
|
71
|
+
attachmentId: msg.attachmentId,
|
|
72
|
+
error: "Attachment exceeds maximum size of " + (MAX_ATTACHMENT_SIZE / 1024 / 1024) + "MB",
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pending.chunks.set(msg.chunkIndex, chunkBuffer);
|
|
63
78
|
pending.receivedCount++;
|
|
79
|
+
pending.totalBytes += chunkBuffer.length;
|
|
64
80
|
|
|
65
81
|
sendTo(clientId, {
|
|
66
82
|
type: "attachment:progress",
|
|
@@ -89,45 +89,73 @@ registerHandler("session", function (clientId: string, message: ClientMessage) {
|
|
|
89
89
|
setActiveProject(clientId, activateMsg.projectSlug);
|
|
90
90
|
watchSessionLock(activateMsg.sessionId);
|
|
91
91
|
void Promise.all([
|
|
92
|
-
loadSessionHistory(activateMsg.projectSlug, activateMsg.sessionId)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
loadSessionHistory(activateMsg.projectSlug, activateMsg.sessionId).catch(function (err) {
|
|
93
|
+
console.error("[lattice] Failed to load session history:", err);
|
|
94
|
+
return null;
|
|
95
|
+
}),
|
|
96
|
+
getSessionTitle(activateMsg.projectSlug, activateMsg.sessionId).catch(function (err) {
|
|
97
|
+
console.error("[lattice] Failed to load session title:", err);
|
|
98
|
+
return null;
|
|
99
|
+
}),
|
|
100
|
+
getSessionUsage(activateMsg.projectSlug, activateMsg.sessionId).catch(function (err) {
|
|
101
|
+
console.error("[lattice] Failed to load session usage:", err);
|
|
102
|
+
return null;
|
|
103
|
+
}),
|
|
104
|
+
getContextBreakdown(activateMsg.projectSlug, activateMsg.sessionId).catch(function (err) {
|
|
105
|
+
console.error("[lattice] Failed to load context breakdown:", err);
|
|
106
|
+
return null;
|
|
107
|
+
}),
|
|
96
108
|
]).then(function (results) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
type: "session:history",
|
|
104
|
-
projectSlug: activateMsg.projectSlug,
|
|
105
|
-
sessionId: activateMsg.sessionId,
|
|
106
|
-
messages: results[0],
|
|
107
|
-
title: results[1],
|
|
108
|
-
interrupted: interrupted || undefined,
|
|
109
|
-
busy: busy || undefined,
|
|
110
|
-
});
|
|
111
|
-
var usage = results[2];
|
|
112
|
-
if (usage) {
|
|
109
|
+
try {
|
|
110
|
+
var interrupted = wasSessionInterrupted(activateMsg.sessionId);
|
|
111
|
+
if (interrupted) {
|
|
112
|
+
clearInterruptedFlag(activateMsg.sessionId);
|
|
113
|
+
}
|
|
114
|
+
var busy = isSessionBusy(activateMsg.sessionId);
|
|
113
115
|
sendTo(clientId, {
|
|
114
|
-
type: "
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
type: "session:history",
|
|
117
|
+
projectSlug: activateMsg.projectSlug,
|
|
118
|
+
sessionId: activateMsg.sessionId,
|
|
119
|
+
messages: results[0] || [],
|
|
120
|
+
title: results[1],
|
|
121
|
+
interrupted: interrupted || undefined,
|
|
122
|
+
busy: busy || undefined,
|
|
120
123
|
});
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error("[lattice] Error sending session history:", err);
|
|
126
|
+
sendTo(clientId, { type: "chat:error", message: "Failed to load session history" });
|
|
121
127
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
try {
|
|
129
|
+
var usage = results[2];
|
|
130
|
+
if (usage) {
|
|
131
|
+
sendTo(clientId, {
|
|
132
|
+
type: "chat:context_usage",
|
|
133
|
+
inputTokens: usage.inputTokens,
|
|
134
|
+
outputTokens: usage.outputTokens,
|
|
135
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
136
|
+
cacheCreationTokens: usage.cacheCreationTokens,
|
|
137
|
+
contextWindow: usage.contextWindow,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error("[lattice] Error sending context usage:", err);
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
var breakdown = results[3];
|
|
145
|
+
if (breakdown) {
|
|
146
|
+
sendTo(clientId, {
|
|
147
|
+
type: "chat:context_breakdown",
|
|
148
|
+
segments: breakdown.segments,
|
|
149
|
+
contextWindow: breakdown.contextWindow,
|
|
150
|
+
autocompactAt: breakdown.autocompactAt,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.error("[lattice] Error sending context breakdown:", err);
|
|
130
155
|
}
|
|
156
|
+
}).catch(function (err) {
|
|
157
|
+
console.error("[lattice] Failed to activate session:", err);
|
|
158
|
+
sendTo(clientId, { type: "chat:error", message: "Failed to activate session" });
|
|
131
159
|
});
|
|
132
160
|
return;
|
|
133
161
|
}
|
package/server/src/index.ts
CHANGED
|
@@ -82,14 +82,33 @@ switch (command) {
|
|
|
82
82
|
async function runDaemon(): Promise<void> {
|
|
83
83
|
var { startDaemon } = await import("./daemon");
|
|
84
84
|
writePid(process.pid);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
85
|
+
var shutdownInProgress = false;
|
|
86
|
+
function gracefulShutdown(): void {
|
|
87
|
+
if (shutdownInProgress) return;
|
|
88
|
+
shutdownInProgress = true;
|
|
89
|
+
console.log("[lattice] Shutting down gracefully...");
|
|
90
|
+
|
|
91
|
+
var { broadcast, closeAllClients } = require("./ws/broadcast") as typeof import("./ws/broadcast");
|
|
92
|
+
var { getActiveStreamCount } = require("./project/sdk-bridge") as typeof import("./project/sdk-bridge");
|
|
93
|
+
var { stopMeshConnections } = require("./mesh/connector") as typeof import("./mesh/connector");
|
|
94
|
+
|
|
95
|
+
broadcast({ type: "chat:error", message: "Server is shutting down" });
|
|
96
|
+
stopMeshConnections();
|
|
97
|
+
|
|
98
|
+
var waited = 0;
|
|
99
|
+
var checkInterval = setInterval(function () {
|
|
100
|
+
var activeCount = getActiveStreamCount();
|
|
101
|
+
waited += 500;
|
|
102
|
+
if (activeCount === 0 || waited >= 5000) {
|
|
103
|
+
clearInterval(checkInterval);
|
|
104
|
+
closeAllClients();
|
|
105
|
+
removePid();
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
}, 500);
|
|
109
|
+
}
|
|
110
|
+
process.on("SIGTERM", gracefulShutdown);
|
|
111
|
+
process.on("SIGINT", gracefulShutdown);
|
|
93
112
|
await startDaemon(portOverride);
|
|
94
113
|
}
|
|
95
114
|
|
|
@@ -22,6 +22,17 @@ var messageCallbacks: Array<(nodeId: string, msg: MeshMessage) => void> = [];
|
|
|
22
22
|
|
|
23
23
|
var MIN_BACKOFF_MS = 1000;
|
|
24
24
|
var MAX_BACKOFF_MS = 30000;
|
|
25
|
+
var CONNECTION_TIMEOUT_MS = 10000;
|
|
26
|
+
var CIRCUIT_BREAKER_THRESHOLD = 5;
|
|
27
|
+
var CIRCUIT_BREAKER_COOLDOWN = 60000;
|
|
28
|
+
|
|
29
|
+
interface CircuitState {
|
|
30
|
+
failures: number;
|
|
31
|
+
openUntil: number;
|
|
32
|
+
halfOpen: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
var circuitBreakers = new Map<string, CircuitState>();
|
|
25
36
|
|
|
26
37
|
export function startMeshConnections(): void {
|
|
27
38
|
var peers = loadPeers();
|
|
@@ -72,15 +83,38 @@ export function connectToPeer(nodeId: string, address: string): void {
|
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
function openConnection(conn: PeerConnection, url: string): void {
|
|
86
|
+
var circuit = circuitBreakers.get(conn.nodeId);
|
|
87
|
+
if (circuit && circuit.failures >= CIRCUIT_BREAKER_THRESHOLD && !circuit.halfOpen) {
|
|
88
|
+
if (Date.now() < circuit.openUntil) {
|
|
89
|
+
conn.retryTimer = setTimeout(function () {
|
|
90
|
+
if (conn.dead) return;
|
|
91
|
+
conn.retryTimer = null;
|
|
92
|
+
circuit!.halfOpen = true;
|
|
93
|
+
openConnection(conn, url);
|
|
94
|
+
}, circuit.openUntil - Date.now());
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
circuit.halfOpen = true;
|
|
98
|
+
}
|
|
99
|
+
|
|
75
100
|
var ws = new WebSocket(url);
|
|
76
101
|
conn.ws = ws;
|
|
77
102
|
|
|
103
|
+
var connectionTimer = setTimeout(function () {
|
|
104
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
105
|
+
console.error("[mesh] Connection timeout for peer: " + conn.nodeId);
|
|
106
|
+
ws.close();
|
|
107
|
+
}
|
|
108
|
+
}, CONNECTION_TIMEOUT_MS);
|
|
109
|
+
|
|
78
110
|
ws.addEventListener("open", function () {
|
|
111
|
+
clearTimeout(connectionTimer);
|
|
79
112
|
if (conn.dead) {
|
|
80
113
|
ws.close();
|
|
81
114
|
return;
|
|
82
115
|
}
|
|
83
116
|
|
|
117
|
+
circuitBreakers.delete(conn.nodeId);
|
|
84
118
|
conn.backoffMs = MIN_BACKOFF_MS;
|
|
85
119
|
|
|
86
120
|
var identity = loadOrCreateIdentity();
|
|
@@ -134,11 +168,26 @@ function openConnection(conn: PeerConnection, url: string): void {
|
|
|
134
168
|
});
|
|
135
169
|
|
|
136
170
|
ws.addEventListener("close", function () {
|
|
171
|
+
clearTimeout(connectionTimer);
|
|
137
172
|
if (conn.dead) {
|
|
138
173
|
return;
|
|
139
174
|
}
|
|
140
175
|
|
|
141
|
-
|
|
176
|
+
var circuit = circuitBreakers.get(conn.nodeId);
|
|
177
|
+
if (!circuit) {
|
|
178
|
+
circuit = { failures: 0, openUntil: 0, halfOpen: false };
|
|
179
|
+
circuitBreakers.set(conn.nodeId, circuit);
|
|
180
|
+
}
|
|
181
|
+
circuit.failures++;
|
|
182
|
+
|
|
183
|
+
if (circuit.halfOpen) {
|
|
184
|
+
circuit.halfOpen = false;
|
|
185
|
+
circuit.openUntil = Date.now() + CIRCUIT_BREAKER_COOLDOWN;
|
|
186
|
+
console.log("[mesh] Circuit breaker open for peer: " + conn.nodeId + " (half-open attempt failed)");
|
|
187
|
+
} else if (circuit.failures >= CIRCUIT_BREAKER_THRESHOLD) {
|
|
188
|
+
circuit.openUntil = Date.now() + CIRCUIT_BREAKER_COOLDOWN;
|
|
189
|
+
console.log("[mesh] Circuit breaker open for peer: " + conn.nodeId + " after " + circuit.failures + " consecutive failures");
|
|
190
|
+
}
|
|
142
191
|
|
|
143
192
|
for (var i = 0; i < disconnectedCallbacks.length; i++) {
|
|
144
193
|
disconnectedCallbacks[i](conn.nodeId);
|
|
@@ -3,7 +3,10 @@ import QRCode from "qrcode";
|
|
|
3
3
|
|
|
4
4
|
var BASE62_CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz";
|
|
5
5
|
|
|
6
|
-
var
|
|
6
|
+
var PAIRING_TOKEN_TTL = 300000;
|
|
7
|
+
var CLEANUP_INTERVAL = 60000;
|
|
8
|
+
|
|
9
|
+
var pendingTokens = new Map<string, number>();
|
|
7
10
|
|
|
8
11
|
function base62Encode(buf: Buffer): string {
|
|
9
12
|
var n = BigInt("0x" + buf.toString("hex"));
|
|
@@ -55,7 +58,7 @@ export async function generateInviteCode(
|
|
|
55
58
|
var encoded = base62Encode(payload);
|
|
56
59
|
var code = formatCode(encoded);
|
|
57
60
|
|
|
58
|
-
pendingTokens.
|
|
61
|
+
pendingTokens.set(token, Date.now());
|
|
59
62
|
|
|
60
63
|
var qrDataUrl = await QRCode.toString(code, { type: "svg" });
|
|
61
64
|
|
|
@@ -86,9 +89,26 @@ export function parseInviteCode(
|
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
export function validatePairingToken(token: string): boolean {
|
|
89
|
-
|
|
92
|
+
var createdAt = pendingTokens.get(token);
|
|
93
|
+
if (createdAt === undefined) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (Date.now() - createdAt > PAIRING_TOKEN_TTL) {
|
|
97
|
+
pendingTokens.delete(token);
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
export function consumePairingToken(token: string): void {
|
|
93
104
|
pendingTokens.delete(token);
|
|
94
105
|
}
|
|
106
|
+
|
|
107
|
+
setInterval(function () {
|
|
108
|
+
var now = Date.now();
|
|
109
|
+
pendingTokens.forEach(function (createdAt, token) {
|
|
110
|
+
if (now - createdAt > PAIRING_TOKEN_TTL) {
|
|
111
|
+
pendingTokens.delete(token);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}, CLEANUP_INTERVAL);
|
|
@@ -5,7 +5,7 @@ import type { CanUseTool, PermissionMode, PermissionResult, PermissionUpdate } f
|
|
|
5
5
|
import type { MessageParam } from "@anthropic-ai/sdk/resources";
|
|
6
6
|
import type { Attachment } from "@lattice/shared";
|
|
7
7
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { sendTo, broadcast } from "../ws/broadcast";
|
|
11
11
|
import { syncSessionToPeers } from "../mesh/session-sync";
|
|
@@ -58,6 +58,7 @@ export function getAvailableModels(): ModelEntry[] {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
var activeStreams = new Map<string, Query>();
|
|
61
|
+
var pendingStreams = new Set<string>();
|
|
61
62
|
var streamMetadata = new Map<string, { projectSlug: string; clientId: string; startedAt: number }>();
|
|
62
63
|
var interruptedSessions = new Set<string>();
|
|
63
64
|
|
|
@@ -174,6 +175,10 @@ export function getActiveStream(sessionId: string): Query | undefined {
|
|
|
174
175
|
return activeStreams.get(sessionId);
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
export function getActiveStreamCount(): number {
|
|
179
|
+
return activeStreams.size;
|
|
180
|
+
}
|
|
181
|
+
|
|
177
182
|
/**
|
|
178
183
|
* Check if a session is controlled by an external process (not Lattice).
|
|
179
184
|
* Lattice's own active streams are handled by isProcessing on the client,
|
|
@@ -339,11 +344,13 @@ export function startChatStream(options: ChatStreamOptions): void {
|
|
|
339
344
|
var { projectSlug, sessionId, text, attachments, clientId, cwd, env, model, effort, isNewSession } = options;
|
|
340
345
|
var startTime = Date.now();
|
|
341
346
|
|
|
342
|
-
if (activeStreams.has(sessionId)) {
|
|
347
|
+
if (activeStreams.has(sessionId) || pendingStreams.has(sessionId)) {
|
|
343
348
|
sendTo(clientId, { type: "chat:error", message: "Session already has an active stream." });
|
|
344
349
|
return;
|
|
345
350
|
}
|
|
346
351
|
|
|
352
|
+
pendingStreams.add(sessionId);
|
|
353
|
+
|
|
347
354
|
var projectSettingsPath = join(cwd, ".claude", "settings.json");
|
|
348
355
|
var savedAdditionalDirs: string[] = [];
|
|
349
356
|
var latticeDefaults: Record<string, unknown> = {};
|
|
@@ -442,7 +449,15 @@ export function startChatStream(options: ChatStreamOptions): void {
|
|
|
442
449
|
if (toolName === "Bash") {
|
|
443
450
|
var cmd = ((input.command || "") as string).trim();
|
|
444
451
|
if (cmd.startsWith("cd ")) {
|
|
445
|
-
|
|
452
|
+
var cdTarget = cmd.slice(3).trim().replace(/^["']|["']$/g, "");
|
|
453
|
+
if (cdTarget.startsWith("~")) {
|
|
454
|
+
cdTarget = join(homedir(), cdTarget.slice(1));
|
|
455
|
+
}
|
|
456
|
+
var cdResolved = resolve(cwd, cdTarget);
|
|
457
|
+
var home = homedir();
|
|
458
|
+
if (cdResolved.startsWith(cwd) || cdResolved === cwd || cdResolved.startsWith(home) || cdResolved === home) {
|
|
459
|
+
return Promise.resolve({ behavior: "allow", updatedInput: input, toolUseID: options.toolUseID } as PermissionResult);
|
|
460
|
+
}
|
|
446
461
|
}
|
|
447
462
|
}
|
|
448
463
|
|
|
@@ -606,6 +621,7 @@ export function startChatStream(options: ChatStreamOptions): void {
|
|
|
606
621
|
}
|
|
607
622
|
|
|
608
623
|
var stream = query({ prompt: queryPrompt, options: queryOptions });
|
|
624
|
+
pendingStreams.delete(sessionId);
|
|
609
625
|
activeStreams.set(sessionId, stream);
|
|
610
626
|
streamMetadata.set(sessionId, { projectSlug, clientId, startedAt: Date.now() });
|
|
611
627
|
persistStreamState();
|
|
@@ -621,6 +637,7 @@ export function startChatStream(options: ChatStreamOptions): void {
|
|
|
621
637
|
console.error("[lattice] SDK stream error: " + errMsg);
|
|
622
638
|
sendTo(clientId, { type: "chat:error", message: errMsg });
|
|
623
639
|
} finally {
|
|
640
|
+
pendingStreams.delete(sessionId);
|
|
624
641
|
activeStreams.delete(sessionId);
|
|
625
642
|
streamMetadata.delete(sessionId);
|
|
626
643
|
persistStreamState();
|
|
@@ -29,3 +29,10 @@ export function sendTo(id: string, message: object): void {
|
|
|
29
29
|
export function getClientCount(): number {
|
|
30
30
|
return clients.size;
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
export function closeAllClients(): void {
|
|
34
|
+
for (var [, ws] of clients) {
|
|
35
|
+
ws.close();
|
|
36
|
+
}
|
|
37
|
+
clients.clear();
|
|
38
|
+
}
|
package/server/src/ws/router.ts
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
import type { ClientMessage } from "@lattice/shared";
|
|
2
2
|
import { sendTo } from "./broadcast";
|
|
3
3
|
|
|
4
|
+
var _registry: typeof import("../project/registry") | null = null;
|
|
5
|
+
var _connector: typeof import("../mesh/connector") | null = null;
|
|
6
|
+
var _proxy: typeof import("../mesh/proxy") | null = null;
|
|
7
|
+
|
|
8
|
+
function getRegistry(): typeof import("../project/registry") {
|
|
9
|
+
if (!_registry) {
|
|
10
|
+
_registry = require("../project/registry") as typeof import("../project/registry");
|
|
11
|
+
}
|
|
12
|
+
return _registry;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getConnector(): typeof import("../mesh/connector") {
|
|
16
|
+
if (!_connector) {
|
|
17
|
+
_connector = require("../mesh/connector") as typeof import("../mesh/connector");
|
|
18
|
+
}
|
|
19
|
+
return _connector;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getProxy(): typeof import("../mesh/proxy") {
|
|
23
|
+
if (!_proxy) {
|
|
24
|
+
_proxy = require("../mesh/proxy") as typeof import("../mesh/proxy");
|
|
25
|
+
}
|
|
26
|
+
return _proxy;
|
|
27
|
+
}
|
|
28
|
+
|
|
4
29
|
type Handler = (clientId: string, message: ClientMessage) => void | Promise<void>;
|
|
5
30
|
|
|
6
31
|
var handlers = new Map<string, Handler>();
|
|
@@ -72,31 +97,20 @@ export function routeMessage(clientId: string, message: ClientMessage): void {
|
|
|
72
97
|
}
|
|
73
98
|
|
|
74
99
|
function getLocalProject(slug: string): boolean {
|
|
75
|
-
|
|
76
|
-
var { getProjectBySlug } = require("../project/registry") as typeof import("../project/registry");
|
|
77
|
-
return getProjectBySlug(slug) !== undefined;
|
|
78
|
-
} catch {
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
100
|
+
return getRegistry().getProjectBySlug(slug) !== undefined;
|
|
81
101
|
}
|
|
82
102
|
|
|
83
103
|
function getRemoteNodeForProject(slug: string): { nodeId: string } | undefined {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (nodeId) {
|
|
88
|
-
return { nodeId: nodeId };
|
|
89
|
-
}
|
|
90
|
-
return undefined;
|
|
91
|
-
} catch {
|
|
92
|
-
return undefined;
|
|
104
|
+
var nodeId = getConnector().findNodeForProject(slug);
|
|
105
|
+
if (nodeId) {
|
|
106
|
+
return { nodeId: nodeId };
|
|
93
107
|
}
|
|
108
|
+
return undefined;
|
|
94
109
|
}
|
|
95
110
|
|
|
96
111
|
function proxyMessage(clientId: string, nodeId: string, projectSlug: string, message: ClientMessage): void {
|
|
97
112
|
try {
|
|
98
|
-
|
|
99
|
-
proxyToRemoteNode(nodeId, projectSlug, clientId, message);
|
|
113
|
+
getProxy().proxyToRemoteNode(nodeId, projectSlug, clientId, message);
|
|
100
114
|
} catch (err) {
|
|
101
115
|
console.error("[router] Failed to proxy message:", err);
|
|
102
116
|
sendTo(clientId, { type: "chat:error", message: "Failed to proxy message to remote node" });
|