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,1240 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import type {
3
+ BrowserOutgoingMessage,
4
+ BrowserIncomingMessage,
5
+ SessionState,
6
+ PermissionRequest,
7
+ BackendType,
8
+ McpServerConfig,
9
+ } from "./session-types.js";
10
+ import type { SessionStore } from "./session-store.js";
11
+ import type { IBackendAdapter } from "./backend-adapter.js";
12
+ import { ClaudeAdapter } from "./claude-adapter.js";
13
+ import type { RecorderManager } from "./recorder.js";
14
+ import { resolveSessionGitInfo } from "./session-git-info.js";
15
+ import type {
16
+ Session,
17
+ SocketData,
18
+ CLISocketData,
19
+ BrowserSocketData,
20
+ GitSessionKey,
21
+ } from "./ws-bridge-types.js";
22
+ import { makeDefaultState } from "./ws-bridge-types.js";
23
+ export type { SocketData } from "./ws-bridge-types.js";
24
+ import {
25
+ isHistoryBackedEvent,
26
+ } from "./ws-bridge-replay.js";
27
+ import {
28
+ parseBrowserMessage,
29
+ deduplicateBrowserMessage,
30
+ IDEMPOTENT_BROWSER_MESSAGE_TYPES,
31
+ } from "./ws-bridge-browser-ingest.js";
32
+ import {
33
+ appendHistory as appendHistoryFn,
34
+ persistSession as persistSessionFn,
35
+ } from "./ws-bridge-persist.js";
36
+ import {
37
+ broadcastToBrowsers as broadcastToBrowsersFn,
38
+ sendToBrowser as sendToBrowserFn,
39
+ EVENT_BUFFER_LIMIT,
40
+ } from "./ws-bridge-publish.js";
41
+ import {
42
+ handleSetAiValidation,
43
+ } from "./ws-bridge-controls.js";
44
+ import {
45
+ handleSessionSubscribe,
46
+ handleSessionAck,
47
+ } from "./ws-bridge-browser.js";
48
+ import { validatePermission } from "./ai-validator.js";
49
+ import { getEffectiveAiValidation } from "./ai-validation-settings.js";
50
+ import { heyHankBus } from "./event-bus.js";
51
+ import { SessionStateMachine } from "./session-state-machine.js";
52
+ import { metricsCollector } from "./metrics-collector.js";
53
+ import { log } from "./logger.js";
54
+
55
+ // ─── Bridge ───────────────────────────────────────────────────────────────────
56
+
57
+ const RETRYABLE_BACKEND_MESSAGE_TYPES = new Set<BrowserOutgoingMessage["type"]>([
58
+ "user_message",
59
+ "mcp_get_status",
60
+ "mcp_toggle",
61
+ "mcp_reconnect",
62
+ "mcp_set_servers",
63
+ ]);
64
+
65
+ export class WsBridge {
66
+ private static readonly PROCESSED_CLIENT_MSG_ID_LIMIT = 1000;
67
+ /** Maximum number of queued browser→backend messages per session to prevent unbounded memory growth. */
68
+ private static readonly PENDING_MESSAGES_LIMIT = 200;
69
+ private static readonly DISCONNECT_DEBOUNCE_MS = Number(
70
+ process.env.HEYHANK_DISCONNECT_DEBOUNCE_MS || process.env.COMPANION_DISCONNECT_DEBOUNCE_MS || "15000",
71
+ );
72
+ private disconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
73
+ private idleKillTimers = new Map<string, ReturnType<typeof setInterval>>();
74
+ private sessions = new Map<string, Session>();
75
+ private store: SessionStore | null = null;
76
+ private recorder: RecorderManager | null = null;
77
+ private autoNamingAttempted = new Set<string>();
78
+ private userMsgCounter = 0;
79
+ private static readonly GIT_SESSION_KEYS: GitSessionKey[] = [
80
+ "git_branch",
81
+ "is_worktree",
82
+ "is_containerized",
83
+ "repo_root",
84
+ "git_ahead",
85
+ "git_behind",
86
+ ];
87
+
88
+
89
+
90
+ /**
91
+ * Pre-populate a session with container info so that handleSystemMessage
92
+ * preserves the host cwd instead of overwriting it with /workspace.
93
+ * Call this right after launcher.launch() for containerized sessions.
94
+ */
95
+ markContainerized(sessionId: string, hostCwd: string): void {
96
+ const session = this.getOrCreateSession(sessionId);
97
+ session.state.is_containerized = true;
98
+ session.state.cwd = hostCwd;
99
+ }
100
+
101
+ /**
102
+ * Pre-populate slash_commands and skills on a session so they are
103
+ * available to browsers immediately (before system.init from the CLI).
104
+ * If system.init arrives later, it overwrites these with the CLI's
105
+ * authoritative list (see handleSystemMessage).
106
+ */
107
+ prePopulateCommands(sessionId: string, slashCommands: string[], skills: string[]): void {
108
+ const session = this.getOrCreateSession(sessionId);
109
+ let changed = false;
110
+ if (session.state.slash_commands.length === 0 && slashCommands.length > 0) {
111
+ session.state.slash_commands = slashCommands;
112
+ changed = true;
113
+ }
114
+ if (session.state.skills.length === 0 && skills.length > 0) {
115
+ session.state.skills = skills;
116
+ changed = true;
117
+ }
118
+ if (changed && session.browserSockets.size > 0) {
119
+ this.broadcastToBrowsers(session, { type: "session_init", session: session.state });
120
+ }
121
+ }
122
+
123
+ /** Push a message to all connected browsers for a session (public, for PRPoller etc.). */
124
+ broadcastToSession(sessionId: string, msg: BrowserIncomingMessage): void {
125
+ const session = this.sessions.get(sessionId);
126
+ if (!session) return;
127
+ this.broadcastToBrowsers(session, msg);
128
+ }
129
+
130
+ /** Attach a persistent store. Call restoreFromDisk() after. */
131
+ setStore(store: SessionStore): void {
132
+ this.store = store;
133
+ }
134
+
135
+ /** Attach a recorder for raw message capture. */
136
+ setRecorder(recorder: RecorderManager): void {
137
+ this.recorder = recorder;
138
+ }
139
+
140
+ /** Restore sessions from disk (call once at startup). */
141
+ restoreFromDisk(): number {
142
+ if (!this.store) return 0;
143
+ const persisted = this.store.loadAll();
144
+ let count = 0;
145
+ for (const p of persisted) {
146
+ if (this.sessions.has(p.id)) continue; // don't overwrite live sessions
147
+ const session: Session = {
148
+ id: p.id,
149
+ backendType: p.state.backend_type || "claude",
150
+ backendAdapter: null,
151
+ browserSockets: new Set(),
152
+ state: p.state,
153
+ pendingPermissions: new Map(p.pendingPermissions || []),
154
+ messageHistory: p.messageHistory || [],
155
+ pendingMessages: p.pendingMessages || [],
156
+ nextEventSeq: p.nextEventSeq && p.nextEventSeq > 0 ? p.nextEventSeq : 1,
157
+ eventBuffer: Array.isArray(p.eventBuffer) ? p.eventBuffer : [],
158
+ lastAckSeq: typeof p.lastAckSeq === "number" ? p.lastAckSeq : 0,
159
+ processedClientMessageIds: Array.isArray(p.processedClientMessageIds) ? p.processedClientMessageIds : [],
160
+ processedClientMessageIdSet: new Set(
161
+ Array.isArray(p.processedClientMessageIds) ? p.processedClientMessageIds : [],
162
+ ),
163
+ lastCliActivityTs: Date.now(),
164
+ stateMachine: new SessionStateMachine(p.id, "terminated"),
165
+ };
166
+ session.state.backend_type = session.backendType;
167
+ // Resolve git info for restored sessions (may have been persisted without it)
168
+ resolveSessionGitInfo(session.id, session.state);
169
+ this.sessions.set(p.id, session);
170
+ // Restored sessions with completed turns don't need auto-naming re-triggered
171
+ if (session.state.num_turns > 0) {
172
+ this.autoNamingAttempted.add(session.id);
173
+ }
174
+ count++;
175
+ }
176
+ if (count > 0) {
177
+ console.log(`[ws-bridge] Restored ${count} session(s) from disk`);
178
+ }
179
+ return count;
180
+ }
181
+
182
+ /** Persist a session to disk (debounced). Delegates to ws-bridge-persist. */
183
+ private persistSession(session: Session): void {
184
+ persistSessionFn(session, this.store);
185
+ }
186
+
187
+ private refreshGitInfo(
188
+ session: Session,
189
+ options: { broadcastUpdate?: boolean; notifyPoller?: boolean } = {},
190
+ ): void {
191
+ const before = {
192
+ git_branch: session.state.git_branch,
193
+ is_worktree: session.state.is_worktree,
194
+ is_containerized: session.state.is_containerized,
195
+ repo_root: session.state.repo_root,
196
+ git_ahead: session.state.git_ahead,
197
+ git_behind: session.state.git_behind,
198
+ };
199
+
200
+ resolveSessionGitInfo(session.id, session.state);
201
+
202
+ let changed = false;
203
+ for (const key of WsBridge.GIT_SESSION_KEYS) {
204
+ if (session.state[key] !== before[key]) {
205
+ changed = true;
206
+ break;
207
+ }
208
+ }
209
+
210
+ if (changed) {
211
+ if (options.broadcastUpdate) {
212
+ this.broadcastToBrowsers(session, {
213
+ type: "session_update",
214
+ session: {
215
+ git_branch: session.state.git_branch,
216
+ is_worktree: session.state.is_worktree,
217
+ is_containerized: session.state.is_containerized,
218
+ repo_root: session.state.repo_root,
219
+ git_ahead: session.state.git_ahead,
220
+ git_behind: session.state.git_behind,
221
+ },
222
+ });
223
+ }
224
+ this.persistSession(session);
225
+ }
226
+
227
+ if (options.notifyPoller && session.state.git_branch && session.state.cwd) {
228
+ heyHankBus.emit("session:git-info-ready", { sessionId: session.id, cwd: session.state.cwd, branch: session.state.git_branch });
229
+ }
230
+ }
231
+
232
+ // ── Session management ──────────────────────────────────────────────────
233
+
234
+ getOrCreateSession(sessionId: string, backendType?: BackendType): Session {
235
+ let session = this.sessions.get(sessionId);
236
+ if (!session) {
237
+ const type = backendType || "claude";
238
+ session = {
239
+ id: sessionId,
240
+ backendType: type,
241
+ backendAdapter: null,
242
+ browserSockets: new Set(),
243
+ state: makeDefaultState(sessionId, type),
244
+ pendingPermissions: new Map(),
245
+ messageHistory: [],
246
+ pendingMessages: [],
247
+ nextEventSeq: 1,
248
+ eventBuffer: [],
249
+ lastAckSeq: 0,
250
+ processedClientMessageIds: [],
251
+ processedClientMessageIdSet: new Set(),
252
+ lastCliActivityTs: Date.now(),
253
+ stateMachine: new SessionStateMachine(sessionId),
254
+ };
255
+ this.sessions.set(sessionId, session);
256
+ this.wireStateMachineListeners(session);
257
+ } else if (backendType) {
258
+ // Only overwrite backendType when explicitly provided (e.g. attachBackendAdapter)
259
+ // Prevents handleBrowserOpen from resetting codex→claude
260
+ session.backendType = backendType;
261
+ session.state.backend_type = backendType;
262
+ }
263
+ return session;
264
+ }
265
+
266
+ getSession(sessionId: string): Session | undefined {
267
+ return this.sessions.get(sessionId);
268
+ }
269
+
270
+ getAllSessions(): SessionState[] {
271
+ return Array.from(this.sessions.values()).map((s) => s.state);
272
+ }
273
+
274
+ /** Return per-session memory stats for diagnostics. */
275
+ getSessionMemoryStats(): { id: string; browsers: number; historyLen: number; eventBufferLen: number; pendingMsgs: number }[] {
276
+ return Array.from(this.sessions.values()).map((s) => ({
277
+ id: s.id,
278
+ browsers: s.browserSockets.size,
279
+ historyLen: s.messageHistory.length,
280
+ eventBufferLen: s.eventBuffer.length,
281
+ pendingMsgs: s.pendingMessages.length,
282
+ }));
283
+ }
284
+
285
+ /** Return current phase for each session (for metrics gauges). */
286
+ getSessionPhases(): Map<string, import("./session-state-machine.js").SessionPhase> {
287
+ const phases = new Map<string, import("./session-state-machine.js").SessionPhase>();
288
+ for (const [id, session] of this.sessions) {
289
+ phases.set(id, session.stateMachine.phase);
290
+ }
291
+ return phases;
292
+ }
293
+
294
+ getCodexRateLimits(sessionId: string) {
295
+ const session = this.sessions.get(sessionId);
296
+ return session?.backendAdapter?.getRateLimits?.() ?? null;
297
+ }
298
+
299
+ isCliConnected(sessionId: string): boolean {
300
+ const session = this.sessions.get(sessionId);
301
+ return session?.backendAdapter?.isConnected() ?? false;
302
+ }
303
+
304
+ removeSession(sessionId: string) {
305
+ const session = this.sessions.get(sessionId);
306
+ session?.unsubscribeStateMachine?.();
307
+ this.cancelDisconnectTimer(sessionId);
308
+ this.stopIdleKillWatchdog(sessionId);
309
+ this.sessions.delete(sessionId);
310
+ this.autoNamingAttempted.delete(sessionId);
311
+ this.store?.remove(sessionId);
312
+ }
313
+
314
+ /** Wire state machine transition listener to broadcast phase changes. */
315
+ private wireStateMachineListeners(session: Session): void {
316
+ // Unsubscribe any previous listener (e.g. from session restoration) to prevent leaks
317
+ session.unsubscribeStateMachine?.();
318
+ session.unsubscribeStateMachine = session.stateMachine.onTransition((event) => {
319
+ heyHankBus.emit("session:phase-changed", {
320
+ sessionId: event.sessionId,
321
+ from: event.from,
322
+ to: event.to,
323
+ trigger: event.trigger,
324
+ });
325
+ this.broadcastToBrowsers(session, {
326
+ type: "session_phase",
327
+ phase: event.to,
328
+ previousPhase: event.from,
329
+ });
330
+ });
331
+ }
332
+
333
+ /**
334
+ * Close all sockets (CLI + browsers) for a session and remove it.
335
+ */
336
+ closeSession(sessionId: string) {
337
+ this.cancelDisconnectTimer(sessionId);
338
+ this.stopIdleKillWatchdog(sessionId);
339
+ const session = this.sessions.get(sessionId);
340
+ if (!session) return;
341
+
342
+ // Unsubscribe state machine listener to prevent leaks
343
+ session.unsubscribeStateMachine?.();
344
+
345
+ // Disconnect backend adapter (Claude or Codex)
346
+ if (session.backendAdapter) {
347
+ session.backendAdapter.disconnect().catch(() => {});
348
+ session.backendAdapter = null;
349
+ }
350
+
351
+ // Close all browser sockets
352
+ for (const ws of session.browserSockets) {
353
+ try { ws.close(); } catch {}
354
+ }
355
+ session.browserSockets.clear();
356
+
357
+ this.sessions.delete(sessionId);
358
+ this.autoNamingAttempted.delete(sessionId);
359
+ this.store?.remove(sessionId);
360
+ }
361
+
362
+ // ── Backend adapter attachment ────────────────────────────────────────────
363
+
364
+ /**
365
+ * Attach a backend adapter (Claude or Codex) to a session.
366
+ * Wires up the shared event pipeline: activity tracking, session state
367
+ * merging, history appending, broadcasting, and persistence.
368
+ */
369
+ attachBackendAdapter(sessionId: string, adapter: IBackendAdapter, backendType?: BackendType): void {
370
+ const session = this.getOrCreateSession(sessionId, backendType);
371
+ session.backendAdapter = adapter;
372
+
373
+ // Advance the state machine so that system_init (starting → ready) is reachable.
374
+ // For Claude, handleCLIOpen does starting → initializing via cli_ws_open.
375
+ // For Codex (and any non-Claude adapter), the adapter attachment IS the transport
376
+ // open event — no separate WS open fires — so do the equivalent transition here.
377
+ // Also handles relaunched sessions stuck in "terminated": step through
378
+ // terminated → starting → initializing so system_init can land on "ready".
379
+ if (!(adapter instanceof ClaudeAdapter)) {
380
+ const phase = session.stateMachine.phase;
381
+ if (phase === "terminated") {
382
+ session.stateMachine.transition("starting", "adapter_reattached");
383
+ }
384
+ // starting → initializing (or reconnecting → initializing)
385
+ session.stateMachine.transition("initializing", "adapter_attached");
386
+ }
387
+
388
+ // ── onBrowserMessage — messages from backend → browsers ──────────────
389
+ adapter.onBrowserMessage((msg) => {
390
+ // Track activity for idle detection
391
+ session.lastCliActivityTs = Date.now();
392
+ metricsCollector.recordMessageProcessed(msg.type);
393
+
394
+ // -- session_init: merge into session state, broadcast, persist -----
395
+ if (msg.type === "session_init") {
396
+ // Exclude session_id from the spread: the CLI reports its own internal
397
+ // session ID which differs from the HeyHank's session ID. Allowing
398
+ // it to overwrite session.state.session_id causes the browser to key
399
+ // the session under the wrong ID, producing duplicate sidebar entries.
400
+ const { slash_commands, skills, session_id: _cliSessionId, ...rest } = msg.session;
401
+ // For containerized sessions, the CLI reports /workspace as its cwd.
402
+ // Keep the host path (set by markContainerized()) for correct project grouping.
403
+ const cwdOverride = session.state.is_containerized ? { cwd: session.state.cwd } : {};
404
+ session.state = {
405
+ ...session.state,
406
+ ...rest,
407
+ // Preserve pre-populated commands/skills when adapter sends empty arrays
408
+ ...(slash_commands?.length ? { slash_commands } : {}),
409
+ ...(skills?.length ? { skills } : {}),
410
+ ...cwdOverride,
411
+ backend_type: session.backendType,
412
+ };
413
+ this.refreshGitInfo(session, { notifyPoller: true });
414
+ this.broadcastToBrowsers(session, { type: "session_init", session: session.state });
415
+ session.stateMachine.transition("ready", "system_init");
416
+ this.persistSession(session);
417
+ return;
418
+ }
419
+
420
+ // -- session_update: merge into session state, persist ---------------
421
+ if (msg.type === "session_update") {
422
+ // Exclude session_id — same rationale as session_init above.
423
+ const { slash_commands, skills, session_id: _cliSessionId, ...rest } = msg.session;
424
+ session.state = {
425
+ ...session.state,
426
+ ...rest,
427
+ ...(slash_commands?.length ? { slash_commands } : {}),
428
+ ...(skills?.length ? { skills } : {}),
429
+ backend_type: session.backendType,
430
+ };
431
+ this.refreshGitInfo(session, { notifyPoller: true });
432
+ this.persistSession(session);
433
+ if (session.pendingMessages.length > 0 && adapter.isConnected()) {
434
+ this.flushQueuedBrowserMessages(session, adapter, "backend_session_update");
435
+ }
436
+ }
437
+
438
+ // -- status_change: update compacting flag ---------------------------
439
+ if (msg.type === "status_change") {
440
+ session.state.is_compacting = msg.status === "compacting";
441
+ if (msg.status === "compacting") {
442
+ session.stateMachine.transition("compacting", "compaction_started");
443
+ } else {
444
+ session.stateMachine.transition("ready", "compaction_ended");
445
+ }
446
+ // Claude status messages may include permissionMode (not in the typed interface)
447
+ const permMode = (msg as unknown as { permissionMode?: string }).permissionMode;
448
+ if (permMode) {
449
+ session.state.permissionMode = permMode;
450
+ }
451
+ this.persistSession(session);
452
+ }
453
+
454
+ if (msg.type === "user_message") {
455
+ const alreadyPersisted = msg.id
456
+ ? session.messageHistory.some((entry) => entry.type === "user_message" && entry.id === msg.id)
457
+ : false;
458
+ if (!alreadyPersisted) {
459
+ this.appendHistory(session, msg);
460
+ this.persistSession(session);
461
+ }
462
+ }
463
+
464
+ // -- assistant: append to history, notify listeners ------------------
465
+ if (msg.type === "assistant") {
466
+ const assistantMsg = { ...msg, timestamp: msg.timestamp || Date.now() };
467
+ this.appendHistory(session, assistantMsg);
468
+ this.persistSession(session);
469
+ heyHankBus.emit("message:assistant", { sessionId: session.id, message: assistantMsg });
470
+ }
471
+
472
+ if (msg.type === "stream_event") {
473
+ heyHankBus.emit("message:stream_event", { sessionId: session.id, message: msg });
474
+ }
475
+
476
+ // -- result: update session cost/turns, refresh git, notify listeners
477
+ if (msg.type === "result") {
478
+ const resultData = msg.data;
479
+ session.state.total_cost_usd = resultData.total_cost_usd;
480
+ session.state.num_turns = resultData.num_turns;
481
+ if (typeof resultData.total_lines_added === "number") {
482
+ session.state.total_lines_added = resultData.total_lines_added;
483
+ }
484
+ if (typeof resultData.total_lines_removed === "number") {
485
+ session.state.total_lines_removed = resultData.total_lines_removed;
486
+ }
487
+ if (resultData.modelUsage) {
488
+ for (const usage of Object.values(resultData.modelUsage)) {
489
+ if (usage.contextWindow > 0) {
490
+ const pct = Math.round(
491
+ ((usage.inputTokens + usage.outputTokens) / usage.contextWindow) * 100
492
+ );
493
+ session.state.context_used_percent = Math.max(0, Math.min(pct, 100));
494
+ }
495
+ }
496
+ }
497
+ this.refreshGitInfo(session, { broadcastUpdate: true, notifyPoller: true });
498
+ this.appendHistory(session, msg);
499
+ session.stateMachine.transition("ready", "turn_completed");
500
+ this.persistSession(session);
501
+ heyHankBus.emit("message:result", { sessionId: session.id, message: msg });
502
+
503
+ // Trigger auto-naming after first successful result
504
+ if (
505
+ !(resultData as { is_error?: boolean }).is_error &&
506
+ !this.autoNamingAttempted.has(session.id)
507
+ ) {
508
+ this.autoNamingAttempted.add(session.id);
509
+ const firstUserMsg = session.messageHistory.find((m) => m.type === "user_message");
510
+ if (firstUserMsg && firstUserMsg.type === "user_message") {
511
+ heyHankBus.emit("session:first-turn-completed", { sessionId: session.id, firstUserMessage: firstUserMsg.content });
512
+ }
513
+ }
514
+ }
515
+
516
+ // -- permission_request: AI validation, add to pending ---------------
517
+ if (msg.type === "permission_request") {
518
+ const perm = msg.request;
519
+ metricsCollector.recordPermissionRequested(perm.request_id, session.id);
520
+
521
+ // AI Validation Mode: evaluate the tool call before showing to user
522
+ const aiSettings = getEffectiveAiValidation(session.state);
523
+ if (
524
+ aiSettings.enabled
525
+ && aiSettings.anthropicApiKey
526
+ && perm.tool_name !== "AskUserQuestion"
527
+ && perm.tool_name !== "ExitPlanMode"
528
+ ) {
529
+ // Run AI validation async
530
+ this.handleAiValidation(session, adapter, perm).catch((err) => {
531
+ console.warn(`[ws-bridge] AI validation error for tool=${perm.tool_name} request_id=${perm.request_id} session=${session.id}, falling through to manual:`, err);
532
+ // On error, fall through to normal permission flow
533
+ session.pendingPermissions.set(perm.request_id, perm);
534
+ session.stateMachine.transition("awaiting_permission", "ai_validation_error_fallback");
535
+ this.persistSession(session);
536
+ this.broadcastToBrowsers(session, msg);
537
+ });
538
+ return; // Don't broadcast yet — AI validation is async
539
+ }
540
+
541
+ session.pendingPermissions.set(perm.request_id, perm);
542
+ session.stateMachine.transition("awaiting_permission", "permission_requested");
543
+ this.persistSession(session);
544
+ }
545
+
546
+ // -- permission_cancelled: remove from pending -----------------------
547
+ if (msg.type === "permission_cancelled") {
548
+ const reqId = (msg as { request_id: string }).request_id;
549
+ session.pendingPermissions.delete(reqId);
550
+ // If no more pending permissions, transition back to streaming
551
+ if (session.pendingPermissions.size === 0 && session.stateMachine.phase === "awaiting_permission") {
552
+ session.stateMachine.transition("streaming", "permission_cancelled");
553
+ }
554
+ this.persistSession(session);
555
+ }
556
+
557
+ // -- system_event: append to history (except hook_progress) ----------
558
+ if (msg.type === "system_event") {
559
+ const event = msg.event;
560
+ if (event.subtype !== "hook_progress") {
561
+ this.appendHistory(session, msg);
562
+ this.persistSession(session);
563
+ }
564
+ }
565
+
566
+ // Broadcast all messages to browsers
567
+ this.broadcastToBrowsers(session, msg);
568
+ });
569
+
570
+ // ── onSessionMeta — metadata updates (CLI session ID, model, cwd) ────
571
+ adapter.onSessionMeta((meta) => {
572
+ if (meta.cliSessionId) {
573
+ heyHankBus.emit("session:cli-id-received", { sessionId: session.id, cliSessionId: meta.cliSessionId });
574
+ }
575
+ if (meta.model) session.state.model = meta.model;
576
+ // For containerized sessions, the CLI reports the container's cwd (e.g. /workspace).
577
+ // Keep the host path (set by markContainerized()) for correct project grouping.
578
+ if (meta.cwd && !session.state.is_containerized) {
579
+ session.state.cwd = meta.cwd;
580
+ }
581
+ session.state.backend_type = session.backendType;
582
+ this.refreshGitInfo(session, { broadcastUpdate: true, notifyPoller: true });
583
+ this.persistSession(session);
584
+ if (session.pendingMessages.length > 0 && adapter.isConnected()) {
585
+ this.flushQueuedBrowserMessages(session, adapter, "backend_session_meta");
586
+ }
587
+ });
588
+
589
+ // ── onDisconnect — handle transport disconnection ────────────────────
590
+ adapter.onDisconnect(() => {
591
+ // Guard: only act if THIS adapter is still the active one
592
+ if (session.backendAdapter !== adapter) {
593
+ console.log(`[ws-bridge] Ignoring stale disconnect for session ${sessionId} (adapter replaced)`);
594
+ return;
595
+ }
596
+
597
+ // For ClaudeAdapter, disconnect is handled by handleCLIClose debounce logic
598
+ if (adapter instanceof ClaudeAdapter) {
599
+ // Do nothing here — handleCLIClose manages the debounce timer
600
+ return;
601
+ }
602
+
603
+ // For Codex adapters: immediate cleanup + auto-relaunch
604
+ for (const [reqId] of session.pendingPermissions) {
605
+ this.broadcastToBrowsers(session, { type: "permission_cancelled", request_id: reqId });
606
+ }
607
+ session.pendingPermissions.clear();
608
+ session.backendAdapter = null;
609
+ this.persistSession(session);
610
+ console.log(`[ws-bridge] Backend adapter disconnected for session ${sessionId}`);
611
+ this.broadcastToBrowsers(session, { type: "cli_disconnected" });
612
+
613
+ // Auto-relaunch if browsers are still connected
614
+ if (session.browserSockets.size > 0) {
615
+ console.log(`[ws-bridge] Auto-relaunching backend for session ${sessionId} (${session.browserSockets.size} browser(s) connected)`);
616
+ heyHankBus.emit("session:relaunch-needed", { sessionId });
617
+ }
618
+ });
619
+
620
+ // ── onInitError (optional) ───────────────────────────────────────────
621
+ adapter.onInitError?.((error) => {
622
+ log.error("ws-bridge", "Backend init error", { sessionId, error });
623
+ this.broadcastToBrowsers(session, { type: "error", message: error });
624
+ });
625
+
626
+ // Flush pending messages for non-Claude backends (Codex uses stdio, not
627
+ // a CLI WebSocket, so handleCLIOpen never runs to flush the queue).
628
+ // For Claude backends, handleCLIOpen handles this after attachWebSocket.
629
+ if (!(adapter instanceof ClaudeAdapter) && session.pendingMessages.length > 0) {
630
+ this.flushQueuedBrowserMessages(session, adapter, "adapter_attach");
631
+ this.persistSession(session);
632
+ }
633
+
634
+ // Broadcast cli_connected
635
+ this.broadcastToBrowsers(session, { type: "cli_connected" });
636
+ log.info("ws-bridge", "Backend adapter attached", {
637
+ sessionId,
638
+ backendType: session.backendType,
639
+ });
640
+ }
641
+
642
+ /** AI validation for permission requests — shared by Claude and Codex paths. */
643
+ private async handleAiValidation(
644
+ session: Session,
645
+ adapter: IBackendAdapter,
646
+ perm: PermissionRequest,
647
+ ): Promise<void> {
648
+ const aiSettings = getEffectiveAiValidation(session.state);
649
+ const result = await validatePermission(
650
+ perm.tool_name,
651
+ perm.input,
652
+ perm.description,
653
+ );
654
+
655
+ perm.ai_validation = {
656
+ verdict: result.verdict,
657
+ reason: result.reason,
658
+ ruleBasedOnly: result.ruleBasedOnly,
659
+ };
660
+
661
+ // Auto-approve safe tools
662
+ if (result.verdict === "safe" && aiSettings.autoApprove) {
663
+ metricsCollector.recordPermissionResolved(perm.request_id, "allow", true);
664
+ this.broadcastToBrowsers(session, {
665
+ type: "permission_auto_resolved",
666
+ request: perm,
667
+ behavior: "allow",
668
+ reason: result.reason,
669
+ });
670
+ adapter.send({
671
+ type: "permission_response",
672
+ request_id: perm.request_id,
673
+ behavior: "allow",
674
+ updated_input: perm.input,
675
+ });
676
+ return;
677
+ }
678
+
679
+ // Auto-deny dangerous tools
680
+ if (result.verdict === "dangerous" && aiSettings.autoDeny) {
681
+ metricsCollector.recordPermissionResolved(perm.request_id, "deny", true);
682
+ this.broadcastToBrowsers(session, {
683
+ type: "permission_auto_resolved",
684
+ request: perm,
685
+ behavior: "deny",
686
+ reason: result.reason,
687
+ });
688
+ adapter.send({
689
+ type: "permission_response",
690
+ request_id: perm.request_id,
691
+ behavior: "deny",
692
+ });
693
+ return;
694
+ }
695
+
696
+ // Uncertain or auto-action disabled: fall through to manual
697
+ session.pendingPermissions.set(perm.request_id, perm);
698
+ session.stateMachine.transition("awaiting_permission", "ai_validation_manual_fallback");
699
+ this.persistSession(session);
700
+ this.broadcastToBrowsers(session, {
701
+ type: "permission_request",
702
+ request: perm,
703
+ });
704
+ }
705
+
706
+ /** Cancel a pending disconnect debounce timer for a session, if any. */
707
+ private cancelDisconnectTimer(sessionId: string): boolean {
708
+ const timer = this.disconnectTimers.get(sessionId);
709
+ if (!timer) return false;
710
+ clearTimeout(timer);
711
+ this.disconnectTimers.delete(sessionId);
712
+ return true;
713
+ }
714
+
715
+ // ── CLI WebSocket handlers ──────────────────────────────────────────────
716
+
717
+ handleCLIOpen(ws: ServerWebSocket<SocketData>, sessionId: string) {
718
+ metricsCollector.recordWsConnection("cli", "open");
719
+ this.recorder?.recordEvent(sessionId, "ws_open", "cli");
720
+ const session = this.getOrCreateSession(sessionId);
721
+
722
+ // Create or retrieve ClaudeAdapter for this session
723
+ let adapter: ClaudeAdapter;
724
+ let isNewAdapter = false;
725
+ if (session.backendAdapter instanceof ClaudeAdapter) {
726
+ adapter = session.backendAdapter;
727
+ } else {
728
+ isNewAdapter = true;
729
+ adapter = new ClaudeAdapter(sessionId, {
730
+ recorder: this.recorder,
731
+ onActivityUpdate: () => { session.lastCliActivityTs = Date.now(); },
732
+ });
733
+ // Wire up the shared event pipeline via attachBackendAdapter
734
+ // (also broadcasts cli_connected for new adapters)
735
+ this.attachBackendAdapter(sessionId, adapter);
736
+ }
737
+ // For relaunched sessions the state machine may be "terminated".
738
+ // Step through terminated → starting first so the cli_ws_open trigger can land.
739
+ if (session.stateMachine.phase === "terminated") {
740
+ session.stateMachine.transition("starting", "cli_reattached");
741
+ }
742
+ session.stateMachine.transition("initializing", "cli_ws_open");
743
+
744
+ // Cancel any pending disconnect debounce timer — CLI reconnected in time
745
+ if (this.cancelDisconnectTimer(sessionId)) {
746
+ log.info("ws-bridge", "CLI reconnected (debounce cancelled)", { sessionId });
747
+ } else {
748
+ log.info("ws-bridge", "CLI connected", { sessionId });
749
+ }
750
+
751
+ // Attach the raw WebSocket to the adapter (flushes pending NDJSON)
752
+ adapter.attachWebSocket(ws);
753
+
754
+ // Broadcast cli_connected on reconnection (new adapters already got this
755
+ // via attachBackendAdapter to avoid double-broadcasting)
756
+ if (!isNewAdapter) {
757
+ this.broadcastToBrowsers(session, { type: "cli_connected" });
758
+ }
759
+
760
+ // Flush any messages queued while waiting for the CLI WebSocket.
761
+ // Per the SDK protocol, the first user message triggers system.init,
762
+ // so we must send it as soon as the WebSocket is open — NOT wait for
763
+ // system.init (which would create a deadlock for slow-starting sessions
764
+ // like Docker containers where the user message arrives before CLI connects).
765
+ if (session.pendingMessages.length > 0) {
766
+ console.log(`[ws-bridge] Flushing ${session.pendingMessages.length} queued message(s) on CLI connect for session ${sessionId}`);
767
+ const queued = session.pendingMessages.splice(0);
768
+ for (const raw of queued) {
769
+ try {
770
+ const queued_msg = JSON.parse(raw) as BrowserOutgoingMessage;
771
+ adapter.send(queued_msg);
772
+ } catch {
773
+ console.warn(`[ws-bridge] Failed to parse queued message: ${raw.substring(0, 100)}`);
774
+ }
775
+ }
776
+ }
777
+ }
778
+
779
+ handleCLIMessage(ws: ServerWebSocket<SocketData>, raw: string | Buffer) {
780
+ const data = typeof raw === "string" ? raw : raw.toString("utf-8");
781
+ const sessionId = (ws.data as CLISocketData).sessionId;
782
+ const session = this.sessions.get(sessionId);
783
+ if (!session) return;
784
+
785
+ // Delegate raw NDJSON parsing, dedup, and routing to the ClaudeAdapter
786
+ // (recording is done inside the adapter's handleRawMessage)
787
+ if (!(session.backendAdapter instanceof ClaudeAdapter)) {
788
+ console.warn(`[ws-bridge] handleCLIMessage: no ClaudeAdapter for session ${sessionId}, dropping message`);
789
+ return;
790
+ }
791
+ session.backendAdapter.handleRawMessage(data);
792
+ }
793
+
794
+ handleCLIClose(ws: ServerWebSocket<SocketData>) {
795
+ metricsCollector.recordWsConnection("cli", "close");
796
+ const sessionId = (ws.data as CLISocketData).sessionId;
797
+ this.recorder?.recordEvent(sessionId, "ws_close", "cli");
798
+ const session = this.sessions.get(sessionId);
799
+ if (!session) return;
800
+
801
+ // Detach the WebSocket from the ClaudeAdapter (guards against stale sockets)
802
+ if (session.backendAdapter instanceof ClaudeAdapter) {
803
+ session.backendAdapter.detachWebSocket(ws);
804
+ }
805
+ session.stateMachine.transition("reconnecting", "cli_ws_closed");
806
+
807
+ // Debounce: delay disconnect notification by 15s.
808
+ // CLI cycles its WebSocket every ~30s (close code 1000) and uses exponential
809
+ // backoff (1s → 2s → 4s → 8s → …) on reconnect. After rapid successive
810
+ // disconnects, the backoff can exceed 5s, so we use 15s to cover the worst
811
+ // case (8s backoff + connection overhead).
812
+ const existing = this.disconnectTimers.get(sessionId);
813
+ if (existing) clearTimeout(existing);
814
+ this.disconnectTimers.set(sessionId, setTimeout(() => {
815
+ this.disconnectTimers.delete(sessionId);
816
+ // Check if CLI reconnected during grace period
817
+ if (session.backendAdapter?.isConnected()) return;
818
+ log.warn("ws-bridge", "CLI disconnect confirmed", { sessionId });
819
+ session.stateMachine.transition("terminated", "disconnect_confirmed");
820
+ this.broadcastToBrowsers(session, { type: "cli_disconnected" });
821
+ for (const [reqId] of session.pendingPermissions) {
822
+ this.broadcastToBrowsers(session, { type: "permission_cancelled", request_id: reqId });
823
+ }
824
+ session.pendingPermissions.clear();
825
+ }, WsBridge.DISCONNECT_DEBOUNCE_MS));
826
+ }
827
+
828
+ // ── Browser WebSocket handlers ──────────────────────────────────────────
829
+
830
+ handleBrowserOpen(ws: ServerWebSocket<SocketData>, sessionId: string) {
831
+ metricsCollector.recordWsConnection("browser", "open");
832
+ this.recorder?.recordEvent(sessionId, "ws_open", "browser");
833
+ const session = this.getOrCreateSession(sessionId);
834
+ const browserData = ws.data as BrowserSocketData;
835
+ browserData.subscribed = false;
836
+ browserData.lastAckSeq = 0;
837
+ session.browserSockets.add(ws);
838
+ log.info("ws-bridge", "Browser connected", { sessionId, browsers: session.browserSockets.size });
839
+
840
+ // Cancel idle kill watchdog — a browser is back
841
+ this.stopIdleKillWatchdog(sessionId);
842
+
843
+ // Refresh git state on browser connect so branch changes made mid-session are reflected.
844
+ this.refreshGitInfo(session, { notifyPoller: true });
845
+
846
+ // Send current session state as snapshot
847
+ const snapshot: BrowserIncomingMessage = {
848
+ type: "session_init",
849
+ session: session.state,
850
+ };
851
+ this.sendToBrowser(ws, snapshot);
852
+
853
+ // Replay message history so the browser can reconstruct the conversation
854
+ if (session.messageHistory.length > 0) {
855
+ this.sendToBrowser(ws, {
856
+ type: "message_history",
857
+ messages: session.messageHistory,
858
+ });
859
+ }
860
+
861
+ // Send any pending permission requests
862
+ for (const perm of session.pendingPermissions.values()) {
863
+ this.sendToBrowser(ws, { type: "permission_request", request: perm });
864
+ }
865
+
866
+ // Notify if backend is not connected and request relaunch.
867
+ // Treat an attached adapter as "alive" during init — `isConnected()`
868
+ // may flip true only after initialize/thread start, and relaunching
869
+ // during that window can kill a healthy startup.
870
+ const backendConnected = !!session.backendAdapter;
871
+
872
+ if (!backendConnected && !this.disconnectTimers.has(sessionId)) {
873
+ // Only signal disconnection if we're not within the debounce window
874
+ // (CLI may be mid-reconnect — avoid UI flap and spurious relaunch)
875
+ this.sendToBrowser(ws, { type: "cli_disconnected" });
876
+ console.log(`[ws-bridge] Browser connected but backend is dead for session ${sessionId}, requesting relaunch`);
877
+ heyHankBus.emit("session:relaunch-needed", { sessionId });
878
+ }
879
+ }
880
+
881
+ handleBrowserMessage(ws: ServerWebSocket<SocketData>, raw: string | Buffer) {
882
+ const data = typeof raw === "string" ? raw : raw.toString("utf-8");
883
+ const sessionId = (ws.data as BrowserSocketData).sessionId;
884
+ const session = this.sessions.get(sessionId);
885
+ if (!session) return;
886
+
887
+ // Record raw incoming browser message
888
+ this.recorder?.record(sessionId, "in", data, "browser", session.backendType, session.state.cwd);
889
+
890
+ // Pipeline: parse → route (dedup happens inside routeBrowserMessage)
891
+ const msg = parseBrowserMessage(data);
892
+ if (!msg) return;
893
+
894
+ this.routeBrowserMessage(session, msg, ws);
895
+ }
896
+
897
+ /** Check if a session has a connected CLI backend */
898
+ hasConnectedCli(sessionId: string): boolean {
899
+ const session = this.sessions.get(sessionId);
900
+ return !!session && !!session.backendAdapter && session.backendAdapter.isConnected();
901
+ }
902
+
903
+ /** Send a user message into a session programmatically (no browser required).
904
+ * Used by the cron scheduler and agent executor to send prompts to autonomous sessions. */
905
+ injectUserMessage(sessionId: string, content: string): void {
906
+ const session = this.sessions.get(sessionId);
907
+ if (!session) {
908
+ console.error(`[ws-bridge] Cannot inject message: session ${sessionId} not found`);
909
+ return;
910
+ }
911
+ this.routeBrowserMessage(session, { type: "user_message", content });
912
+ }
913
+
914
+ /** Configure MCP servers on a session programmatically (no browser required).
915
+ * Used by the agent executor to set up MCP servers after CLI connects. */
916
+ injectMcpSetServers(sessionId: string, servers: Record<string, McpServerConfig>): void {
917
+ const session = this.sessions.get(sessionId);
918
+ if (!session) {
919
+ console.error(`[ws-bridge] Cannot inject MCP servers: session ${sessionId} not found`);
920
+ return;
921
+ }
922
+ this.routeBrowserMessage(session, { type: "mcp_set_servers", servers });
923
+ }
924
+
925
+ /** Send an initialize control request with context appended to the system prompt.
926
+ * Must be called before the first user message. Claude-specific: uses ClaudeAdapter
927
+ * to send a raw control_request. If CLI isn't connected yet, the adapter queues it. */
928
+ injectSystemPrompt(sessionId: string, appendSystemPrompt: string): void {
929
+ const session = this.sessions.get(sessionId);
930
+ if (!session) {
931
+ console.error(`[ws-bridge] Cannot inject system prompt: session ${sessionId} not found`);
932
+ return;
933
+ }
934
+ if (session.backendAdapter instanceof ClaudeAdapter) {
935
+ const { randomUUID } = require("node:crypto") as typeof import("node:crypto");
936
+ const ndjson = JSON.stringify({
937
+ type: "control_request",
938
+ request_id: randomUUID(),
939
+ request: { subtype: "initialize", appendSystemPrompt },
940
+ });
941
+ session.backendAdapter.sendRawNDJSON(ndjson);
942
+ }
943
+ }
944
+
945
+ handleBrowserClose(ws: ServerWebSocket<SocketData>) {
946
+ metricsCollector.recordWsConnection("browser", "close");
947
+ const sessionId = (ws.data as BrowserSocketData).sessionId;
948
+ this.recorder?.recordEvent(sessionId, "ws_close", "browser");
949
+ const session = this.sessions.get(sessionId);
950
+ if (!session) return;
951
+
952
+ session.browserSockets.delete(ws);
953
+ log.info("ws-bridge", "Browser disconnected", { sessionId, browsers: session.browserSockets.size });
954
+
955
+ // Start idle kill watchdog when last browser disconnects
956
+ if (session.browserSockets.size === 0 && !this.idleKillTimers.has(sessionId)) {
957
+ this.startIdleKillWatchdog(sessionId);
958
+ }
959
+ }
960
+
961
+ // ── Idle kill watchdog ─────────────────────────────────────────────────
962
+
963
+ private static readonly IDLE_KILL_THRESHOLD_MS = Number(
964
+ (process.env.HEYHANK_IDLE_KILL_MINUTES || process.env.COMPANION_IDLE_KILL_MINUTES)
965
+ ? Number(process.env.HEYHANK_IDLE_KILL_MINUTES || process.env.COMPANION_IDLE_KILL_MINUTES) * 60_000
966
+ : 24 * 60 * 60_000, // 24 hours default
967
+ );
968
+ private static readonly IDLE_CHECK_INTERVAL_MS = 60_000; // check every 60s
969
+
970
+ private startIdleKillWatchdog(sessionId: string) {
971
+ // Reset activity timestamp so we measure from when browsers left, not from
972
+ // last CLI message (which may have been seconds ago during active work)
973
+ const session = this.sessions.get(sessionId);
974
+ if (session) {
975
+ session.lastCliActivityTs = Date.now();
976
+ }
977
+ console.log(`[ws-bridge] Starting idle kill watchdog for ${sessionId} (threshold: ${WsBridge.IDLE_KILL_THRESHOLD_MS / 60_000}min)`);
978
+ const timer = setInterval(() => {
979
+ this.checkIdleKill(sessionId);
980
+ }, WsBridge.IDLE_CHECK_INTERVAL_MS);
981
+ this.idleKillTimers.set(sessionId, timer);
982
+ }
983
+
984
+ private stopIdleKillWatchdog(sessionId: string) {
985
+ const timer = this.idleKillTimers.get(sessionId);
986
+ if (timer) {
987
+ clearInterval(timer);
988
+ this.idleKillTimers.delete(sessionId);
989
+ console.log(`[ws-bridge] Cancelled idle kill watchdog for ${sessionId} (browser reconnected)`);
990
+ }
991
+ }
992
+
993
+ private checkIdleKill(sessionId: string) {
994
+ const session = this.sessions.get(sessionId);
995
+ if (!session) {
996
+ this.stopIdleKillWatchdog(sessionId);
997
+ return;
998
+ }
999
+
1000
+ // Browser reconnected — cancel
1001
+ if (session.browserSockets.size > 0) {
1002
+ this.stopIdleKillWatchdog(sessionId);
1003
+ return;
1004
+ }
1005
+
1006
+ const idleMs = Date.now() - session.lastCliActivityTs;
1007
+ if (idleMs < WsBridge.IDLE_KILL_THRESHOLD_MS) {
1008
+ return; // still active or not idle long enough
1009
+ }
1010
+
1011
+ // Truly idle with no browsers — kill
1012
+ console.log(`[ws-bridge] Idle kill triggered for ${sessionId} (idle ${Math.round(idleMs / 60_000)}min, 0 browsers)`);
1013
+ this.stopIdleKillWatchdog(sessionId);
1014
+ heyHankBus.emit("session:idle-kill", { sessionId });
1015
+ }
1016
+
1017
+ /** Append to messageHistory with cap. Delegates to ws-bridge-persist. */
1018
+ private appendHistory(session: Session, msg: BrowserIncomingMessage) {
1019
+ appendHistoryFn(session, msg);
1020
+ }
1021
+
1022
+ // ── Browser message routing ─────────────────────────────────────────────
1023
+
1024
+ private routeBrowserMessage(
1025
+ session: Session,
1026
+ msg: BrowserOutgoingMessage,
1027
+ ws?: ServerWebSocket<SocketData>,
1028
+ ) {
1029
+ // Bridge-level message types — never forwarded to backend
1030
+ if (msg.type === "session_subscribe") {
1031
+ handleSessionSubscribe(
1032
+ session,
1033
+ ws,
1034
+ msg.last_seq,
1035
+ this.sendToBrowser.bind(this),
1036
+ isHistoryBackedEvent,
1037
+ );
1038
+ return;
1039
+ }
1040
+
1041
+ if (msg.type === "session_ack") {
1042
+ handleSessionAck(session, ws, msg.last_seq, this.persistSession.bind(this));
1043
+ return;
1044
+ }
1045
+
1046
+ // Dedup idempotent messages
1047
+ if (deduplicateBrowserMessage(
1048
+ msg,
1049
+ IDEMPOTENT_BROWSER_MESSAGE_TYPES,
1050
+ session,
1051
+ WsBridge.PROCESSED_CLIENT_MSG_ID_LIMIT,
1052
+ this.persistSession.bind(this),
1053
+ )) {
1054
+ return;
1055
+ }
1056
+
1057
+ // -- set_ai_validation: bridge-level, not forwarded to backend --------
1058
+ if (msg.type === "set_ai_validation") {
1059
+ handleSetAiValidation(session, msg);
1060
+ this.persistSession(session);
1061
+ this.broadcastToBrowsers(session, {
1062
+ type: "session_update",
1063
+ session: {
1064
+ aiValidationEnabled: session.state.aiValidationEnabled,
1065
+ aiValidationAutoApprove: session.state.aiValidationAutoApprove,
1066
+ aiValidationAutoDeny: session.state.aiValidationAutoDeny,
1067
+ },
1068
+ });
1069
+ return;
1070
+ }
1071
+
1072
+ // -- user_message: store in history before delegating to adapter ------
1073
+ if (msg.type === "user_message") {
1074
+ metricsCollector.recordTurnStarted(session.id);
1075
+ const ts = Date.now();
1076
+ const userMessage: BrowserIncomingMessage = {
1077
+ type: "user_message",
1078
+ content: msg.content,
1079
+ timestamp: ts,
1080
+ id: msg.client_msg_id || `user-${ts}-${this.userMsgCounter++}`,
1081
+ };
1082
+ this.appendHistory(session, userMessage);
1083
+ const transitioned = session.stateMachine.transition("streaming", "user_message");
1084
+ if (!transitioned) {
1085
+ // Session not ready yet (e.g. still initializing). Log a warning so
1086
+ // protocol drift is visible, but still forward the message — the
1087
+ // backend adapter has its own internal queue for pre-init messages.
1088
+ log.warn("ws-bridge", "Session not ready for user message, forwarding to adapter queue", {
1089
+ sessionId: session.id,
1090
+ phase: session.stateMachine.phase,
1091
+ });
1092
+ }
1093
+ this.persistSession(session);
1094
+ this.broadcastToBrowsers(session, userMessage);
1095
+ }
1096
+
1097
+ // -- permission_response: populate updatedInput fallback from pending, then remove -------
1098
+ if (msg.type === "permission_response") {
1099
+ metricsCollector.recordPermissionResolved(msg.request_id, msg.behavior as "allow" | "deny", false);
1100
+ const pending = session.pendingPermissions.get(msg.request_id);
1101
+ // When the browser sends allow without updated_input, use the original tool input
1102
+ // as a fallback. This matches the pre-adapter behavior.
1103
+ if (msg.behavior === "allow" && !msg.updated_input && pending?.input) {
1104
+ msg = { ...msg, updated_input: pending.input };
1105
+ }
1106
+ session.pendingPermissions.delete(msg.request_id);
1107
+ session.stateMachine.transition("streaming", "permission_resolved");
1108
+ this.persistSession(session);
1109
+ }
1110
+
1111
+ // Delegate to the backend adapter if connected; otherwise queue for later flush.
1112
+ // For Claude: adapter may exist but WS is disconnected (CLI cycling). Queue at
1113
+ // bridge level so handleCLIOpen flushes via adapter.send() after reconnect.
1114
+ if (session.backendAdapter?.isConnected()) {
1115
+ if (session.pendingMessages.length > 0) {
1116
+ this.flushQueuedBrowserMessages(session, session.backendAdapter, "backend_connected_send");
1117
+ // Preserve FIFO ordering: if flush was interrupted and left pending
1118
+ // messages, queue this incoming message behind them instead of sending
1119
+ // it immediately (which could overtake older queued work).
1120
+ if (session.pendingMessages.length > 0) {
1121
+ this.enqueuePendingMessage(session, JSON.stringify(msg));
1122
+ this.persistSession(session);
1123
+ return;
1124
+ }
1125
+ }
1126
+ const sent = session.backendAdapter.send(msg);
1127
+ // Codex can be "adapter-connected" while its underlying transport is in a
1128
+ // transient disconnected state. If send rejects retryable messages, keep
1129
+ // them queued so they can be flushed after reconnect/relaunch.
1130
+ if (!sent && RETRYABLE_BACKEND_MESSAGE_TYPES.has(msg.type)) {
1131
+ log.warn("ws-bridge", "Backend send failed, re-queuing", {
1132
+ sessionId: session.id,
1133
+ messageType: msg.type,
1134
+ });
1135
+ this.enqueuePendingMessage(session, JSON.stringify(msg));
1136
+ }
1137
+ this.persistSession(session);
1138
+ } else {
1139
+ // Adapter not yet attached or transport disconnected — queue for when it reconnects
1140
+ log.info("ws-bridge", "Backend not connected, queuing message", {
1141
+ sessionId: session.id,
1142
+ messageType: msg.type,
1143
+ });
1144
+ this.enqueuePendingMessage(session, JSON.stringify(msg));
1145
+ this.persistSession(session);
1146
+ }
1147
+ }
1148
+
1149
+ // ── Transport helpers (delegate to ws-bridge-publish) ────────────────────
1150
+
1151
+ /** Push a session name update to all connected browsers for a session. */
1152
+ broadcastNameUpdate(sessionId: string, name: string): void {
1153
+ const session = this.sessions.get(sessionId);
1154
+ if (!session) return;
1155
+ this.broadcastToBrowsers(session, { type: "session_name_update", name });
1156
+ }
1157
+
1158
+ private broadcastToBrowsers(session: Session, msg: BrowserIncomingMessage) {
1159
+ broadcastToBrowsersFn(session, msg, {
1160
+ eventBufferLimit: EVENT_BUFFER_LIMIT,
1161
+ recorder: this.recorder,
1162
+ persistFn: this.persistSession.bind(this),
1163
+ });
1164
+ }
1165
+
1166
+ private sendToBrowser(ws: ServerWebSocket<SocketData>, msg: BrowserIncomingMessage) {
1167
+ sendToBrowserFn(ws, msg);
1168
+ }
1169
+
1170
+ /**
1171
+ * Flush queued browser-originated messages to an attached backend adapter.
1172
+ * Keeps ordering and re-queues retryable messages if dispatch fails.
1173
+ */
1174
+ /** Enqueue a browser→backend message, dropping the oldest if the queue is full. */
1175
+ private enqueuePendingMessage(session: Session, raw: string): void {
1176
+ if (session.pendingMessages.length >= WsBridge.PENDING_MESSAGES_LIMIT) {
1177
+ const dropped = session.pendingMessages.shift();
1178
+ log.warn("ws-bridge", "Pending message queue full, dropping oldest message", {
1179
+ sessionId: session.id,
1180
+ queueSize: session.pendingMessages.length,
1181
+ droppedPreview: dropped?.substring(0, 80),
1182
+ });
1183
+ this.broadcastToBrowsers(session, {
1184
+ type: "error",
1185
+ message: "Message queue full: the oldest queued message was discarded.",
1186
+ });
1187
+ }
1188
+ session.pendingMessages.push(raw);
1189
+ }
1190
+
1191
+ private flushQueuedBrowserMessages(session: Session, adapter: IBackendAdapter, reason: string): void {
1192
+ if (session.pendingMessages.length === 0) return;
1193
+
1194
+ log.info("ws-bridge", "Flushing queued messages", {
1195
+ sessionId: session.id,
1196
+ backendType: session.backendType,
1197
+ reason,
1198
+ count: session.pendingMessages.length,
1199
+ });
1200
+
1201
+ const queued = session.pendingMessages.splice(0);
1202
+ for (let i = 0; i < queued.length; i++) {
1203
+ const raw = queued[i];
1204
+ let queuedMsg: BrowserOutgoingMessage;
1205
+ try {
1206
+ queuedMsg = JSON.parse(raw) as BrowserOutgoingMessage;
1207
+ } catch {
1208
+ log.warn("ws-bridge", "Failed to parse queued message during flush", {
1209
+ sessionId: session.id,
1210
+ backendType: session.backendType,
1211
+ rawPreview: raw.substring(0, 100),
1212
+ });
1213
+ continue;
1214
+ }
1215
+
1216
+ const sent = adapter.send(queuedMsg);
1217
+ if (!sent && RETRYABLE_BACKEND_MESSAGE_TYPES.has(queuedMsg.type)) {
1218
+ const remaining = queued.slice(i);
1219
+ session.pendingMessages = remaining.concat(session.pendingMessages);
1220
+ log.warn("ws-bridge", "Queued message flush interrupted, re-queued remaining messages", {
1221
+ sessionId: session.id,
1222
+ backendType: session.backendType,
1223
+ reason,
1224
+ failedMessageType: queuedMsg.type,
1225
+ remaining: remaining.length,
1226
+ });
1227
+ break;
1228
+ }
1229
+
1230
+ if (!sent) {
1231
+ log.warn("ws-bridge", "Dropping non-retryable queued message after flush failure", {
1232
+ sessionId: session.id,
1233
+ backendType: session.backendType,
1234
+ reason,
1235
+ failedMessageType: queuedMsg.type,
1236
+ });
1237
+ }
1238
+ }
1239
+ }
1240
+ }