@cryptiklemur/lattice 0.0.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/.editorconfig +12 -0
- package/.github/workflows/release.yml +44 -0
- package/.impeccable.md +66 -0
- package/.releaserc.json +32 -0
- package/.serena/project.yml +138 -0
- package/CLAUDE.md +35 -0
- package/CONTRIBUTING.md +93 -0
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/bun.lock +1459 -0
- package/bunfig.toml +2 -0
- package/client/index.html +32 -0
- package/client/package.json +37 -0
- package/client/public/icons/icon-192.svg +11 -0
- package/client/public/icons/icon-512.svg +11 -0
- package/client/public/manifest.json +24 -0
- package/client/public/sw.js +61 -0
- package/client/src/App.tsx +28 -0
- package/client/src/components/auth/PassphrasePrompt.tsx +70 -0
- package/client/src/components/chat/ChatInput.tsx +241 -0
- package/client/src/components/chat/ChatView.tsx +727 -0
- package/client/src/components/chat/Message.tsx +362 -0
- package/client/src/components/chat/ModelSelector.tsx +87 -0
- package/client/src/components/chat/PermissionModeSelector.tsx +41 -0
- package/client/src/components/chat/StatusBar.tsx +50 -0
- package/client/src/components/chat/ToolGroup.tsx +129 -0
- package/client/src/components/chat/ToolResultRenderer.tsx +343 -0
- package/client/src/components/chat/toolSummary.ts +41 -0
- package/client/src/components/dashboard/DashboardView.tsx +219 -0
- package/client/src/components/dashboard/ProjectDashboardView.tsx +168 -0
- package/client/src/components/mesh/NodeBadge.tsx +24 -0
- package/client/src/components/mesh/PairingDialog.tsx +281 -0
- package/client/src/components/panels/FileBrowser.tsx +241 -0
- package/client/src/components/panels/StickyNotes.tsx +187 -0
- package/client/src/components/panels/Terminal.tsx +128 -0
- package/client/src/components/project-settings/ProjectClaude.tsx +304 -0
- package/client/src/components/project-settings/ProjectEnvironment.tsx +235 -0
- package/client/src/components/project-settings/ProjectGeneral.tsx +76 -0
- package/client/src/components/project-settings/ProjectMcp.tsx +232 -0
- package/client/src/components/project-settings/ProjectPermissions.tsx +209 -0
- package/client/src/components/project-settings/ProjectRules.tsx +277 -0
- package/client/src/components/project-settings/ProjectSettingsView.tsx +99 -0
- package/client/src/components/project-settings/ProjectSkills.tsx +91 -0
- package/client/src/components/settings/Appearance.tsx +151 -0
- package/client/src/components/settings/ClaudeSettings.tsx +151 -0
- package/client/src/components/settings/Environment.tsx +185 -0
- package/client/src/components/settings/GlobalMcp.tsx +207 -0
- package/client/src/components/settings/GlobalSkills.tsx +125 -0
- package/client/src/components/settings/MeshStatus.tsx +145 -0
- package/client/src/components/settings/SettingsView.tsx +57 -0
- package/client/src/components/settings/SkillMarketplace.tsx +175 -0
- package/client/src/components/settings/mcp-shared.tsx +194 -0
- package/client/src/components/settings/skill-shared.tsx +177 -0
- package/client/src/components/setup/SetupWizard.tsx +750 -0
- package/client/src/components/sidebar/NodeSettingsModal.tsx +180 -0
- package/client/src/components/sidebar/ProjectDropdown.tsx +43 -0
- package/client/src/components/sidebar/ProjectRail.tsx +291 -0
- package/client/src/components/sidebar/SearchFilter.tsx +52 -0
- package/client/src/components/sidebar/SessionList.tsx +384 -0
- package/client/src/components/sidebar/SettingsSidebar.tsx +128 -0
- package/client/src/components/sidebar/Sidebar.tsx +209 -0
- package/client/src/components/sidebar/UserIsland.tsx +59 -0
- package/client/src/components/sidebar/UserMenu.tsx +101 -0
- package/client/src/components/ui/CommandPalette.tsx +321 -0
- package/client/src/components/ui/ErrorBoundary.tsx +56 -0
- package/client/src/components/ui/IconPicker.tsx +209 -0
- package/client/src/components/ui/LatticeLogomark.tsx +19 -0
- package/client/src/components/ui/PopupMenu.tsx +98 -0
- package/client/src/components/ui/SaveFooter.tsx +38 -0
- package/client/src/components/ui/Toast.tsx +112 -0
- package/client/src/hooks/useMesh.ts +89 -0
- package/client/src/hooks/useProjectSettings.ts +56 -0
- package/client/src/hooks/useProjects.ts +66 -0
- package/client/src/hooks/useSaveState.ts +59 -0
- package/client/src/hooks/useSession.ts +317 -0
- package/client/src/hooks/useSidebar.ts +74 -0
- package/client/src/hooks/useSkills.ts +30 -0
- package/client/src/hooks/useTheme.ts +114 -0
- package/client/src/hooks/useWebSocket.ts +26 -0
- package/client/src/main.tsx +10 -0
- package/client/src/providers/WebSocketProvider.tsx +146 -0
- package/client/src/router.tsx +391 -0
- package/client/src/stores/mesh.ts +78 -0
- package/client/src/stores/session.ts +322 -0
- package/client/src/stores/sidebar.ts +336 -0
- package/client/src/stores/theme.ts +44 -0
- package/client/src/styles/global.css +167 -0
- package/client/src/styles/theme-vars.css +18 -0
- package/client/src/themes/index.ts +79 -0
- package/client/src/utils/findDuplicateKeys.ts +12 -0
- package/client/tsconfig.json +14 -0
- package/client/vite.config.ts +20 -0
- package/package.json +46 -0
- package/server/package.json +22 -0
- package/server/src/auth/passphrase.ts +48 -0
- package/server/src/config.ts +55 -0
- package/server/src/daemon.ts +338 -0
- package/server/src/features/ralph-loop.ts +173 -0
- package/server/src/features/scheduler.ts +281 -0
- package/server/src/features/sticky-notes.ts +102 -0
- package/server/src/handlers/chat.ts +194 -0
- package/server/src/handlers/fs.ts +84 -0
- package/server/src/handlers/loop.ts +37 -0
- package/server/src/handlers/mesh.ts +125 -0
- package/server/src/handlers/notes.ts +45 -0
- package/server/src/handlers/project-settings.ts +174 -0
- package/server/src/handlers/scheduler.ts +47 -0
- package/server/src/handlers/session.ts +159 -0
- package/server/src/handlers/settings.ts +109 -0
- package/server/src/handlers/skills.ts +380 -0
- package/server/src/handlers/terminal.ts +70 -0
- package/server/src/identity.ts +26 -0
- package/server/src/index.ts +190 -0
- package/server/src/mesh/connector.ts +209 -0
- package/server/src/mesh/discovery.ts +123 -0
- package/server/src/mesh/pairing.ts +94 -0
- package/server/src/mesh/peers.ts +52 -0
- package/server/src/mesh/proxy.ts +103 -0
- package/server/src/mesh/session-sync.ts +107 -0
- package/server/src/project/context-breakdown.ts +289 -0
- package/server/src/project/file-browser.ts +106 -0
- package/server/src/project/project-files.ts +267 -0
- package/server/src/project/registry.ts +57 -0
- package/server/src/project/sdk-bridge.ts +566 -0
- package/server/src/project/session.ts +432 -0
- package/server/src/project/terminal.ts +69 -0
- package/server/src/tls.ts +51 -0
- package/server/src/ws/broadcast.ts +31 -0
- package/server/src/ws/router.ts +104 -0
- package/server/src/ws/server.ts +2 -0
- package/server/tsconfig.json +16 -0
- package/shared/package.json +11 -0
- package/shared/src/constants.ts +7 -0
- package/shared/src/index.ts +4 -0
- package/shared/src/messages.ts +638 -0
- package/shared/src/models.ts +136 -0
- package/shared/src/project-settings.ts +45 -0
- package/shared/tsconfig.json +11 -0
- package/themes/amoled.json +20 -0
- package/themes/ayu-light.json +9 -0
- package/themes/catppuccin-latte.json +9 -0
- package/themes/catppuccin-mocha.json +9 -0
- package/themes/clay-light.json +10 -0
- package/themes/clay.json +10 -0
- package/themes/dracula.json +9 -0
- package/themes/everforest-light.json +9 -0
- package/themes/everforest.json +9 -0
- package/themes/github-light.json +9 -0
- package/themes/gruvbox-dark.json +9 -0
- package/themes/gruvbox-light.json +9 -0
- package/themes/monokai.json +9 -0
- package/themes/nord-light.json +9 -0
- package/themes/nord.json +9 -0
- package/themes/one-dark.json +9 -0
- package/themes/one-light.json +9 -0
- package/themes/rose-pine-dawn.json +9 -0
- package/themes/rose-pine.json +9 -0
- package/themes/solarized-dark.json +9 -0
- package/themes/solarized-light.json +9 -0
- package/themes/tokyo-night-light.json +9 -0
- package/themes/tokyo-night.json +9 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { MeshMessage, MeshHelloMessage, MeshSessionSyncMessage, MeshSessionRequestMessage } from "@lattice/shared";
|
|
2
|
+
import { loadPeers } from "./peers";
|
|
3
|
+
import { loadOrCreateIdentity } from "../identity";
|
|
4
|
+
import { loadConfig } from "../config";
|
|
5
|
+
import { listProjects } from "../project/registry";
|
|
6
|
+
import { getProjectBySlug } from "../project/registry";
|
|
7
|
+
import { handleSessionSync, handleSessionRequest } from "./session-sync";
|
|
8
|
+
|
|
9
|
+
interface PeerConnection {
|
|
10
|
+
nodeId: string;
|
|
11
|
+
ws: WebSocket;
|
|
12
|
+
backoffMs: number;
|
|
13
|
+
retryTimer: ReturnType<typeof setTimeout> | null;
|
|
14
|
+
dead: boolean;
|
|
15
|
+
projects: Array<{ slug: string; title: string }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
var connections = new Map<string, PeerConnection>();
|
|
19
|
+
var connectedCallbacks: Array<(nodeId: string) => void> = [];
|
|
20
|
+
var disconnectedCallbacks: Array<(nodeId: string) => void> = [];
|
|
21
|
+
var messageCallbacks: Array<(nodeId: string, msg: MeshMessage) => void> = [];
|
|
22
|
+
|
|
23
|
+
var MIN_BACKOFF_MS = 1000;
|
|
24
|
+
var MAX_BACKOFF_MS = 30000;
|
|
25
|
+
|
|
26
|
+
export function startMeshConnections(): void {
|
|
27
|
+
var peers = loadPeers();
|
|
28
|
+
for (var i = 0; i < peers.length; i++) {
|
|
29
|
+
connectToPeer(peers[i].id, peers[i].addresses[0]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function stopMeshConnections(): void {
|
|
34
|
+
for (var [, conn] of connections) {
|
|
35
|
+
conn.dead = true;
|
|
36
|
+
if (conn.retryTimer !== null) {
|
|
37
|
+
clearTimeout(conn.retryTimer);
|
|
38
|
+
conn.retryTimer = null;
|
|
39
|
+
}
|
|
40
|
+
conn.ws.close();
|
|
41
|
+
}
|
|
42
|
+
connections.clear();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function connectToPeer(nodeId: string, address: string): void {
|
|
46
|
+
if (connections.has(nodeId)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
var peers = loadPeers();
|
|
51
|
+
var peer = peers.find(function (p) { return p.id === nodeId; });
|
|
52
|
+
if (!peer) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
var config = loadConfig();
|
|
57
|
+
var port = config.port;
|
|
58
|
+
var protocol = config.tls ? "wss" : "ws";
|
|
59
|
+
var url = protocol + "://" + address + ":" + port + "/ws";
|
|
60
|
+
|
|
61
|
+
var conn: PeerConnection = {
|
|
62
|
+
nodeId: nodeId,
|
|
63
|
+
ws: null as unknown as WebSocket,
|
|
64
|
+
backoffMs: MIN_BACKOFF_MS,
|
|
65
|
+
retryTimer: null,
|
|
66
|
+
dead: false,
|
|
67
|
+
projects: [],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
connections.set(nodeId, conn);
|
|
71
|
+
openConnection(conn, url);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function openConnection(conn: PeerConnection, url: string): void {
|
|
75
|
+
var ws = new WebSocket(url);
|
|
76
|
+
conn.ws = ws;
|
|
77
|
+
|
|
78
|
+
ws.addEventListener("open", function () {
|
|
79
|
+
if (conn.dead) {
|
|
80
|
+
ws.close();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
conn.backoffMs = MIN_BACKOFF_MS;
|
|
85
|
+
|
|
86
|
+
var identity = loadOrCreateIdentity();
|
|
87
|
+
var config = loadConfig();
|
|
88
|
+
var projects = listProjects(identity.id);
|
|
89
|
+
|
|
90
|
+
var hello: MeshHelloMessage = {
|
|
91
|
+
type: "mesh:hello",
|
|
92
|
+
nodeId: identity.id,
|
|
93
|
+
name: config.name,
|
|
94
|
+
projects: projects.map(function (p) {
|
|
95
|
+
return { slug: p.slug, title: p.title };
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
ws.send(JSON.stringify(hello));
|
|
100
|
+
|
|
101
|
+
console.log("[mesh] Connected to peer: " + conn.nodeId);
|
|
102
|
+
|
|
103
|
+
for (var i = 0; i < connectedCallbacks.length; i++) {
|
|
104
|
+
connectedCallbacks[i](conn.nodeId);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
ws.addEventListener("message", function (event: MessageEvent) {
|
|
109
|
+
if (conn.dead) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
var msg = JSON.parse(event.data as string) as MeshMessage;
|
|
115
|
+
|
|
116
|
+
if (msg.type === "mesh:hello") {
|
|
117
|
+
conn.projects = msg.projects;
|
|
118
|
+
} else if (msg.type === "mesh:session_sync") {
|
|
119
|
+
handleSessionSync(conn.nodeId, msg as MeshSessionSyncMessage);
|
|
120
|
+
} else if (msg.type === "mesh:session_request") {
|
|
121
|
+
var reqMsg = msg as MeshSessionRequestMessage;
|
|
122
|
+
var reqProject = getProjectBySlug(reqMsg.projectSlug);
|
|
123
|
+
if (reqProject) {
|
|
124
|
+
handleSessionRequest(conn.nodeId, reqMsg, reqProject.path);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (var i = 0; i < messageCallbacks.length; i++) {
|
|
129
|
+
messageCallbacks[i](conn.nodeId, msg);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
console.error("[mesh] Invalid message from peer: " + conn.nodeId);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
ws.addEventListener("close", function () {
|
|
137
|
+
if (conn.dead) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log("[mesh] Disconnected from peer: " + conn.nodeId + ", reconnecting in " + conn.backoffMs + "ms");
|
|
142
|
+
|
|
143
|
+
for (var i = 0; i < disconnectedCallbacks.length; i++) {
|
|
144
|
+
disconnectedCallbacks[i](conn.nodeId);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
var delay = conn.backoffMs;
|
|
148
|
+
conn.backoffMs = Math.min(conn.backoffMs * 2, MAX_BACKOFF_MS);
|
|
149
|
+
|
|
150
|
+
conn.retryTimer = setTimeout(function () {
|
|
151
|
+
if (conn.dead) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
conn.retryTimer = null;
|
|
155
|
+
openConnection(conn, url);
|
|
156
|
+
}, delay);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
ws.addEventListener("error", function () {
|
|
160
|
+
console.error("[mesh] WebSocket error for peer: " + conn.nodeId);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function getPeerConnection(nodeId: string): WebSocket | undefined {
|
|
165
|
+
var conn = connections.get(nodeId);
|
|
166
|
+
if (!conn) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
if (conn.ws.readyState !== WebSocket.OPEN) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
return conn.ws;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function getConnectedPeerIds(): string[] {
|
|
176
|
+
var ids: string[] = [];
|
|
177
|
+
for (var [nodeId, conn] of connections) {
|
|
178
|
+
if (conn.ws.readyState === WebSocket.OPEN) {
|
|
179
|
+
ids.push(nodeId);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return ids;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function onPeerConnected(callback: (nodeId: string) => void): void {
|
|
186
|
+
connectedCallbacks.push(callback);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function onPeerDisconnected(callback: (nodeId: string) => void): void {
|
|
190
|
+
disconnectedCallbacks.push(callback);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function onPeerMessage(callback: (nodeId: string, msg: MeshMessage) => void): void {
|
|
194
|
+
messageCallbacks.push(callback);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function findNodeForProject(projectSlug: string): string | undefined {
|
|
198
|
+
for (var [nodeId, conn] of connections) {
|
|
199
|
+
if (conn.ws.readyState !== WebSocket.OPEN) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
for (var i = 0; i < conn.projects.length; i++) {
|
|
203
|
+
if (conn.projects[i].slug === projectSlug) {
|
|
204
|
+
return nodeId;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import Bonjour from "bonjour-service";
|
|
2
|
+
import type { Service, Browser } from "bonjour-service";
|
|
3
|
+
|
|
4
|
+
export interface DiscoveredNode {
|
|
5
|
+
nodeId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
address: string;
|
|
8
|
+
port: number;
|
|
9
|
+
discoveredAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
var bonjour: InstanceType<typeof Bonjour> | null = null;
|
|
13
|
+
var publishedService: Service | null = null;
|
|
14
|
+
var browser: Browser | null = null;
|
|
15
|
+
var discoveredNodes: Map<string, DiscoveredNode> = new Map();
|
|
16
|
+
var discoveredCallbacks: Array<(node: DiscoveredNode) => void> = [];
|
|
17
|
+
var lostCallbacks: Array<(nodeId: string) => void> = [];
|
|
18
|
+
|
|
19
|
+
export function startDiscovery(nodeId: string, name: string, port: number): void {
|
|
20
|
+
if (bonjour !== null) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
bonjour = new Bonjour();
|
|
25
|
+
|
|
26
|
+
publishedService = bonjour.publish({
|
|
27
|
+
name: name,
|
|
28
|
+
type: "lattice",
|
|
29
|
+
port: port,
|
|
30
|
+
protocol: "tcp",
|
|
31
|
+
txt: {
|
|
32
|
+
nodeId: nodeId,
|
|
33
|
+
name: name,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
console.log(`[discovery] Published _lattice._tcp as "${name}" on port ${port}`);
|
|
38
|
+
|
|
39
|
+
browser = bonjour.find(
|
|
40
|
+
{ type: "lattice", protocol: "tcp" },
|
|
41
|
+
function (service: Service) {
|
|
42
|
+
var txt = service.txt as Record<string, string> | undefined;
|
|
43
|
+
if (!txt || !txt.nodeId) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (txt.nodeId === nodeId) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
var address = service.addresses && service.addresses.length > 0
|
|
51
|
+
? service.addresses[0]
|
|
52
|
+
: (service.referer ? service.referer.address : "");
|
|
53
|
+
|
|
54
|
+
if (!address) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
var node: DiscoveredNode = {
|
|
59
|
+
nodeId: txt.nodeId,
|
|
60
|
+
name: txt.name || service.name,
|
|
61
|
+
address: address,
|
|
62
|
+
port: service.port,
|
|
63
|
+
discoveredAt: Date.now(),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
discoveredNodes.set(node.nodeId, node);
|
|
67
|
+
console.log(`[discovery] Found node: ${node.name} (${node.nodeId}) at ${node.address}:${node.port}`);
|
|
68
|
+
|
|
69
|
+
for (var i = 0; i < discoveredCallbacks.length; i++) {
|
|
70
|
+
discoveredCallbacks[i](node);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
browser.on("down", function (service: Service) {
|
|
76
|
+
var txt = service.txt as Record<string, string> | undefined;
|
|
77
|
+
if (!txt || !txt.nodeId) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
var lostId = txt.nodeId;
|
|
81
|
+
if (!discoveredNodes.has(lostId)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
discoveredNodes.delete(lostId);
|
|
85
|
+
console.log(`[discovery] Lost node: ${lostId}`);
|
|
86
|
+
|
|
87
|
+
for (var i = 0; i < lostCallbacks.length; i++) {
|
|
88
|
+
lostCallbacks[i](lostId);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
browser.start();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function stopDiscovery(): void {
|
|
96
|
+
if (browser !== null) {
|
|
97
|
+
browser.stop();
|
|
98
|
+
browser = null;
|
|
99
|
+
}
|
|
100
|
+
if (bonjour !== null) {
|
|
101
|
+
bonjour.unpublishAll(function () {
|
|
102
|
+
if (bonjour !== null) {
|
|
103
|
+
bonjour.destroy();
|
|
104
|
+
bonjour = null;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
publishedService = null;
|
|
108
|
+
}
|
|
109
|
+
discoveredNodes.clear();
|
|
110
|
+
console.log("[discovery] Stopped");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getDiscoveredNodes(): DiscoveredNode[] {
|
|
114
|
+
return Array.from(discoveredNodes.values());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function onNodeDiscovered(callback: (node: DiscoveredNode) => void): void {
|
|
118
|
+
discoveredCallbacks.push(callback);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function onNodeLost(callback: (nodeId: string) => void): void {
|
|
122
|
+
lostCallbacks.push(callback);
|
|
123
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import QRCode from "qrcode";
|
|
3
|
+
|
|
4
|
+
var BASE62_CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz";
|
|
5
|
+
|
|
6
|
+
var pendingTokens = new Set<string>();
|
|
7
|
+
|
|
8
|
+
function base62Encode(buf: Buffer): string {
|
|
9
|
+
var n = BigInt("0x" + buf.toString("hex"));
|
|
10
|
+
var result = "";
|
|
11
|
+
var base = BigInt(BASE62_CHARS.length);
|
|
12
|
+
while (n > 0n) {
|
|
13
|
+
result = BASE62_CHARS[Number(n % base)] + result;
|
|
14
|
+
n = n / base;
|
|
15
|
+
}
|
|
16
|
+
return result || BASE62_CHARS[0];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function base62Decode(s: string): Buffer {
|
|
20
|
+
var n = 0n;
|
|
21
|
+
var base = BigInt(BASE62_CHARS.length);
|
|
22
|
+
for (var i = 0; i < s.length; i++) {
|
|
23
|
+
var idx = BASE62_CHARS.indexOf(s[i]);
|
|
24
|
+
if (idx < 0) {
|
|
25
|
+
throw new Error("Invalid base62 character: " + s[i]);
|
|
26
|
+
}
|
|
27
|
+
n = n * base + BigInt(idx);
|
|
28
|
+
}
|
|
29
|
+
var hex = n.toString(16);
|
|
30
|
+
if (hex.length % 2 !== 0) {
|
|
31
|
+
hex = "0" + hex;
|
|
32
|
+
}
|
|
33
|
+
return Buffer.from(hex, "hex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatCode(raw: string): string {
|
|
37
|
+
var upper = raw.toUpperCase();
|
|
38
|
+
var chunks: string[] = [];
|
|
39
|
+
for (var i = 0; i < upper.length; i += 4) {
|
|
40
|
+
chunks.push(upper.slice(i, i + 4));
|
|
41
|
+
}
|
|
42
|
+
return "LTCE-" + chunks.join("-");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stripCode(code: string): string {
|
|
46
|
+
return code.replace(/^LTCE-/i, "").replace(/-/g, "");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function generateInviteCode(
|
|
50
|
+
address: string,
|
|
51
|
+
port: number
|
|
52
|
+
): Promise<{ code: string; token: string; qrDataUrl: string }> {
|
|
53
|
+
var token = randomBytes(8).toString("hex");
|
|
54
|
+
var payload = Buffer.from(address + ":" + port + ":" + token, "utf-8");
|
|
55
|
+
var encoded = base62Encode(payload);
|
|
56
|
+
var code = formatCode(encoded);
|
|
57
|
+
|
|
58
|
+
pendingTokens.add(token);
|
|
59
|
+
|
|
60
|
+
var qrDataUrl = await QRCode.toString(code, { type: "svg" });
|
|
61
|
+
|
|
62
|
+
return { code, token, qrDataUrl };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parseInviteCode(
|
|
66
|
+
code: string
|
|
67
|
+
): { address: string; port: number; token: string } | null {
|
|
68
|
+
try {
|
|
69
|
+
var stripped = stripCode(code);
|
|
70
|
+
var decoded = base62Decode(stripped).toString("utf-8");
|
|
71
|
+
var parts = decoded.split(":");
|
|
72
|
+
if (parts.length < 3) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
var token = parts[parts.length - 1];
|
|
76
|
+
var portStr = parts[parts.length - 2];
|
|
77
|
+
var address = parts.slice(0, parts.length - 2).join(":");
|
|
78
|
+
var port = parseInt(portStr, 10);
|
|
79
|
+
if (isNaN(port)) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return { address, port, token };
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function validatePairingToken(token: string): boolean {
|
|
89
|
+
return pendingTokens.has(token);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function consumePairingToken(token: string): void {
|
|
93
|
+
pendingTokens.delete(token);
|
|
94
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { PeerInfo } from "@lattice/shared";
|
|
4
|
+
import { getLatticeHome } from "../config";
|
|
5
|
+
|
|
6
|
+
function getPeersPath(): string {
|
|
7
|
+
return join(getLatticeHome(), "peers.json");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function loadPeers(): PeerInfo[] {
|
|
11
|
+
var path = getPeersPath();
|
|
12
|
+
if (!existsSync(path)) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
var raw = readFileSync(path, "utf-8");
|
|
16
|
+
return JSON.parse(raw) as PeerInfo[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function savePeers(peers: PeerInfo[]): void {
|
|
20
|
+
var path = getPeersPath();
|
|
21
|
+
writeFileSync(path, JSON.stringify(peers, null, 2), "utf-8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function addPeer(peer: PeerInfo): void {
|
|
25
|
+
var peers = loadPeers();
|
|
26
|
+
var idx = peers.findIndex(function (p) { return p.id === peer.id; });
|
|
27
|
+
if (idx >= 0) {
|
|
28
|
+
peers[idx] = peer;
|
|
29
|
+
} else {
|
|
30
|
+
peers.push(peer);
|
|
31
|
+
}
|
|
32
|
+
savePeers(peers);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function removePeer(nodeId: string): boolean {
|
|
36
|
+
var peers = loadPeers();
|
|
37
|
+
var next = peers.filter(function (p) { return p.id !== nodeId; });
|
|
38
|
+
if (next.length === peers.length) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
savePeers(next);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getPeer(nodeId: string): PeerInfo | undefined {
|
|
46
|
+
var peers = loadPeers();
|
|
47
|
+
return peers.find(function (p) { return p.id === nodeId; });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isPaired(nodeId: string): boolean {
|
|
51
|
+
return getPeer(nodeId) !== undefined;
|
|
52
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ClientMessage, MeshProxyRequestMessage, MeshProxyResponseMessage, ServerMessage } from "@lattice/shared";
|
|
3
|
+
import { getPeerConnection } from "./connector";
|
|
4
|
+
import { sendTo, broadcast } from "../ws/broadcast";
|
|
5
|
+
import { routeMessage } from "../ws/router";
|
|
6
|
+
|
|
7
|
+
var pendingRequests = new Map<string, string>();
|
|
8
|
+
|
|
9
|
+
export function proxyToRemoteNode(nodeId: string, projectSlug: string, clientId: string, message: ClientMessage): void {
|
|
10
|
+
var ws = getPeerConnection(nodeId);
|
|
11
|
+
if (!ws) {
|
|
12
|
+
console.warn("[mesh/proxy] No connection to peer: " + nodeId);
|
|
13
|
+
sendTo(clientId, { type: "chat:error", message: "Remote node " + nodeId + " is not connected" });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
var requestId = randomUUID();
|
|
18
|
+
pendingRequests.set(requestId, clientId);
|
|
19
|
+
|
|
20
|
+
var envelope: MeshProxyRequestMessage = {
|
|
21
|
+
type: "mesh:proxy_request",
|
|
22
|
+
projectSlug: projectSlug,
|
|
23
|
+
requestId: requestId,
|
|
24
|
+
payload: message,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
ws.send(JSON.stringify(envelope));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function handleProxyRequest(sourceNodeId: string, msg: MeshProxyRequestMessage): void {
|
|
31
|
+
var proxyClientId = "mesh-proxy:" + sourceNodeId + ":" + msg.requestId;
|
|
32
|
+
|
|
33
|
+
var originalBroadcast = broadcast;
|
|
34
|
+
void originalBroadcast;
|
|
35
|
+
|
|
36
|
+
var interceptedSendTo = function (targetId: string, response: object): void {
|
|
37
|
+
if (targetId === proxyClientId) {
|
|
38
|
+
var ws = getPeerConnection(sourceNodeId);
|
|
39
|
+
if (!ws) {
|
|
40
|
+
console.warn("[mesh/proxy] Cannot send response, no connection to: " + sourceNodeId);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
var envelope: MeshProxyResponseMessage = {
|
|
45
|
+
type: "mesh:proxy_response",
|
|
46
|
+
projectSlug: msg.projectSlug,
|
|
47
|
+
requestId: msg.requestId,
|
|
48
|
+
payload: response as ServerMessage,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
ws.send(JSON.stringify(envelope));
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
proxyRouteMessage(proxyClientId, msg.payload, interceptedSendTo);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function handleProxyResponse(msg: MeshProxyResponseMessage): void {
|
|
59
|
+
var clientId = pendingRequests.get(msg.requestId);
|
|
60
|
+
if (!clientId) {
|
|
61
|
+
console.warn("[mesh/proxy] No pending request for id: " + msg.requestId);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pendingRequests.delete(msg.requestId);
|
|
66
|
+
sendTo(clientId, msg.payload);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type SendToFn = (clientId: string, message: object) => void;
|
|
70
|
+
|
|
71
|
+
var proxyHandlers = new Map<string, (clientId: string, message: ClientMessage, sendToFn: SendToFn) => void>();
|
|
72
|
+
|
|
73
|
+
export function registerProxyAwareHandler(prefix: string, handler: (clientId: string, message: ClientMessage, sendToFn: SendToFn) => void): void {
|
|
74
|
+
proxyHandlers.set(prefix, handler);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function proxyRouteMessage(clientId: string, message: ClientMessage, sendToFn: SendToFn): void {
|
|
78
|
+
var prefix = message.type.split(":")[0];
|
|
79
|
+
var proxyHandler = proxyHandlers.get(prefix);
|
|
80
|
+
if (proxyHandler) {
|
|
81
|
+
proxyHandler(clientId, message, sendToFn);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
routeMessage(clientId, message);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getProxySendTo(requestId: string, sourceNodeId: string): ((msg: ServerMessage) => void) | undefined {
|
|
88
|
+
var clientId = pendingRequests.get(requestId);
|
|
89
|
+
if (clientId !== undefined) {
|
|
90
|
+
var resolvedClientId = clientId;
|
|
91
|
+
return function (msg: ServerMessage) {
|
|
92
|
+
sendTo(resolvedClientId, msg);
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return function (msg: ServerMessage) {
|
|
97
|
+
var ws = getPeerConnection(sourceNodeId);
|
|
98
|
+
if (!ws) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
ws.send(JSON.stringify(msg));
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import type { MeshSessionSyncMessage, MeshSessionRequestMessage } from "@lattice/shared";
|
|
5
|
+
import { getConnectedPeerIds, getPeerConnection } from "./connector";
|
|
6
|
+
import { getLatticeHome } from "../config";
|
|
7
|
+
|
|
8
|
+
var syncOffsets = new Map<string, number>();
|
|
9
|
+
|
|
10
|
+
function getSyncKey(nodeId: string, sessionId: string): string {
|
|
11
|
+
return nodeId + ":" + sessionId;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getRemoteSessionsDir(nodeId: string, projectSlug: string): string {
|
|
15
|
+
var dir = join(getLatticeHome(), "remote-sessions", nodeId, projectSlug);
|
|
16
|
+
if (!existsSync(dir)) {
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getSessionFilePath(projectPath: string, sessionId: string): string {
|
|
23
|
+
var hash = projectPath.replace(/\//g, "-");
|
|
24
|
+
return join(homedir(), ".claude", "projects", hash, sessionId + ".jsonl");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function syncSessionToPeers(projectPath: string, projectSlug: string, sessionId: string): void {
|
|
28
|
+
var filePath = getSessionFilePath(projectPath, sessionId);
|
|
29
|
+
if (!existsSync(filePath)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
var content = readFileSync(filePath, "utf-8");
|
|
34
|
+
var peerIds = getConnectedPeerIds();
|
|
35
|
+
|
|
36
|
+
for (var i = 0; i < peerIds.length; i++) {
|
|
37
|
+
var nodeId = peerIds[i];
|
|
38
|
+
var key = getSyncKey(nodeId, sessionId);
|
|
39
|
+
var lastOffset = syncOffsets.get(key) || 0;
|
|
40
|
+
|
|
41
|
+
if (content.length <= lastOffset) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
var newContent = content.slice(lastOffset);
|
|
46
|
+
var newLines = newContent.split("\n").filter(function (l) { return l.trim().length > 0; });
|
|
47
|
+
|
|
48
|
+
if (newLines.length === 0) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
var ws = getPeerConnection(nodeId);
|
|
53
|
+
if (!ws) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
var msg: MeshSessionSyncMessage = {
|
|
58
|
+
type: "mesh:session_sync",
|
|
59
|
+
projectSlug,
|
|
60
|
+
sessionId,
|
|
61
|
+
lines: newLines,
|
|
62
|
+
offset: lastOffset,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
ws.send(JSON.stringify(msg));
|
|
66
|
+
syncOffsets.set(key, content.length);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function handleSessionSync(nodeId: string, msg: MeshSessionSyncMessage): void {
|
|
71
|
+
var dir = getRemoteSessionsDir(nodeId, msg.projectSlug);
|
|
72
|
+
var filePath = join(dir, msg.sessionId + ".jsonl");
|
|
73
|
+
|
|
74
|
+
for (var i = 0; i < msg.lines.length; i++) {
|
|
75
|
+
appendFileSync(filePath, msg.lines[i] + "\n", "utf-8");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function handleSessionRequest(nodeId: string, msg: MeshSessionRequestMessage, projectPath: string): void {
|
|
80
|
+
var filePath = getSessionFilePath(projectPath, msg.sessionId);
|
|
81
|
+
if (!existsSync(filePath)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
var content = readFileSync(filePath, "utf-8");
|
|
86
|
+
var newContent = content.slice(msg.fromOffset);
|
|
87
|
+
var lines = newContent.split("\n").filter(function (l) { return l.trim().length > 0; });
|
|
88
|
+
|
|
89
|
+
if (lines.length === 0) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
var ws = getPeerConnection(nodeId);
|
|
94
|
+
if (!ws) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
var syncMsg: MeshSessionSyncMessage = {
|
|
99
|
+
type: "mesh:session_sync",
|
|
100
|
+
projectSlug: msg.projectSlug,
|
|
101
|
+
sessionId: msg.sessionId,
|
|
102
|
+
lines,
|
|
103
|
+
offset: msg.fromOffset,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
ws.send(JSON.stringify(syncMsg));
|
|
107
|
+
}
|