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,124 @@
1
+ // Zero-dependency, strongly-typed internal event bus for the HeyHank server.
2
+
3
+ import type { HeyHankEventMap } from "./event-bus-types.js";
4
+
5
+ type EventHandler<T> = (payload: T) => void | Promise<void>;
6
+
7
+ /**
8
+ * Generic typed event bus. Handlers are invoked synchronously; async handlers
9
+ * are fire-and-forget. Errors in handlers are caught and logged, never
10
+ * propagated to emitters.
11
+ */
12
+ export class EventBus<
13
+ TMap extends Record<string, any> = HeyHankEventMap,
14
+ > {
15
+ private handlers = new Map<keyof TMap, Set<EventHandler<any>>>();
16
+ private onceHandlers = new Map<keyof TMap, Set<EventHandler<any>>>();
17
+
18
+ /** Subscribe to an event. Returns an unsubscribe function. */
19
+ on<K extends keyof TMap>(
20
+ event: K,
21
+ handler: EventHandler<TMap[K]>,
22
+ ): () => void {
23
+ if (!this.handlers.has(event)) {
24
+ this.handlers.set(event, new Set());
25
+ }
26
+ this.handlers.get(event)!.add(handler);
27
+ return () => {
28
+ this.handlers.get(event)?.delete(handler);
29
+ };
30
+ }
31
+
32
+ /** Subscribe to an event; auto-unsubscribe after the first invocation. */
33
+ once<K extends keyof TMap>(
34
+ event: K,
35
+ handler: EventHandler<TMap[K]>,
36
+ ): () => void {
37
+ if (!this.onceHandlers.has(event)) {
38
+ this.onceHandlers.set(event, new Set());
39
+ }
40
+ this.onceHandlers.get(event)!.add(handler);
41
+ return () => {
42
+ this.onceHandlers.get(event)?.delete(handler);
43
+ };
44
+ }
45
+
46
+ /** Remove a specific handler for an event. */
47
+ off<K extends keyof TMap>(
48
+ event: K,
49
+ handler: EventHandler<TMap[K]>,
50
+ ): void {
51
+ this.handlers.get(event)?.delete(handler);
52
+ this.onceHandlers.get(event)?.delete(handler);
53
+ }
54
+
55
+ /**
56
+ * Emit an event to all subscribed handlers.
57
+ * Errors are caught and logged — never propagated to the emitter.
58
+ */
59
+ emit<K extends keyof TMap>(event: K, payload: TMap[K]): void {
60
+ const regular = this.handlers.get(event);
61
+ if (regular && regular.size > 0) {
62
+ const snapshot = [...regular];
63
+ for (const handler of snapshot) {
64
+ try {
65
+ const result = handler(payload);
66
+ if (result && typeof (result as Promise<void>).catch === "function") {
67
+ (result as Promise<void>).catch((err) => {
68
+ console.error(
69
+ `[event-bus] Async handler error for "${String(event)}":`,
70
+ err,
71
+ );
72
+ });
73
+ }
74
+ } catch (err) {
75
+ console.error(
76
+ `[event-bus] Handler error for "${String(event)}":`,
77
+ err,
78
+ );
79
+ }
80
+ }
81
+ }
82
+
83
+ const onces = this.onceHandlers.get(event);
84
+ if (onces && onces.size > 0) {
85
+ const snapshot = [...onces];
86
+ onces.clear();
87
+ for (const handler of snapshot) {
88
+ try {
89
+ const result = handler(payload);
90
+ if (result && typeof (result as Promise<void>).catch === "function") {
91
+ (result as Promise<void>).catch((err) => {
92
+ console.error(
93
+ `[event-bus] Async once-handler error for "${String(event)}":`,
94
+ err,
95
+ );
96
+ });
97
+ }
98
+ } catch (err) {
99
+ console.error(
100
+ `[event-bus] Once-handler error for "${String(event)}":`,
101
+ err,
102
+ );
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ /** Remove all handlers (useful for testing or shutdown). */
109
+ clear(): void {
110
+ this.handlers.clear();
111
+ this.onceHandlers.clear();
112
+ }
113
+
114
+ /** Return the number of handlers registered for an event. */
115
+ listenerCount<K extends keyof TMap>(event: K): number {
116
+ return (
117
+ (this.handlers.get(event)?.size ?? 0) +
118
+ (this.onceHandlers.get(event)?.size ?? 0)
119
+ );
120
+ }
121
+ }
122
+
123
+ /** Singleton bus instance for the HeyHank server. */
124
+ export const heyHankBus = new EventBus<HeyHankEventMap>();
@@ -0,0 +1,170 @@
1
+ // ─── Execution Store ────────────────────────────────────────────────────────
2
+ // Persists AgentExecution records to disk as JSONL (one file per day).
3
+ // Used by the Runs view to display execution history across server restarts.
4
+
5
+ import { mkdirSync, appendFileSync, readdirSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import type { AgentExecution } from "./agent-types.js";
9
+
10
+ const EXECUTIONS_DIR = join(homedir(), ".heyhank", "executions");
11
+
12
+ export interface ExecutionQuery {
13
+ agentId?: string;
14
+ triggerType?: string;
15
+ status?: "running" | "success" | "error";
16
+ limit?: number;
17
+ offset?: number;
18
+ }
19
+
20
+ export interface ExecutionListResult {
21
+ executions: AgentExecution[];
22
+ total: number;
23
+ }
24
+
25
+ export class ExecutionStore {
26
+ private dir: string;
27
+ /** In-memory cache of recent executions for fast access */
28
+ private recentCache: AgentExecution[] = [];
29
+ private static readonly MAX_CACHE_SIZE = 200;
30
+
31
+ constructor(dir?: string) {
32
+ this.dir = dir || EXECUTIONS_DIR;
33
+ mkdirSync(this.dir, { recursive: true });
34
+ this.loadRecentIntoCache();
35
+ }
36
+
37
+ /** Append an execution record to the daily JSONL file and in-memory cache. */
38
+ append(execution: AgentExecution): void {
39
+ const filename = this.dailyFilename(execution.startedAt);
40
+ const filepath = join(this.dir, filename);
41
+
42
+ try {
43
+ appendFileSync(filepath, JSON.stringify(execution) + "\n", "utf-8");
44
+ } catch (err) {
45
+ console.error("[execution-store] Failed to append execution:", err);
46
+ }
47
+
48
+ // Update cache
49
+ this.recentCache.unshift(execution);
50
+ if (this.recentCache.length > ExecutionStore.MAX_CACHE_SIZE) {
51
+ this.recentCache.length = ExecutionStore.MAX_CACHE_SIZE;
52
+ }
53
+ }
54
+
55
+ /** Update an existing execution in the cache and persist to disk. */
56
+ update(sessionId: string, updates: Partial<AgentExecution>): void {
57
+ const idx = this.recentCache.findIndex((e) => e.sessionId === sessionId);
58
+ if (idx < 0) {
59
+ console.warn(`[execution-store] update() called for unknown sessionId: ${sessionId} (not in cache)`);
60
+ return;
61
+ }
62
+ Object.assign(this.recentCache[idx], updates);
63
+ // Re-append the updated record to disk for durability.
64
+ // On next load, dedup by sessionId keeps the latest entry.
65
+ const updated = this.recentCache[idx];
66
+ const filename = this.dailyFilename(updated.startedAt);
67
+ const filepath = join(this.dir, filename);
68
+ try {
69
+ appendFileSync(filepath, JSON.stringify(updated) + "\n", "utf-8");
70
+ } catch (err) {
71
+ console.error("[execution-store] Failed to persist update:", err);
72
+ }
73
+ }
74
+
75
+ /** Query executions with pagination and filtering. */
76
+ list(opts?: ExecutionQuery): ExecutionListResult {
77
+ const limit = opts?.limit ?? 50;
78
+ const offset = opts?.offset ?? 0;
79
+
80
+ let filtered = this.getAllExecutions();
81
+
82
+ if (opts?.agentId) {
83
+ filtered = filtered.filter((e) => e.agentId === opts.agentId);
84
+ }
85
+ if (opts?.triggerType) {
86
+ filtered = filtered.filter((e) => e.triggerType === opts.triggerType);
87
+ }
88
+ if (opts?.status) {
89
+ filtered = filtered.filter((e) => {
90
+ if (opts.status === "running") return !e.completedAt;
91
+ if (opts.status === "success") return e.success === true;
92
+ if (opts.status === "error") return e.error !== undefined;
93
+ return true;
94
+ });
95
+ }
96
+
97
+ // Sort by startedAt descending (most recent first)
98
+ filtered.sort((a, b) => b.startedAt - a.startedAt);
99
+
100
+ const total = filtered.length;
101
+ const executions = filtered.slice(offset, offset + limit);
102
+
103
+ return { executions, total };
104
+ }
105
+
106
+ /** Get all executions from cache + disk. */
107
+ private getAllExecutions(): AgentExecution[] {
108
+ // For now, use the in-memory cache which is loaded from disk on startup.
109
+ // This is fast and sufficient for the Runs view.
110
+ return [...this.recentCache];
111
+ }
112
+
113
+ /** Load recent executions from disk into cache on startup. */
114
+ private loadRecentIntoCache(): void {
115
+ try {
116
+ const files = readdirSync(this.dir)
117
+ .filter((f) => f.startsWith("executions-") && f.endsWith(".jsonl"))
118
+ .sort()
119
+ .reverse(); // Most recent day first
120
+
121
+ const allLoaded: AgentExecution[] = [];
122
+
123
+ for (const file of files) {
124
+ if (allLoaded.length >= ExecutionStore.MAX_CACHE_SIZE * 2) break;
125
+
126
+ const filepath = join(this.dir, file);
127
+ const content = readFileSync(filepath, "utf-8");
128
+ const lines = content.trim().split("\n").filter(Boolean);
129
+
130
+ // Parse lines in reverse (most recent last in file = most up-to-date)
131
+ for (let i = lines.length - 1; i >= 0; i--) {
132
+ try {
133
+ const execution = JSON.parse(lines[i]) as AgentExecution;
134
+ allLoaded.push(execution);
135
+ } catch {
136
+ // Skip malformed lines
137
+ }
138
+ }
139
+ }
140
+
141
+ // Dedup by sessionId: since we read most-recent-last first, the first
142
+ // occurrence of a sessionId is the most up-to-date version.
143
+ const seen = new Set<string>();
144
+ for (const exec of allLoaded) {
145
+ if (seen.has(exec.sessionId)) continue;
146
+ seen.add(exec.sessionId);
147
+ this.recentCache.push(exec);
148
+ if (this.recentCache.length >= ExecutionStore.MAX_CACHE_SIZE) break;
149
+ }
150
+ } catch (err) {
151
+ // Log errors that aren't simply "directory not found" (ENOENT)
152
+ if (err && typeof err === "object" && "code" in err && (err as { code: string }).code !== "ENOENT") {
153
+ console.error("[execution-store] Failed to load executions from disk:", err);
154
+ }
155
+ }
156
+ }
157
+
158
+ /** Generate a daily JSONL filename from a timestamp. */
159
+ private dailyFilename(timestamp: number): string {
160
+ const date = new Date(timestamp);
161
+ const yyyy = date.getUTCFullYear();
162
+ const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
163
+ const dd = String(date.getUTCDate()).padStart(2, "0");
164
+ return `executions-${yyyy}-${mm}-${dd}.jsonl`;
165
+ }
166
+
167
+ get directory(): string {
168
+ return this.dir;
169
+ }
170
+ }
@@ -0,0 +1,190 @@
1
+ // ─── Node Connection ──────────────────────────────────────────────────────────
2
+ // Single outbound WebSocket to a peer node.
3
+ // Modeled on server/relay-client.ts: auth frame, exponential backoff, ping/pong.
4
+
5
+ import type { NodeConfig, NodeMessage, RemoteSessionSummary } from "./node-types.js";
6
+ import { PROTOCOL_VERSION, PING_INTERVAL_MS, RECONNECT_MIN_MS, RECONNECT_MAX_MS } from "./node-types.js";
7
+ import { getNodeIdentity } from "./node-store.js";
8
+ import type { NodeManager } from "./node-manager.js";
9
+
10
+ export class NodeConnection {
11
+ readonly config: NodeConfig;
12
+ private manager: NodeManager;
13
+ private ws: WebSocket | null = null;
14
+
15
+ remoteNodeId: string | null = null;
16
+ remoteName: string | null = null;
17
+ connected = false;
18
+ authenticated = false;
19
+ remoteSessions: RemoteSessionSummary[] = [];
20
+
21
+ private reconnectDelay = RECONNECT_MIN_MS;
22
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
23
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
24
+ private lastPong = 0;
25
+ private destroyed = false;
26
+
27
+ constructor(config: NodeConfig, manager: NodeManager) {
28
+ this.config = config;
29
+ this.manager = manager;
30
+ }
31
+
32
+ connect(): void {
33
+ if (this.destroyed) return;
34
+ this.cleanup();
35
+
36
+ const wsUrl = this.config.url.replace(/^http/, "ws") + "/ws/node";
37
+ try {
38
+ this.ws = new WebSocket(wsUrl);
39
+ } catch (err) {
40
+ console.log(`[federation] WS create error for ${this.config.name}: ${err instanceof Error ? err.message : String(err)}`);
41
+ this.scheduleReconnect();
42
+ return;
43
+ }
44
+
45
+ this.ws.addEventListener("open", () => {
46
+ this.reconnectDelay = RECONNECT_MIN_MS;
47
+ const identity = getNodeIdentity();
48
+ this.sendMsg({
49
+ type: "auth",
50
+ nodeId: identity.nodeId,
51
+ name: identity.name,
52
+ secret: this.config.secret,
53
+ version: PROTOCOL_VERSION,
54
+ });
55
+ });
56
+
57
+ this.ws.addEventListener("message", (event: MessageEvent) => {
58
+ try {
59
+ const msg = JSON.parse(typeof event.data === "string" ? event.data : String(event.data)) as NodeMessage;
60
+ this.handleMessage(msg);
61
+ } catch { /* malformed */ }
62
+ });
63
+
64
+ this.ws.addEventListener("close", () => {
65
+ this.onDisconnect();
66
+ });
67
+
68
+ this.ws.addEventListener("error", () => {
69
+ // Close event follows — reconnect handled there
70
+ });
71
+ }
72
+
73
+ private handleMessage(msg: NodeMessage): void {
74
+ if (msg.type === "auth_ok") {
75
+ this.authenticated = true;
76
+ this.connected = true;
77
+ this.remoteNodeId = msg.nodeId;
78
+ this.remoteName = msg.name;
79
+ this.lastPong = Date.now();
80
+ this.startPing();
81
+ console.log(`[federation] Connected to ${this.remoteName} (${this.remoteNodeId})`);
82
+ this.sendMsg({ type: "sessions_request" });
83
+ this.manager.onNodeConnected(this);
84
+ return;
85
+ }
86
+
87
+ if (msg.type === "auth_error") {
88
+ console.log(`[federation] Auth rejected by ${this.config.name}: ${msg.error}`);
89
+ // Don't reconnect on auth failure — secret is wrong
90
+ this.destroy();
91
+ return;
92
+ }
93
+
94
+ if (!this.authenticated) return;
95
+
96
+ switch (msg.type) {
97
+ case "pong":
98
+ this.lastPong = Date.now();
99
+ break;
100
+ case "ping":
101
+ this.sendMsg({ type: "pong" });
102
+ break;
103
+ case "sessions_request":
104
+ this.sendMsg({
105
+ type: "sessions_sync",
106
+ sessions: this.manager.getLocalSessionSummaries(),
107
+ });
108
+ break;
109
+ case "sessions_sync":
110
+ this.remoteSessions = msg.sessions ?? [];
111
+ this.manager.onSessionsUpdated();
112
+ break;
113
+ case "session_proxy":
114
+ this.manager.handleProxyRequest(this, msg);
115
+ break;
116
+ case "session_proxy_response":
117
+ if (msg.requestId) {
118
+ this.manager.resolveProxy(msg.requestId, msg);
119
+ }
120
+ break;
121
+ }
122
+ }
123
+
124
+ sendMsg(msg: NodeMessage): void {
125
+ if (this.ws?.readyState === WebSocket.OPEN) {
126
+ this.ws.send(JSON.stringify(msg));
127
+ }
128
+ }
129
+
130
+ private startPing(): void {
131
+ this.stopPing();
132
+ this.pingTimer = setInterval(() => {
133
+ if (Date.now() - this.lastPong > PING_INTERVAL_MS * 3) {
134
+ console.log(`[federation] Ping timeout for ${this.config.name}`);
135
+ this.ws?.close();
136
+ return;
137
+ }
138
+ this.sendMsg({ type: "ping" });
139
+ }, PING_INTERVAL_MS);
140
+ }
141
+
142
+ private stopPing(): void {
143
+ if (this.pingTimer) {
144
+ clearInterval(this.pingTimer);
145
+ this.pingTimer = null;
146
+ }
147
+ }
148
+
149
+ private onDisconnect(): void {
150
+ const wasConnected = this.connected;
151
+ this.connected = false;
152
+ this.authenticated = false;
153
+ this.stopPing();
154
+ this.cleanup();
155
+ if (wasConnected) {
156
+ console.log(`[federation] Disconnected from ${this.config.name}`);
157
+ this.manager.onNodeDisconnected(this);
158
+ }
159
+ if (!this.destroyed) this.scheduleReconnect();
160
+ }
161
+
162
+ private scheduleReconnect(): void {
163
+ if (this.destroyed || this.reconnectTimer) return;
164
+ const jitter = Math.random() * 500;
165
+ this.reconnectTimer = setTimeout(() => {
166
+ this.reconnectTimer = null;
167
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
168
+ this.connect();
169
+ }, this.reconnectDelay + jitter);
170
+ }
171
+
172
+ private cleanup(): void {
173
+ if (this.ws) {
174
+ try { this.ws.close(); } catch { /* ignore */ }
175
+ this.ws = null;
176
+ }
177
+ }
178
+
179
+ destroy(): void {
180
+ this.destroyed = true;
181
+ this.stopPing();
182
+ if (this.reconnectTimer) {
183
+ clearTimeout(this.reconnectTimer);
184
+ this.reconnectTimer = null;
185
+ }
186
+ this.cleanup();
187
+ this.connected = false;
188
+ this.authenticated = false;
189
+ }
190
+ }