@cryptiklemur/lattice 1.14.1 → 1.15.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.
- package/.github/workflows/ci.yml +72 -0
- package/bun.lock +5 -1
- package/client/src/App.tsx +2 -0
- package/client/src/components/analytics/ChartCard.tsx +6 -10
- package/client/src/components/chat/ChatView.tsx +7 -9
- package/client/src/components/chat/ToolResultRenderer.tsx +6 -2
- package/client/src/components/mesh/PairingDialog.tsx +6 -17
- package/client/src/components/project-settings/ProjectMemory.tsx +10 -19
- package/client/src/components/sidebar/AddProjectModal.tsx +7 -11
- package/client/src/components/sidebar/NodeSettingsModal.tsx +6 -11
- package/client/src/components/sidebar/ProjectRail.tsx +11 -1
- package/client/src/components/sidebar/SessionList.tsx +4 -0
- package/client/src/components/ui/KeyboardShortcuts.tsx +129 -0
- package/client/src/components/ui/Toast.tsx +22 -2
- package/client/src/components/workspace/TaskEditModal.tsx +7 -2
- package/client/src/hooks/useFocusTrap.ts +72 -0
- package/client/src/hooks/useWebSocket.ts +1 -0
- package/client/src/providers/WebSocketProvider.tsx +17 -3
- package/client/src/router.tsx +6 -11
- package/package.json +1 -1
- package/server/package.json +2 -0
- package/server/src/auth/passphrase.ts +23 -3
- package/server/src/daemon.ts +29 -7
- package/server/src/handlers/attachment.ts +17 -1
- package/server/src/handlers/session.ts +64 -35
- package/server/src/index.ts +27 -8
- package/server/src/logger.ts +12 -0
- package/server/src/mesh/connector.ts +54 -4
- package/server/src/mesh/pairing.ts +23 -3
- package/server/src/project/sdk-bridge.ts +82 -6
- package/server/src/project/session.ts +5 -4
- package/server/src/ws/broadcast.ts +7 -0
- package/server/src/ws/router.ts +36 -21
- package/shared/src/models.ts +3 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useState, useRef, useCallback } from "react";
|
|
2
2
|
import { X } from "lucide-react";
|
|
3
|
+
import { useFocusTrap } from "../../hooks/useFocusTrap";
|
|
3
4
|
import cronstrue from "cronstrue";
|
|
4
5
|
import type { ScheduledTask } from "@lattice/shared";
|
|
5
6
|
|
|
@@ -25,6 +26,10 @@ export function TaskEditModal(props: TaskEditModalProps) {
|
|
|
25
26
|
var [prompt, setPrompt] = useState(task ? task.prompt : "");
|
|
26
27
|
var [cron, setCron] = useState(task ? task.cron : "0 9 * * 1-5");
|
|
27
28
|
|
|
29
|
+
var modalRef = useRef<HTMLDivElement>(null);
|
|
30
|
+
var stableOnClose = useCallback(function () { onClose(); }, [onClose]);
|
|
31
|
+
useFocusTrap(modalRef, stableOnClose);
|
|
32
|
+
|
|
28
33
|
var cronPreview = getCronPreview(cron);
|
|
29
34
|
var cronValid = cronPreview !== "Invalid cron expression" && cronPreview !== "";
|
|
30
35
|
|
|
@@ -40,12 +45,12 @@ export function TaskEditModal(props: TaskEditModalProps) {
|
|
|
40
45
|
|
|
41
46
|
return (
|
|
42
47
|
<div
|
|
48
|
+
ref={modalRef}
|
|
43
49
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
|
44
50
|
onClick={handleBackdrop}
|
|
45
51
|
role="dialog"
|
|
46
52
|
aria-modal="true"
|
|
47
53
|
aria-label={task ? "Edit Task" : "New Scheduled Task"}
|
|
48
|
-
onKeyDown={function (e) { if (e.key === "Escape") onClose(); }}
|
|
49
54
|
>
|
|
50
55
|
<div className="bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-md mx-4">
|
|
51
56
|
<div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15">
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
var FOCUSABLE_SELECTOR = [
|
|
4
|
+
"a[href]",
|
|
5
|
+
"button:not([disabled])",
|
|
6
|
+
"input:not([disabled])",
|
|
7
|
+
"select:not([disabled])",
|
|
8
|
+
"textarea:not([disabled])",
|
|
9
|
+
"[tabindex]:not([tabindex='-1'])",
|
|
10
|
+
].join(", ");
|
|
11
|
+
|
|
12
|
+
export function useFocusTrap(
|
|
13
|
+
containerRef: React.RefObject<HTMLElement | null>,
|
|
14
|
+
onClose: () => void,
|
|
15
|
+
active: boolean = true,
|
|
16
|
+
): void {
|
|
17
|
+
var previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(function () {
|
|
20
|
+
if (!active) return;
|
|
21
|
+
|
|
22
|
+
var container = containerRef.current;
|
|
23
|
+
if (!container) return;
|
|
24
|
+
|
|
25
|
+
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
|
|
26
|
+
|
|
27
|
+
var focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
28
|
+
if (focusable.length > 0) {
|
|
29
|
+
focusable[0].focus();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
33
|
+
if (e.key === "Escape") {
|
|
34
|
+
onClose();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (e.key !== "Tab") return;
|
|
39
|
+
|
|
40
|
+
var currentContainer = containerRef.current;
|
|
41
|
+
if (!currentContainer) return;
|
|
42
|
+
|
|
43
|
+
var elements = currentContainer.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
44
|
+
if (elements.length === 0) return;
|
|
45
|
+
|
|
46
|
+
var first = elements[0];
|
|
47
|
+
var last = elements[elements.length - 1];
|
|
48
|
+
|
|
49
|
+
if (e.shiftKey) {
|
|
50
|
+
if (document.activeElement === first) {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
last.focus();
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
if (document.activeElement === last) {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
first.focus();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
var el = container;
|
|
63
|
+
el.addEventListener("keydown", handleKeyDown);
|
|
64
|
+
|
|
65
|
+
return function () {
|
|
66
|
+
el.removeEventListener("keydown", handleKeyDown);
|
|
67
|
+
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === "function") {
|
|
68
|
+
previouslyFocusedRef.current.focus();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}, [containerRef, onClose, active]);
|
|
72
|
+
}
|
|
@@ -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/client/src/router.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createRouter, createRootRoute, createRoute, createMemoryHistory } from "@tanstack/react-router";
|
|
2
2
|
import { Outlet } from "@tanstack/react-router";
|
|
3
|
-
import { useState, useEffect, useRef } from "react";
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
|
+
import { useFocusTrap } from "./hooks/useFocusTrap";
|
|
4
5
|
import { Sidebar } from "./components/sidebar/Sidebar";
|
|
5
6
|
import { WorkspaceView } from "./components/workspace/WorkspaceView";
|
|
6
7
|
import { SetupWizard } from "./components/setup/SetupWizard";
|
|
@@ -299,6 +300,9 @@ function RemoveProjectConfirm() {
|
|
|
299
300
|
var sidebar = useSidebar();
|
|
300
301
|
var ws = useWebSocket();
|
|
301
302
|
var slug = sidebar.confirmRemoveSlug;
|
|
303
|
+
var removeModalRef = useRef<HTMLDivElement>(null);
|
|
304
|
+
var stableCloseRemove = useCallback(function () { sidebar.closeConfirmRemove(); }, [sidebar.closeConfirmRemove]);
|
|
305
|
+
useFocusTrap(removeModalRef, stableCloseRemove, !!slug);
|
|
302
306
|
|
|
303
307
|
if (!slug) return null;
|
|
304
308
|
|
|
@@ -311,17 +315,8 @@ function RemoveProjectConfirm() {
|
|
|
311
315
|
}
|
|
312
316
|
})();
|
|
313
317
|
|
|
314
|
-
useEffect(function () {
|
|
315
|
-
if (!slug) return;
|
|
316
|
-
function handleKeyDown(e: KeyboardEvent) {
|
|
317
|
-
if (e.key === "Escape") sidebar.closeConfirmRemove();
|
|
318
|
-
}
|
|
319
|
-
document.addEventListener("keydown", handleKeyDown);
|
|
320
|
-
return function () { document.removeEventListener("keydown", handleKeyDown); };
|
|
321
|
-
}, [slug]);
|
|
322
|
-
|
|
323
318
|
return (
|
|
324
|
-
<div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="Remove Project">
|
|
319
|
+
<div ref={removeModalRef} className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="Remove Project">
|
|
325
320
|
<div className="absolute inset-0 bg-black/50" onClick={sidebar.closeConfirmRemove} />
|
|
326
321
|
<div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden">
|
|
327
322
|
<div className="px-5 py-4 border-b border-base-content/15">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cryptiklemur/lattice",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.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>",
|
package/server/package.json
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
"@anthropic-ai/claude-agent-sdk": "^0.2.79",
|
|
12
12
|
"@lattice/shared": "workspace:*",
|
|
13
13
|
"bonjour-service": "^1.3.0",
|
|
14
|
+
"debug": "^4.4.3",
|
|
14
15
|
"js-tiktoken": "^1.0.21",
|
|
15
16
|
"node-pty": "^1.1.0",
|
|
16
17
|
"qrcode": "^1.5.4"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
20
|
+
"@types/debug": "^4.1.13",
|
|
19
21
|
"@types/qrcode": "^1.5.6",
|
|
20
22
|
"bun-types": "latest"
|
|
21
23
|
}
|
|
@@ -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
|
@@ -12,11 +12,14 @@ import { handleProxyRequest, handleProxyResponse } from "./mesh/proxy";
|
|
|
12
12
|
import { verifyPassphrase, generateSessionToken, addSession, isValidSession } from "./auth/passphrase";
|
|
13
13
|
import { ensureCerts } from "./tls";
|
|
14
14
|
import type { ClientMessage, MeshMessage } from "@lattice/shared";
|
|
15
|
+
import { log } from "./logger";
|
|
15
16
|
import "./handlers/session";
|
|
16
17
|
import "./handlers/chat";
|
|
17
18
|
import "./handlers/attachment";
|
|
18
19
|
import { loadInterruptedSessions, unwatchSessionLock, cleanupClientPermissions } from "./project/sdk-bridge";
|
|
19
20
|
import { clearActiveSession, getActiveSession } from "./handlers/chat";
|
|
21
|
+
import { clearActiveProject } from "./handlers/fs";
|
|
22
|
+
import { clearClientRemoteNode } from "./ws/router";
|
|
20
23
|
import "./handlers/fs";
|
|
21
24
|
import "./handlers/terminal";
|
|
22
25
|
import "./handlers/settings";
|
|
@@ -38,6 +41,10 @@ interface WsData {
|
|
|
38
41
|
id: string;
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
var RATE_LIMIT_WINDOW = 10000;
|
|
45
|
+
var RATE_LIMIT_MAX = 100;
|
|
46
|
+
var clientRateLimits = new Map<string, { count: number; windowStart: number }>();
|
|
47
|
+
|
|
41
48
|
function parseCookies(cookieHeader: string): Map<string, string> {
|
|
42
49
|
var map = new Map<string, string>();
|
|
43
50
|
var parts = cookieHeader.split(";");
|
|
@@ -188,8 +195,8 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
|
|
|
188
195
|
}
|
|
189
196
|
var identity = loadOrCreateIdentity();
|
|
190
197
|
|
|
191
|
-
|
|
192
|
-
|
|
198
|
+
log.server("Node: %s (%s)", config.name, identity.id);
|
|
199
|
+
log.server("Home: %s", getLatticeHome());
|
|
193
200
|
|
|
194
201
|
var clientDir = join(import.meta.dir, "../../client/dist");
|
|
195
202
|
|
|
@@ -201,7 +208,7 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
|
|
|
201
208
|
cert: readFileSync(certs.cert),
|
|
202
209
|
key: readFileSync(certs.key),
|
|
203
210
|
};
|
|
204
|
-
|
|
211
|
+
log.server("TLS enabled");
|
|
205
212
|
} catch (err) {
|
|
206
213
|
console.error("[lattice] Failed to load TLS certs, falling back to HTTP:", err);
|
|
207
214
|
}
|
|
@@ -291,16 +298,28 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
|
|
|
291
298
|
websocket: {
|
|
292
299
|
open(ws: ServerWebSocket<WsData>) {
|
|
293
300
|
addClient(ws);
|
|
294
|
-
|
|
301
|
+
log.ws("Client connected: %s", ws.data.id);
|
|
295
302
|
sendTo(ws.data.id, { type: "mesh:nodes", nodes: buildNodesMessage() });
|
|
296
303
|
},
|
|
297
304
|
message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
|
|
305
|
+
var now = Date.now();
|
|
306
|
+
var limit = clientRateLimits.get(ws.data.id);
|
|
307
|
+
if (!limit || now - limit.windowStart > RATE_LIMIT_WINDOW) {
|
|
308
|
+
limit = { count: 0, windowStart: now };
|
|
309
|
+
clientRateLimits.set(ws.data.id, limit);
|
|
310
|
+
}
|
|
311
|
+
limit.count++;
|
|
312
|
+
if (limit.count > RATE_LIMIT_MAX) {
|
|
313
|
+
sendTo(ws.data.id, { type: "chat:error", message: "Rate limit exceeded, please slow down" });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
298
317
|
var text = typeof message === "string" ? message : message.toString();
|
|
299
318
|
try {
|
|
300
319
|
var msg = JSON.parse(text) as ClientMessage;
|
|
301
320
|
routeMessage(ws.data.id, msg);
|
|
302
321
|
} catch (err) {
|
|
303
|
-
|
|
322
|
+
log.ws("Invalid JSON message: %O", err);
|
|
304
323
|
}
|
|
305
324
|
},
|
|
306
325
|
close(ws: ServerWebSocket<WsData>) {
|
|
@@ -309,16 +328,19 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
|
|
|
309
328
|
unwatchSessionLock(activeSession.sessionId);
|
|
310
329
|
}
|
|
311
330
|
clearActiveSession(ws.data.id);
|
|
331
|
+
clearActiveProject(ws.data.id);
|
|
332
|
+
clearClientRemoteNode(ws.data.id);
|
|
312
333
|
removeClient(ws.data.id);
|
|
313
334
|
cleanupClientTerminals(ws.data.id);
|
|
314
335
|
cleanupClientAttachments(ws.data.id);
|
|
315
336
|
cleanupClientPermissions(ws.data.id);
|
|
316
|
-
|
|
337
|
+
clientRateLimits.delete(ws.data.id);
|
|
338
|
+
log.ws("Client disconnected: %s", ws.data.id);
|
|
317
339
|
},
|
|
318
340
|
},
|
|
319
341
|
});
|
|
320
342
|
|
|
321
|
-
|
|
343
|
+
log.server("Listening on %s://0.0.0.0:%d", protocol, config.port);
|
|
322
344
|
|
|
323
345
|
startDiscovery(identity.id, config.name, config.port);
|
|
324
346
|
|
|
@@ -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",
|
|
@@ -25,6 +25,7 @@ import { getContextBreakdown } from "../project/context-breakdown";
|
|
|
25
25
|
import { setActiveSession } from "./chat";
|
|
26
26
|
import { setActiveProject } from "./fs";
|
|
27
27
|
import { wasSessionInterrupted, clearInterruptedFlag, isSessionBusy, watchSessionLock, stopExternalSession } from "../project/sdk-bridge";
|
|
28
|
+
import { log } from "../logger";
|
|
28
29
|
|
|
29
30
|
registerHandler("session", function (clientId: string, message: ClientMessage) {
|
|
30
31
|
if (message.type === "session:list_request") {
|
|
@@ -89,45 +90,73 @@ registerHandler("session", function (clientId: string, message: ClientMessage) {
|
|
|
89
90
|
setActiveProject(clientId, activateMsg.projectSlug);
|
|
90
91
|
watchSessionLock(activateMsg.sessionId);
|
|
91
92
|
void Promise.all([
|
|
92
|
-
loadSessionHistory(activateMsg.projectSlug, activateMsg.sessionId)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
loadSessionHistory(activateMsg.projectSlug, activateMsg.sessionId).catch(function (err) {
|
|
94
|
+
log.session("Failed to load session history: %O", err);
|
|
95
|
+
return null;
|
|
96
|
+
}),
|
|
97
|
+
getSessionTitle(activateMsg.projectSlug, activateMsg.sessionId).catch(function (err) {
|
|
98
|
+
log.session("Failed to load session title: %O", err);
|
|
99
|
+
return null;
|
|
100
|
+
}),
|
|
101
|
+
getSessionUsage(activateMsg.projectSlug, activateMsg.sessionId).catch(function (err) {
|
|
102
|
+
log.session("Failed to load session usage: %O", err);
|
|
103
|
+
return null;
|
|
104
|
+
}),
|
|
105
|
+
getContextBreakdown(activateMsg.projectSlug, activateMsg.sessionId).catch(function (err) {
|
|
106
|
+
log.session("Failed to load context breakdown: %O", err);
|
|
107
|
+
return null;
|
|
108
|
+
}),
|
|
96
109
|
]).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) {
|
|
110
|
+
try {
|
|
111
|
+
var interrupted = wasSessionInterrupted(activateMsg.sessionId);
|
|
112
|
+
if (interrupted) {
|
|
113
|
+
clearInterruptedFlag(activateMsg.sessionId);
|
|
114
|
+
}
|
|
115
|
+
var busy = isSessionBusy(activateMsg.sessionId);
|
|
113
116
|
sendTo(clientId, {
|
|
114
|
-
type: "
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
type: "session:history",
|
|
118
|
+
projectSlug: activateMsg.projectSlug,
|
|
119
|
+
sessionId: activateMsg.sessionId,
|
|
120
|
+
messages: results[0] || [],
|
|
121
|
+
title: results[1],
|
|
122
|
+
interrupted: interrupted || undefined,
|
|
123
|
+
busy: busy || undefined,
|
|
120
124
|
});
|
|
125
|
+
} catch (err) {
|
|
126
|
+
log.session("Error sending session history: %O", err);
|
|
127
|
+
sendTo(clientId, { type: "chat:error", message: "Failed to load session history" });
|
|
121
128
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
try {
|
|
130
|
+
var usage = results[2];
|
|
131
|
+
if (usage) {
|
|
132
|
+
sendTo(clientId, {
|
|
133
|
+
type: "chat:context_usage",
|
|
134
|
+
inputTokens: usage.inputTokens,
|
|
135
|
+
outputTokens: usage.outputTokens,
|
|
136
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
137
|
+
cacheCreationTokens: usage.cacheCreationTokens,
|
|
138
|
+
contextWindow: usage.contextWindow,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
log.session("Error sending context usage: %O", err);
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
var breakdown = results[3];
|
|
146
|
+
if (breakdown) {
|
|
147
|
+
sendTo(clientId, {
|
|
148
|
+
type: "chat:context_breakdown",
|
|
149
|
+
segments: breakdown.segments,
|
|
150
|
+
contextWindow: breakdown.contextWindow,
|
|
151
|
+
autocompactAt: breakdown.autocompactAt,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
log.session("Error sending context breakdown: %O", err);
|
|
130
156
|
}
|
|
157
|
+
}).catch(function (err) {
|
|
158
|
+
log.session("Failed to activate session: %O", err);
|
|
159
|
+
sendTo(clientId, { type: "chat:error", message: "Failed to activate session" });
|
|
131
160
|
});
|
|
132
161
|
return;
|
|
133
162
|
}
|
|
@@ -175,7 +204,7 @@ registerHandler("session", function (clientId: string, message: ClientMessage) {
|
|
|
175
204
|
var stopMsg = message as { type: string; sessionId: string };
|
|
176
205
|
var stopped = stopExternalSession(stopMsg.sessionId);
|
|
177
206
|
if (stopped) {
|
|
178
|
-
|
|
207
|
+
log.session("Sent SIGINT to external CLI process for session %s", stopMsg.sessionId);
|
|
179
208
|
} else {
|
|
180
209
|
sendTo(clientId, { type: "chat:error", message: "No external process found for this session." });
|
|
181
210
|
}
|
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
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import createDebug from "debug";
|
|
2
|
+
|
|
3
|
+
export var log = {
|
|
4
|
+
server: createDebug("lattice:server"),
|
|
5
|
+
ws: createDebug("lattice:ws"),
|
|
6
|
+
chat: createDebug("lattice:chat"),
|
|
7
|
+
session: createDebug("lattice:session"),
|
|
8
|
+
mesh: createDebug("lattice:mesh"),
|
|
9
|
+
auth: createDebug("lattice:auth"),
|
|
10
|
+
fs: createDebug("lattice:fs"),
|
|
11
|
+
analytics: createDebug("lattice:analytics"),
|
|
12
|
+
};
|