heyhank 0.1.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 (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,366 @@
1
+ // ─── Node Manager ─────────────────────────────────────────────────────────────
2
+ // Orchestrates all federation connections. Singleton.
3
+
4
+ import type { ServerWebSocket } from "bun";
5
+ import { randomUUID } from "node:crypto";
6
+ import type {
7
+ NodeMessage,
8
+ NodeStatus,
9
+ RemoteSessionSummary,
10
+ RemoteAgentSummary,
11
+ NodeSessionProxyMessage,
12
+ NodeSessionProxyResponseMessage,
13
+ } from "./node-types.js";
14
+ import { PROTOCOL_VERSION, PING_INTERVAL_MS } from "./node-types.js";
15
+ import { getNodeIdentity, listNodes, getNode } from "./node-store.js";
16
+ import { NodeConnection } from "./node-connection.js";
17
+
18
+ interface InboundPeer {
19
+ ws: WebSocket | ServerWebSocket<unknown>;
20
+ nodeId: string;
21
+ name: string;
22
+ sessions: RemoteSessionSummary[];
23
+ lastPong: number;
24
+ pingTimer: ReturnType<typeof setInterval> | null;
25
+ }
26
+
27
+ type ProxyCallback = (result: { result?: { replyText: string }; error?: string }) => void;
28
+
29
+ export class NodeManager {
30
+ /** Outbound connections keyed by node config ID */
31
+ private outbound = new Map<string, NodeConnection>();
32
+ /** Inbound connections keyed by remote node ID */
33
+ private inbound = new Map<string, InboundPeer>();
34
+ /** Pending proxy requests */
35
+ private pendingProxies = new Map<string, ProxyCallback>();
36
+
37
+ /** Provider: returns local session summaries for syncing */
38
+ getLocalSessions: (() => RemoteSessionSummary[]) | null = null;
39
+ /** Provider: handles a proxy request (runs command on local session) */
40
+ proxyHandler: ((sessionId: string, text: string) => Promise<{ replyText: string }>) | null = null;
41
+
42
+ // ─── Lifecycle ──────────────────────────────────────────────────────────────
43
+
44
+ initialize(): void {
45
+ const nodes = listNodes();
46
+ for (const node of nodes) {
47
+ this.connectNode(node.id);
48
+ }
49
+ console.log(`[federation] Initialized with ${nodes.length} configured node(s)`);
50
+ }
51
+
52
+ shutdown(): void {
53
+ for (const conn of this.outbound.values()) conn.destroy();
54
+ this.outbound.clear();
55
+ for (const info of this.inbound.values()) {
56
+ if (info.pingTimer) clearInterval(info.pingTimer);
57
+ try { (info.ws as WebSocket).close?.(); } catch { /* ignore */ }
58
+ }
59
+ this.inbound.clear();
60
+ }
61
+
62
+ connectNode(nodeConfigId: string): void {
63
+ if (this.outbound.has(nodeConfigId)) return;
64
+ const config = getNode(nodeConfigId);
65
+ if (!config) return;
66
+ const conn = new NodeConnection(config, this);
67
+ this.outbound.set(nodeConfigId, conn);
68
+ conn.connect();
69
+ }
70
+
71
+ disconnectNode(nodeConfigId: string): void {
72
+ const conn = this.outbound.get(nodeConfigId);
73
+ if (conn) {
74
+ conn.destroy();
75
+ this.outbound.delete(nodeConfigId);
76
+ }
77
+ }
78
+
79
+ // ─── Inbound WebSocket handling ─────────────────────────────────────────────
80
+
81
+ handleInboundConnection(ws: WebSocket | ServerWebSocket<unknown>): void {
82
+ let authTimeout: ReturnType<typeof setTimeout> | null = setTimeout(() => {
83
+ try { (ws as WebSocket).close?.(); } catch { /* ignore */ }
84
+ }, 10_000);
85
+
86
+ let authed = false;
87
+ const sendJson = (msg: NodeMessage) => {
88
+ try {
89
+ const payload = JSON.stringify(msg);
90
+ ws.send(payload);
91
+ } catch { /* ignore */ }
92
+ };
93
+
94
+ const onMessage = (data: string | Buffer | ArrayBuffer) => {
95
+ let msg: NodeMessage;
96
+ try {
97
+ msg = JSON.parse(typeof data === "string" ? data : data.toString()) as NodeMessage;
98
+ } catch { return; }
99
+
100
+ if (!authed) {
101
+ if (msg.type !== "auth") {
102
+ try { (ws as WebSocket).close?.(); } catch { /* ignore */ }
103
+ return;
104
+ }
105
+ if (authTimeout) { clearTimeout(authTimeout); authTimeout = null; }
106
+
107
+ // Verify secret against any configured node
108
+ const nodes = listNodes();
109
+ const match = nodes.find((n) => n.secret === msg.secret);
110
+ if (!match) {
111
+ sendJson({ type: "auth_error", error: "invalid secret" });
112
+ setTimeout(() => { try { (ws as WebSocket).close?.(); } catch { /* ignore */ } }, 100);
113
+ return;
114
+ }
115
+
116
+ authed = true;
117
+ const identity = getNodeIdentity();
118
+
119
+ // Replace any old inbound from same nodeId
120
+ const old = this.inbound.get(msg.nodeId);
121
+ if (old) {
122
+ if (old.pingTimer) clearInterval(old.pingTimer);
123
+ try { (old.ws as WebSocket).close?.(); } catch { /* ignore */ }
124
+ }
125
+
126
+ const info: InboundPeer = {
127
+ ws,
128
+ nodeId: msg.nodeId,
129
+ name: msg.name || "Unknown",
130
+ sessions: [],
131
+ lastPong: Date.now(),
132
+ pingTimer: null,
133
+ };
134
+ this.inbound.set(msg.nodeId, info);
135
+
136
+ sendJson({ type: "auth_ok", nodeId: identity.nodeId, name: identity.name, version: PROTOCOL_VERSION });
137
+
138
+ // Start ping
139
+ info.pingTimer = setInterval(() => {
140
+ if (Date.now() - info.lastPong > PING_INTERVAL_MS * 3) {
141
+ console.log(`[federation] Inbound ping timeout for ${info.name}`);
142
+ try { (ws as WebSocket).close?.(); } catch { /* ignore */ }
143
+ return;
144
+ }
145
+ sendJson({ type: "ping" });
146
+ }, PING_INTERVAL_MS);
147
+
148
+ console.log(`[federation] Inbound connection from ${info.name} (${info.nodeId})`);
149
+
150
+ // Send our sessions
151
+ sendJson({ type: "sessions_sync", sessions: this.getLocalSessionSummaries() });
152
+ return;
153
+ }
154
+
155
+ // Find inbound peer by ws reference
156
+ const peer = [...this.inbound.values()].find((i) => i.ws === ws);
157
+ if (!peer) return;
158
+
159
+ switch (msg.type) {
160
+ case "pong":
161
+ peer.lastPong = Date.now();
162
+ break;
163
+ case "ping":
164
+ sendJson({ type: "pong" });
165
+ break;
166
+ case "sessions_request":
167
+ sendJson({ type: "sessions_sync", sessions: this.getLocalSessionSummaries() });
168
+ break;
169
+ case "sessions_sync":
170
+ peer.sessions = msg.sessions ?? [];
171
+ break;
172
+ case "session_proxy":
173
+ this.handleProxyRequest({ sendMsg: sendJson }, msg);
174
+ break;
175
+ case "session_proxy_response":
176
+ if (msg.requestId) this.resolveProxy(msg.requestId, msg);
177
+ break;
178
+ }
179
+ };
180
+
181
+ // Bun ServerWebSocket uses `.data` property and events are handled via the global websocket handler,
182
+ // but for the federation inbound we store the handler and call it from index.ts
183
+ // Store message handler on the ws object for retrieval
184
+ (ws as unknown as Record<string, unknown>).__federationOnMessage = onMessage;
185
+ (ws as unknown as Record<string, unknown>).__federationOnClose = () => {
186
+ if (authTimeout) clearTimeout(authTimeout);
187
+ for (const [nodeId, info] of this.inbound) {
188
+ if (info.ws === ws) {
189
+ if (info.pingTimer) clearInterval(info.pingTimer);
190
+ this.inbound.delete(nodeId);
191
+ console.log(`[federation] Inbound disconnected: ${info.name}`);
192
+ break;
193
+ }
194
+ }
195
+ };
196
+ }
197
+
198
+ // ─── Session summaries ──────────────────────────────────────────────────────
199
+
200
+ getLocalSessionSummaries(): RemoteSessionSummary[] {
201
+ return this.getLocalSessions?.() ?? [];
202
+ }
203
+
204
+ getRemoteSessions(): (RemoteSessionSummary & { nodeId: string; nodeName: string })[] {
205
+ const sessions: (RemoteSessionSummary & { nodeId: string; nodeName: string })[] = [];
206
+ const seen = new Set<string>();
207
+
208
+ for (const conn of this.outbound.values()) {
209
+ if (!conn.connected) continue;
210
+ for (const s of conn.remoteSessions) {
211
+ const key = `${conn.remoteNodeId}:${s.sessionId}`;
212
+ if (!seen.has(key)) {
213
+ seen.add(key);
214
+ sessions.push({ ...s, nodeId: conn.remoteNodeId!, nodeName: conn.remoteName! });
215
+ }
216
+ }
217
+ }
218
+
219
+ for (const info of this.inbound.values()) {
220
+ for (const s of info.sessions) {
221
+ const key = `${info.nodeId}:${s.sessionId}`;
222
+ if (!seen.has(key)) {
223
+ seen.add(key);
224
+ sessions.push({ ...s, nodeId: info.nodeId, nodeName: info.name });
225
+ }
226
+ }
227
+ }
228
+
229
+ return sessions;
230
+ }
231
+
232
+ getRemoteAgents(): (RemoteAgentSummary & { nodeId: string; nodeName: string })[] {
233
+ const agents: (RemoteAgentSummary & { nodeId: string; nodeName: string })[] = [];
234
+ // Agents sync is not yet implemented in the protocol, but return empty for now
235
+ // This will be populated when agents_sync messages are exchanged
236
+ return agents;
237
+ }
238
+
239
+ // ─── Node statuses ──────────────────────────────────────────────────────────
240
+
241
+ getNodeStatuses(): NodeStatus[] {
242
+ const nodes = listNodes();
243
+ return nodes.map((n) => {
244
+ const conn = this.outbound.get(n.id);
245
+ const connected = conn?.connected ?? false;
246
+ const remoteNodeId = conn?.remoteNodeId ?? null;
247
+ const remoteName = conn?.remoteName ?? null;
248
+ const sessionCount = conn?.remoteSessions.length ?? 0;
249
+ const inboundConnected = remoteNodeId ? this.inbound.has(remoteNodeId) : false;
250
+
251
+ return {
252
+ ...n,
253
+ connected,
254
+ inboundConnected,
255
+ remoteNodeId,
256
+ remoteName,
257
+ sessionCount,
258
+ };
259
+ });
260
+ }
261
+
262
+ // ─── Proxy ──────────────────────────────────────────────────────────────────
263
+
264
+ async sendToRemoteSession(sessionId: string, text: string): Promise<{ result?: { replyText: string }; error?: string }> {
265
+ // Find in outbound
266
+ for (const conn of this.outbound.values()) {
267
+ if (!conn.connected) continue;
268
+ if (conn.remoteSessions.some((s) => s.sessionId === sessionId)) {
269
+ return this.proxyVia(
270
+ (msg) => conn.sendMsg(msg),
271
+ sessionId,
272
+ text,
273
+ );
274
+ }
275
+ }
276
+ // Find in inbound
277
+ for (const info of this.inbound.values()) {
278
+ if (info.sessions.some((s) => s.sessionId === sessionId)) {
279
+ return this.proxyVia(
280
+ (msg) => { try { info.ws.send(JSON.stringify(msg)); } catch { /* ignore */ } },
281
+ sessionId,
282
+ text,
283
+ );
284
+ }
285
+ }
286
+ return { error: "session not found on any remote node" };
287
+ }
288
+
289
+ private proxyVia(
290
+ send: (msg: NodeMessage) => void,
291
+ sessionId: string,
292
+ text: string,
293
+ ): Promise<{ result?: { replyText: string }; error?: string }> {
294
+ return new Promise((resolve) => {
295
+ const requestId = randomUUID();
296
+ const timeout = setTimeout(() => {
297
+ this.pendingProxies.delete(requestId);
298
+ resolve({ error: "proxy timeout" });
299
+ }, 120_000);
300
+
301
+ this.pendingProxies.set(requestId, (result) => {
302
+ clearTimeout(timeout);
303
+ resolve(result);
304
+ });
305
+
306
+ send({ type: "session_proxy", requestId, sessionId, text });
307
+ });
308
+ }
309
+
310
+ resolveProxy(requestId: string, msg: NodeSessionProxyResponseMessage): void {
311
+ const cb = this.pendingProxies.get(requestId);
312
+ if (cb) {
313
+ this.pendingProxies.delete(requestId);
314
+ cb(msg.error ? { error: msg.error } : { result: msg.result });
315
+ }
316
+ }
317
+
318
+ async handleProxyRequest(
319
+ sender: { sendMsg: (msg: NodeMessage) => void },
320
+ msg: NodeSessionProxyMessage,
321
+ ): Promise<void> {
322
+ if (!this.proxyHandler) {
323
+ sender.sendMsg({ type: "session_proxy_response", requestId: msg.requestId, error: "no proxy handler" });
324
+ return;
325
+ }
326
+ try {
327
+ const result = await this.proxyHandler(msg.sessionId, msg.text);
328
+ sender.sendMsg({ type: "session_proxy_response", requestId: msg.requestId, result });
329
+ } catch (err) {
330
+ sender.sendMsg({
331
+ type: "session_proxy_response",
332
+ requestId: msg.requestId,
333
+ error: err instanceof Error ? err.message : String(err),
334
+ });
335
+ }
336
+ }
337
+
338
+ // ─── Events ─────────────────────────────────────────────────────────────────
339
+
340
+ onNodeConnected(conn: NodeConnection): void {
341
+ conn.sendMsg({ type: "sessions_sync", sessions: this.getLocalSessionSummaries() });
342
+ }
343
+
344
+ onNodeDisconnected(conn: NodeConnection): void {
345
+ conn.remoteSessions = [];
346
+ }
347
+
348
+ onSessionsUpdated(): void {
349
+ // Future: emit event bus events
350
+ }
351
+
352
+ broadcastLocalSessions(): void {
353
+ const sessions = this.getLocalSessionSummaries();
354
+ const msg: NodeMessage = { type: "sessions_sync", sessions };
355
+ for (const conn of this.outbound.values()) {
356
+ if (conn.connected) conn.sendMsg(msg);
357
+ }
358
+ for (const info of this.inbound.values()) {
359
+ try { info.ws.send(JSON.stringify(msg)); } catch { /* ignore */ }
360
+ }
361
+ }
362
+ }
363
+
364
+ // ─── Singleton ────────────────────────────────────────────────────────────────
365
+
366
+ export const nodeManager = new NodeManager();
@@ -0,0 +1,86 @@
1
+ // ─── Node Store ───────────────────────────────────────────────────────────────
2
+ // File-based CRUD for node configs. Stores identity + peer list in HEYHANK_HOME.
3
+
4
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { randomUUID, randomBytes } from "node:crypto";
7
+ import { HEYHANK_HOME } from "../paths.js";
8
+ import type { NodeIdentity, NodeConfig } from "./node-types.js";
9
+
10
+ const IDENTITY_FILE = join(HEYHANK_HOME, "node-identity.json");
11
+ const NODES_FILE = join(HEYHANK_HOME, "nodes.json");
12
+
13
+ // ─── Node Identity ────────────────────────────────────────────────────────────
14
+
15
+ let _identity: NodeIdentity | null = null;
16
+
17
+ export function getNodeIdentity(): NodeIdentity {
18
+ if (_identity) return _identity;
19
+ try {
20
+ if (existsSync(IDENTITY_FILE)) {
21
+ const raw = JSON.parse(readFileSync(IDENTITY_FILE, "utf-8")) as Partial<NodeIdentity>;
22
+ if (raw.nodeId && raw.name) {
23
+ _identity = raw as NodeIdentity;
24
+ return _identity;
25
+ }
26
+ }
27
+ } catch { /* corrupted file, regenerate */ }
28
+
29
+ _identity = {
30
+ nodeId: randomUUID(),
31
+ name: `Hank-${randomBytes(3).toString("hex")}`,
32
+ createdAt: new Date().toISOString(),
33
+ };
34
+ mkdirSync(dirname(IDENTITY_FILE), { recursive: true });
35
+ writeFileSync(IDENTITY_FILE, JSON.stringify(_identity, null, 2));
36
+ return _identity;
37
+ }
38
+
39
+ export function updateNodeName(name: string): NodeIdentity {
40
+ const id = getNodeIdentity();
41
+ id.name = name;
42
+ writeFileSync(IDENTITY_FILE, JSON.stringify(id, null, 2));
43
+ return id;
44
+ }
45
+
46
+ // ─── Node Configs ─────────────────────────────────────────────────────────────
47
+
48
+ function readNodes(): NodeConfig[] {
49
+ try {
50
+ if (existsSync(NODES_FILE)) {
51
+ return JSON.parse(readFileSync(NODES_FILE, "utf-8")) as NodeConfig[];
52
+ }
53
+ } catch { /* corrupted */ }
54
+ return [];
55
+ }
56
+
57
+ function writeNodes(nodes: NodeConfig[]): void {
58
+ mkdirSync(dirname(NODES_FILE), { recursive: true });
59
+ writeFileSync(NODES_FILE, JSON.stringify(nodes, null, 2));
60
+ }
61
+
62
+ export function listNodes(): NodeConfig[] {
63
+ return readNodes();
64
+ }
65
+
66
+ export function getNode(id: string): NodeConfig | null {
67
+ return readNodes().find((n) => n.id === id) ?? null;
68
+ }
69
+
70
+ export function addNode(opts: { url: string; secret: string; name: string }): NodeConfig {
71
+ const nodes = readNodes();
72
+ const node: NodeConfig = {
73
+ id: randomUUID(),
74
+ url: opts.url.replace(/\/+$/, ""),
75
+ secret: opts.secret,
76
+ name: opts.name || "Remote Node",
77
+ addedAt: new Date().toISOString(),
78
+ };
79
+ nodes.push(node);
80
+ writeNodes(nodes);
81
+ return node;
82
+ }
83
+
84
+ export function removeNode(id: string): void {
85
+ writeNodes(readNodes().filter((n) => n.id !== id));
86
+ }
@@ -0,0 +1,121 @@
1
+ // ─── Federation Types ──────────────────────────────────────────────────────────
2
+ // Peer-to-peer WebSocket mesh for multi-node HeyHank connectivity.
3
+
4
+ export interface NodeIdentity {
5
+ nodeId: string;
6
+ name: string;
7
+ createdAt: string;
8
+ }
9
+
10
+ export interface NodeConfig {
11
+ id: string;
12
+ url: string;
13
+ secret: string;
14
+ name: string;
15
+ addedAt: string;
16
+ }
17
+
18
+ export interface NodeStatus extends NodeConfig {
19
+ connected: boolean;
20
+ inboundConnected: boolean;
21
+ remoteNodeId: string | null;
22
+ remoteName: string | null;
23
+ sessionCount: number;
24
+ }
25
+
26
+ // ─── Protocol Messages ────────────────────────────────────────────────────────
27
+
28
+ export type NodeMessage =
29
+ | NodeAuthMessage
30
+ | NodeAuthOkMessage
31
+ | NodeAuthErrorMessage
32
+ | NodePingMessage
33
+ | NodePongMessage
34
+ | NodeSessionsRequestMessage
35
+ | NodeSessionsSyncMessage
36
+ | NodeSessionProxyMessage
37
+ | NodeSessionProxyResponseMessage
38
+ | NodeAgentsSyncMessage;
39
+
40
+ export interface NodeAuthMessage {
41
+ type: "auth";
42
+ nodeId: string;
43
+ name: string;
44
+ secret: string;
45
+ version: number;
46
+ }
47
+
48
+ export interface NodeAuthOkMessage {
49
+ type: "auth_ok";
50
+ nodeId: string;
51
+ name: string;
52
+ version: number;
53
+ }
54
+
55
+ export interface NodeAuthErrorMessage {
56
+ type: "auth_error";
57
+ error: string;
58
+ }
59
+
60
+ export interface NodePingMessage {
61
+ type: "ping";
62
+ }
63
+
64
+ export interface NodePongMessage {
65
+ type: "pong";
66
+ }
67
+
68
+ export interface NodeSessionsRequestMessage {
69
+ type: "sessions_request";
70
+ }
71
+
72
+ export interface NodeSessionsSyncMessage {
73
+ type: "sessions_sync";
74
+ sessions: RemoteSessionSummary[];
75
+ }
76
+
77
+ export interface NodeSessionProxyMessage {
78
+ type: "session_proxy";
79
+ requestId: string;
80
+ sessionId: string;
81
+ text: string;
82
+ }
83
+
84
+ export interface NodeSessionProxyResponseMessage {
85
+ type: "session_proxy_response";
86
+ requestId: string;
87
+ result?: { replyText: string };
88
+ error?: string;
89
+ }
90
+
91
+ export interface NodeAgentsSyncMessage {
92
+ type: "agents_sync";
93
+ agents: RemoteAgentSummary[];
94
+ }
95
+
96
+ // ─── Remote Summaries ─────────────────────────────────────────────────────────
97
+
98
+ export interface RemoteSessionSummary {
99
+ sessionId: string;
100
+ name?: string;
101
+ model?: string;
102
+ cwd?: string;
103
+ status?: string;
104
+ backendType?: string;
105
+ isConnected?: boolean;
106
+ }
107
+
108
+ export interface RemoteAgentSummary {
109
+ id: string;
110
+ name: string;
111
+ description?: string;
112
+ backendType?: string;
113
+ status?: string;
114
+ }
115
+
116
+ // ─── Constants ────────────────────────────────────────────────────────────────
117
+
118
+ export const PROTOCOL_VERSION = 1;
119
+ export const PING_INTERVAL_MS = 30_000;
120
+ export const RECONNECT_MIN_MS = 1_000;
121
+ export const RECONNECT_MAX_MS = 30_000;
@@ -0,0 +1,15 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ /** Count newlines in a file. Fast: reads raw buffer, counts 0x0A bytes. */
4
+ export function countFileLines(path: string): number {
5
+ try {
6
+ const buf = readFileSync(path);
7
+ let count = 0;
8
+ for (let i = 0; i < buf.length; i++) {
9
+ if (buf[i] === 0x0a) count++;
10
+ }
11
+ return count;
12
+ } catch {
13
+ return 0;
14
+ }
15
+ }