@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.
Files changed (34) hide show
  1. package/.github/workflows/ci.yml +72 -0
  2. package/bun.lock +5 -1
  3. package/client/src/App.tsx +2 -0
  4. package/client/src/components/analytics/ChartCard.tsx +6 -10
  5. package/client/src/components/chat/ChatView.tsx +7 -9
  6. package/client/src/components/chat/ToolResultRenderer.tsx +6 -2
  7. package/client/src/components/mesh/PairingDialog.tsx +6 -17
  8. package/client/src/components/project-settings/ProjectMemory.tsx +10 -19
  9. package/client/src/components/sidebar/AddProjectModal.tsx +7 -11
  10. package/client/src/components/sidebar/NodeSettingsModal.tsx +6 -11
  11. package/client/src/components/sidebar/ProjectRail.tsx +11 -1
  12. package/client/src/components/sidebar/SessionList.tsx +4 -0
  13. package/client/src/components/ui/KeyboardShortcuts.tsx +129 -0
  14. package/client/src/components/ui/Toast.tsx +22 -2
  15. package/client/src/components/workspace/TaskEditModal.tsx +7 -2
  16. package/client/src/hooks/useFocusTrap.ts +72 -0
  17. package/client/src/hooks/useWebSocket.ts +1 -0
  18. package/client/src/providers/WebSocketProvider.tsx +17 -3
  19. package/client/src/router.tsx +6 -11
  20. package/package.json +1 -1
  21. package/server/package.json +2 -0
  22. package/server/src/auth/passphrase.ts +23 -3
  23. package/server/src/daemon.ts +29 -7
  24. package/server/src/handlers/attachment.ts +17 -1
  25. package/server/src/handlers/session.ts +64 -35
  26. package/server/src/index.ts +27 -8
  27. package/server/src/logger.ts +12 -0
  28. package/server/src/mesh/connector.ts +54 -4
  29. package/server/src/mesh/pairing.ts +23 -3
  30. package/server/src/project/sdk-bridge.ts +82 -6
  31. package/server/src/project/session.ts +5 -4
  32. package/server/src/ws/broadcast.ts +7 -0
  33. package/server/src/ws/router.ts +36 -21
  34. 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
  );
@@ -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.14.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>",
@@ -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 activeSessions = new Set<string>();
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.add(token);
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
- return activeSessions.has(token);
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);
@@ -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
- console.log(`[lattice] Node: ${config.name} (${identity.id})`);
192
- console.log(`[lattice] Home: ${getLatticeHome()}`);
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
- console.log("[lattice] TLS enabled");
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
- console.log(`[lattice] Client connected: ${ws.data.id}`);
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
- console.error("[lattice] Invalid JSON message:", err);
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
- console.log(`[lattice] Client disconnected: ${ws.data.id}`);
337
+ clientRateLimits.delete(ws.data.id);
338
+ log.ws("Client disconnected: %s", ws.data.id);
317
339
  },
318
340
  },
319
341
  });
320
342
 
321
- console.log(`[lattice] Listening on ${protocol}://0.0.0.0:${config.port}`);
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
- pending.chunks.set(msg.chunkIndex, Buffer.from(msg.data, "base64"));
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
- getSessionTitle(activateMsg.projectSlug, activateMsg.sessionId),
94
- getSessionUsage(activateMsg.projectSlug, activateMsg.sessionId),
95
- getContextBreakdown(activateMsg.projectSlug, activateMsg.sessionId),
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
- var interrupted = wasSessionInterrupted(activateMsg.sessionId);
98
- if (interrupted) {
99
- clearInterruptedFlag(activateMsg.sessionId);
100
- }
101
- var busy = isSessionBusy(activateMsg.sessionId);
102
- sendTo(clientId, {
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: "chat:context_usage",
115
- inputTokens: usage.inputTokens,
116
- outputTokens: usage.outputTokens,
117
- cacheReadTokens: usage.cacheReadTokens,
118
- cacheCreationTokens: usage.cacheCreationTokens,
119
- contextWindow: usage.contextWindow,
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
- var breakdown = results[3];
123
- if (breakdown) {
124
- sendTo(clientId, {
125
- type: "chat:context_breakdown",
126
- segments: breakdown.segments,
127
- contextWindow: breakdown.contextWindow,
128
- autocompactAt: breakdown.autocompactAt,
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
- console.log("[lattice] Sent SIGINT to external CLI process for session " + stopMsg.sessionId);
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
  }
@@ -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
- process.on("SIGTERM", function () {
86
- removePid();
87
- process.exit(0);
88
- });
89
- process.on("SIGINT", function () {
90
- removePid();
91
- process.exit(0);
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
+ };