@cryptiklemur/lattice 1.14.1 → 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.
@@ -92,7 +92,7 @@ export function ChatView() {
92
92
  if (msg.type === "user") return 100;
93
93
  return 200;
94
94
  },
95
- overscan: 30,
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
  });
@@ -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.1",
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 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);
@@ -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
- 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",
@@ -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
- getSessionTitle(activateMsg.projectSlug, activateMsg.sessionId),
94
- getSessionUsage(activateMsg.projectSlug, activateMsg.sessionId),
95
- getContextBreakdown(activateMsg.projectSlug, activateMsg.sessionId),
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
- 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) {
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: "chat:context_usage",
115
- inputTokens: usage.inputTokens,
116
- outputTokens: usage.outputTokens,
117
- cacheReadTokens: usage.cacheReadTokens,
118
- cacheCreationTokens: usage.cacheCreationTokens,
119
- contextWindow: usage.contextWindow,
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
- 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
- });
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
  }
@@ -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
 
@@ -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
- console.log("[mesh] Disconnected from peer: " + conn.nodeId + ", reconnecting in " + conn.backoffMs + "ms");
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 pendingTokens = new Set<string>();
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.add(token);
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
- return pendingTokens.has(token);
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
- return Promise.resolve({ behavior: "allow", updatedInput: input, toolUseID: options.toolUseID } as PermissionResult);
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
+ }
@@ -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
- try {
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
- try {
85
- var { findNodeForProject } = require("../mesh/connector") as typeof import("../mesh/connector");
86
- var nodeId = findNodeForProject(slug);
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
- var { proxyToRemoteNode } = require("../mesh/proxy") as typeof import("../mesh/proxy");
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" });