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,889 @@
1
+ /**
2
+ * Claude Code Backend Adapter
3
+ *
4
+ * Translates between the Claude Code NDJSON WebSocket protocol and
5
+ * HeyHank's BrowserIncomingMessage/BrowserOutgoingMessage types.
6
+ *
7
+ * This allows the bridge (and by extension the browser) to be completely
8
+ * unaware of which backend is running -- it sees the same message types
9
+ * regardless of whether Claude Code or Codex is the backend.
10
+ */
11
+
12
+ import { randomUUID } from "node:crypto";
13
+ import type { ServerWebSocket } from "bun";
14
+ import type { IBackendAdapter } from "./backend-adapter.js";
15
+ import type {
16
+ BrowserIncomingMessage,
17
+ BrowserOutgoingMessage,
18
+ CLIMessage,
19
+ CLISystemMessage,
20
+ CLISystemInitMessage,
21
+ CLIAssistantMessage,
22
+ CLIResultMessage,
23
+ CLIStreamEventMessage,
24
+ CLIToolProgressMessage,
25
+ CLIToolUseSummaryMessage,
26
+ CLIControlRequestMessage,
27
+ CLIControlResponseMessage,
28
+ CLIAuthStatusMessage,
29
+ CLIControlCancelRequestMessage,
30
+ CLIStreamlinedTextMessage,
31
+ CLIStreamlinedToolUseSummaryMessage,
32
+ CLIPromptSuggestionMessage,
33
+ CLICompactBoundaryMessage,
34
+ CLITaskNotificationMessage,
35
+ CLIFilesPersistedMessage,
36
+ CLIHookStartedMessage,
37
+ CLIHookProgressMessage,
38
+ CLIHookResponseMessage,
39
+ PermissionRequest,
40
+ McpServerDetail,
41
+ SessionState,
42
+ } from "./session-types.js";
43
+ import type { SocketData } from "./ws-bridge-types.js";
44
+ import type { PendingControlRequest } from "./ws-bridge-types.js";
45
+ import type { RecorderManager } from "./recorder.js";
46
+ import { parseNDJSON, isDuplicateCLIMessage } from "./ws-bridge-cli-ingest.js";
47
+ import type { CLIDedupState } from "./ws-bridge-cli-ingest.js";
48
+ import { reportProtocolDrift } from "./protocol-monitor.js";
49
+
50
+ // --- Constants ----------------------------------------------------------------
51
+
52
+ /** Number of recent CLI message hashes to track for deduplication on WS reconnect. */
53
+ const CLI_DEDUP_WINDOW = 2000;
54
+
55
+ // --- Claude Code Adapter ------------------------------------------------------
56
+
57
+ export class ClaudeAdapter implements IBackendAdapter {
58
+ private sessionId: string;
59
+
60
+ // WebSocket to the Claude Code CLI process
61
+ private cliSocket: ServerWebSocket<SocketData> | null = null;
62
+
63
+ // Callbacks registered by the bridge via on*() methods
64
+ private browserMessageCb: ((msg: BrowserIncomingMessage) => void) | null = null;
65
+ private sessionMetaCb: ((meta: { cliSessionId?: string; model?: string; cwd?: string }) => void) | null = null;
66
+ private disconnectCb: (() => void) | null = null;
67
+
68
+ // Pending NDJSON messages queued before CLI WebSocket connects
69
+ private pendingMessages: string[] = [];
70
+
71
+ // Async control request/response pairs (e.g. MCP status queries)
72
+ private pendingControlRequests = new Map<string, PendingControlRequest>();
73
+
74
+ // CLI message deduplication state (rolling hash window)
75
+ private dedupState: CLIDedupState = {
76
+ recentCLIMessageHashes: [],
77
+ recentCLIMessageHashSet: new Set(),
78
+ };
79
+
80
+ // Optional recorder for raw protocol messages
81
+ private recorder: RecorderManager | null;
82
+
83
+ // Callback to update session.lastCliActivityTs from the bridge
84
+ private onActivityUpdate: (() => void) | null;
85
+
86
+ private protocolDriftSeen = new Set<string>();
87
+ private parseErrorSeen = new Set<string>();
88
+
89
+ constructor(
90
+ sessionId: string,
91
+ opts?: {
92
+ recorder?: RecorderManager | null;
93
+ onActivityUpdate?: () => void;
94
+ },
95
+ ) {
96
+ this.sessionId = sessionId;
97
+ this.recorder = opts?.recorder ?? null;
98
+ this.onActivityUpdate = opts?.onActivityUpdate ?? null;
99
+ }
100
+
101
+ // -- WebSocket lifecycle ----------------------------------------------------
102
+
103
+ /**
104
+ * Called when the CLI WebSocket connects. Stores the socket reference and
105
+ * flushes any NDJSON messages that were queued before the connection.
106
+ */
107
+ attachWebSocket(ws: ServerWebSocket<SocketData>): void {
108
+ this.cliSocket = ws;
109
+
110
+ // Flush pending messages
111
+ if (this.pendingMessages.length > 0) {
112
+ console.log(
113
+ `[claude-adapter] Flushing ${this.pendingMessages.length} queued message(s) for session ${this.sessionId}`,
114
+ );
115
+ const queued = this.pendingMessages.splice(0);
116
+ for (const ndjson of queued) {
117
+ this.sendRaw(ndjson);
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Called when the CLI WebSocket closes. Guards against stale socket references
124
+ * (a new WS may have opened before the old one closed).
125
+ */
126
+ detachWebSocket(ws: ServerWebSocket<SocketData>): void {
127
+ // Only detach if this is the current socket -- ignore stale close events
128
+ if (this.cliSocket !== ws) return;
129
+ this.cliSocket = null;
130
+ this.disconnectCb?.();
131
+ }
132
+
133
+ // -- IBackendAdapter: Event registration ------------------------------------
134
+
135
+ onBrowserMessage(cb: (msg: BrowserIncomingMessage) => void): void {
136
+ this.browserMessageCb = cb;
137
+ }
138
+
139
+ onSessionMeta(cb: (meta: { cliSessionId?: string; model?: string; cwd?: string }) => void): void {
140
+ this.sessionMetaCb = cb;
141
+ }
142
+
143
+ onDisconnect(cb: () => void): void {
144
+ this.disconnectCb = cb;
145
+ }
146
+
147
+ // -- IBackendAdapter: Transport state ---------------------------------------
148
+
149
+ isConnected(): boolean {
150
+ return this.cliSocket !== null;
151
+ }
152
+
153
+ async disconnect(): Promise<void> {
154
+ // Clear pending control requests to prevent memory leaks from
155
+ // unresolved promises (CLI won't respond after disconnect)
156
+ this.pendingControlRequests.clear();
157
+ if (this.cliSocket) {
158
+ try {
159
+ this.cliSocket.close();
160
+ } catch {
161
+ // Socket may already be closed
162
+ }
163
+ this.cliSocket = null;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Handle transport-level close (used when WS proxy drops).
169
+ * Clears the socket reference without triggering the disconnect callback,
170
+ * allowing the CLI to reconnect.
171
+ */
172
+ handleTransportClose(): void {
173
+ this.cliSocket = null;
174
+ }
175
+
176
+ // -- IBackendAdapter: Raw message ingestion from CLI ------------------------
177
+
178
+ /**
179
+ * Called when raw NDJSON data arrives from the CLI WebSocket.
180
+ * Parses lines, deduplicates, and routes each message.
181
+ */
182
+ handleRawMessage(data: string): void {
183
+ // Record raw incoming CLI message before any parsing
184
+ this.recorder?.record(
185
+ this.sessionId, "in", data, "cli", "claude", "",
186
+ );
187
+
188
+ const lines = parseNDJSON(data);
189
+ for (const line of lines) {
190
+ let msg: CLIMessage;
191
+ try {
192
+ msg = JSON.parse(line);
193
+ } catch {
194
+ reportProtocolDrift(
195
+ this.parseErrorSeen,
196
+ {
197
+ backend: "claude",
198
+ sessionId: this.sessionId,
199
+ direction: "incoming",
200
+ messageKind: "parse_error",
201
+ messageName: "ndjson",
202
+ rawPreview: line,
203
+ },
204
+ (message) => this.browserMessageCb?.({ type: "error", message }),
205
+ );
206
+ continue;
207
+ }
208
+
209
+ if (isDuplicateCLIMessage(msg, line, this.dedupState, CLI_DEDUP_WINDOW)) {
210
+ continue;
211
+ }
212
+
213
+ this.routeCLIMessage(msg);
214
+ }
215
+ }
216
+
217
+ // -- IBackendAdapter: send() -- browser -> CLI translation ------------------
218
+
219
+ send(msg: BrowserOutgoingMessage): boolean {
220
+ switch (msg.type) {
221
+ case "user_message":
222
+ return this.handleOutgoingUserMessage(msg);
223
+
224
+ case "permission_response":
225
+ return this.handleOutgoingPermissionResponse(msg);
226
+
227
+ case "interrupt":
228
+ return this.handleOutgoingInterrupt();
229
+
230
+ case "set_model":
231
+ return this.handleOutgoingSetModel(msg.model);
232
+
233
+ case "set_permission_mode":
234
+ return this.handleOutgoingSetPermissionMode(msg.mode);
235
+
236
+ case "set_ai_validation":
237
+ // AI validation state is managed at the bridge/session level, not
238
+ // forwarded to the CLI. Return true to indicate acceptance.
239
+ return true;
240
+
241
+ case "mcp_get_status":
242
+ return this.handleOutgoingMcpGetStatus();
243
+
244
+ case "mcp_toggle":
245
+ return this.handleOutgoingMcpToggle(msg.serverName, msg.enabled);
246
+
247
+ case "mcp_reconnect":
248
+ return this.handleOutgoingMcpReconnect(msg.serverName);
249
+
250
+ case "mcp_set_servers":
251
+ return this.handleOutgoingMcpSetServers(msg.servers);
252
+
253
+ case "end_session":
254
+ return this.handleOutgoingEndSession((msg as { reason?: string }).reason);
255
+
256
+ case "stop_task":
257
+ return this.handleOutgoingStopTask((msg as { task_id: string }).task_id);
258
+
259
+ case "update_environment_variables":
260
+ return this.handleOutgoingUpdateEnvVars((msg as { variables: Record<string, string> }).variables);
261
+
262
+ case "session_subscribe":
263
+ case "session_ack":
264
+ // These are handled at the bridge level -- never forwarded to the backend.
265
+ return false;
266
+
267
+ default:
268
+ return false;
269
+ }
270
+ }
271
+
272
+ // -- Outgoing message handlers (browser -> NDJSON) --------------------------
273
+
274
+ private handleOutgoingUserMessage(
275
+ msg: { type: "user_message"; content: string; session_id?: string; images?: { media_type: string; data: string }[] },
276
+ ): boolean {
277
+ // Build content: if images are present, use content block array; otherwise plain string
278
+ let content: string | unknown[];
279
+ if (msg.images?.length) {
280
+ const blocks: unknown[] = [];
281
+ for (const img of msg.images) {
282
+ blocks.push({
283
+ type: "image",
284
+ source: { type: "base64", media_type: img.media_type, data: img.data },
285
+ });
286
+ }
287
+ blocks.push({ type: "text", text: msg.content });
288
+ content = blocks;
289
+ } else {
290
+ content = msg.content;
291
+ }
292
+
293
+ const ndjson = JSON.stringify({
294
+ type: "user",
295
+ message: { role: "user", content },
296
+ parent_tool_use_id: null,
297
+ session_id: msg.session_id || "",
298
+ });
299
+ this.sendToBackend(ndjson);
300
+ return true;
301
+ }
302
+
303
+ private handleOutgoingPermissionResponse(
304
+ msg: {
305
+ type: "permission_response";
306
+ request_id: string;
307
+ behavior: "allow" | "deny";
308
+ updated_input?: Record<string, unknown>;
309
+ updated_permissions?: unknown[];
310
+ message?: string;
311
+ },
312
+ ): boolean {
313
+ if (msg.behavior === "allow") {
314
+ const response: Record<string, unknown> = {
315
+ behavior: "allow",
316
+ updatedInput: msg.updated_input ?? {},
317
+ };
318
+ if (msg.updated_permissions?.length) {
319
+ response.updatedPermissions = msg.updated_permissions;
320
+ }
321
+ const ndjson = JSON.stringify({
322
+ type: "control_response",
323
+ response: {
324
+ subtype: "success",
325
+ request_id: msg.request_id,
326
+ response,
327
+ },
328
+ });
329
+ this.sendToBackend(ndjson);
330
+ } else {
331
+ const ndjson = JSON.stringify({
332
+ type: "control_response",
333
+ response: {
334
+ subtype: "success",
335
+ request_id: msg.request_id,
336
+ response: {
337
+ behavior: "deny",
338
+ message: msg.message || "Denied by user",
339
+ },
340
+ },
341
+ });
342
+ this.sendToBackend(ndjson);
343
+ }
344
+ return true;
345
+ }
346
+
347
+ private handleOutgoingInterrupt(): boolean {
348
+ const ndjson = JSON.stringify({
349
+ type: "control_request",
350
+ request_id: randomUUID(),
351
+ request: { subtype: "interrupt" },
352
+ });
353
+ this.sendToBackend(ndjson);
354
+ return true;
355
+ }
356
+
357
+ private handleOutgoingSetModel(model: string): boolean {
358
+ const ndjson = JSON.stringify({
359
+ type: "control_request",
360
+ request_id: randomUUID(),
361
+ request: { subtype: "set_model", model },
362
+ });
363
+ this.sendToBackend(ndjson);
364
+ return true;
365
+ }
366
+
367
+ private handleOutgoingSetPermissionMode(mode: string): boolean {
368
+ const ndjson = JSON.stringify({
369
+ type: "control_request",
370
+ request_id: randomUUID(),
371
+ request: { subtype: "set_permission_mode", mode },
372
+ });
373
+ this.sendToBackend(ndjson);
374
+ return true;
375
+ }
376
+
377
+ private handleOutgoingMcpGetStatus(): boolean {
378
+ this.sendControlRequest(
379
+ { subtype: "mcp_status" },
380
+ {
381
+ subtype: "mcp_status",
382
+ resolve: (response) => {
383
+ const servers = (response as { mcpServers?: McpServerDetail[] }).mcpServers ?? [];
384
+ this.browserMessageCb?.({ type: "mcp_status", servers });
385
+ },
386
+ },
387
+ );
388
+ return true;
389
+ }
390
+
391
+ private handleOutgoingMcpToggle(serverName: string, enabled: boolean): boolean {
392
+ this.sendControlRequest({ subtype: "mcp_toggle", serverName, enabled });
393
+ // Refresh MCP status after a delay to pick up the change
394
+ setTimeout(() => this.handleOutgoingMcpGetStatus(), 500);
395
+ return true;
396
+ }
397
+
398
+ private handleOutgoingMcpReconnect(serverName: string): boolean {
399
+ this.sendControlRequest({ subtype: "mcp_reconnect", serverName });
400
+ // Refresh MCP status after a delay to pick up the reconnection
401
+ setTimeout(() => this.handleOutgoingMcpGetStatus(), 1000);
402
+ return true;
403
+ }
404
+
405
+ private handleOutgoingMcpSetServers(servers: Record<string, unknown>): boolean {
406
+ this.sendControlRequest({ subtype: "mcp_set_servers", servers });
407
+ // Refresh MCP status after a delay to pick up the new server config
408
+ setTimeout(() => this.handleOutgoingMcpGetStatus(), 2000);
409
+ return true;
410
+ }
411
+
412
+ private handleOutgoingEndSession(reason?: string): boolean {
413
+ this.sendControlRequest({ subtype: "end_session", ...(reason ? { reason } : {}) });
414
+ return true;
415
+ }
416
+
417
+ private handleOutgoingStopTask(taskId: string): boolean {
418
+ this.sendControlRequest({ subtype: "stop_task", task_id: taskId });
419
+ return true;
420
+ }
421
+
422
+ private handleOutgoingUpdateEnvVars(variables: Record<string, string>): boolean {
423
+ const ndjson = JSON.stringify({
424
+ type: "update_environment_variables",
425
+ variables,
426
+ });
427
+ this.sendToBackend(ndjson);
428
+ return true;
429
+ }
430
+
431
+ // -- CLI message routing (NDJSON -> BrowserIncomingMessage) -----------------
432
+
433
+ private routeCLIMessage(msg: CLIMessage): void {
434
+ // Track activity for idle detection (skip keepalives -- they don't indicate real work)
435
+ if (msg.type !== "keep_alive") {
436
+ this.onActivityUpdate?.();
437
+ }
438
+
439
+ switch (msg.type) {
440
+ case "system":
441
+ this.handleSystemMessage(msg);
442
+ break;
443
+
444
+ case "assistant":
445
+ this.handleAssistantMessage(msg);
446
+ break;
447
+
448
+ case "result":
449
+ this.handleResultMessage(msg);
450
+ break;
451
+
452
+ case "stream_event":
453
+ this.handleStreamEvent(msg);
454
+ break;
455
+
456
+ case "control_request":
457
+ this.handleControlRequest(msg);
458
+ break;
459
+
460
+ case "control_response":
461
+ this.handleControlResponse(msg);
462
+ break;
463
+
464
+ case "tool_progress":
465
+ this.handleToolProgress(msg);
466
+ break;
467
+
468
+ case "tool_use_summary":
469
+ this.handleToolUseSummary(msg);
470
+ break;
471
+
472
+ case "auth_status":
473
+ this.handleAuthStatus(msg);
474
+ break;
475
+
476
+ case "keep_alive":
477
+ // Silently consume keepalives
478
+ break;
479
+
480
+ case "user":
481
+ // CLI echoes back user messages (including tool_result blocks from
482
+ // subagents). These are purely informational — the bridge already
483
+ // persists user messages from the browser side. Silently drop them
484
+ // to avoid rendering raw tool_result JSON in the chat UI.
485
+ break;
486
+
487
+ case "rate_limit_event":
488
+ // Rate-limit status from Claude API (allowed/throttled). Silently
489
+ // consumed — no user-facing action needed.
490
+ break;
491
+
492
+ case "control_cancel_request":
493
+ this.handleControlCancelRequest(msg as CLIControlCancelRequestMessage);
494
+ break;
495
+
496
+ case "streamlined_text":
497
+ this.handleStreamlinedText(msg as CLIStreamlinedTextMessage);
498
+ break;
499
+
500
+ case "streamlined_tool_use_summary":
501
+ this.handleStreamlinedToolUseSummary(msg as CLIStreamlinedToolUseSummaryMessage);
502
+ break;
503
+
504
+ case "prompt_suggestion":
505
+ this.handlePromptSuggestion(msg as CLIPromptSuggestionMessage);
506
+ break;
507
+
508
+ default:
509
+ reportProtocolDrift(
510
+ this.protocolDriftSeen,
511
+ {
512
+ backend: "claude",
513
+ sessionId: this.sessionId,
514
+ direction: "incoming",
515
+ messageKind: "message",
516
+ messageName: (msg as { type?: string }).type || "unknown",
517
+ rawPreview: JSON.stringify(msg),
518
+ },
519
+ (message) => this.browserMessageCb?.({ type: "error", message }),
520
+ );
521
+ break;
522
+ }
523
+ }
524
+
525
+ // -- System message handling ------------------------------------------------
526
+
527
+ private handleSystemMessage(msg: CLISystemMessage): void {
528
+ if (msg.subtype === "init") {
529
+ this.handleSystemInit(msg as CLISystemInitMessage);
530
+ return;
531
+ }
532
+
533
+ if (msg.subtype === "status") {
534
+ const statusMsg = msg as { subtype: "status"; status: "compacting" | null; permissionMode?: string; uuid: string; session_id: string };
535
+ // Include permissionMode in the emitted message so the bridge can update session state
536
+ const statusChange: Record<string, unknown> = {
537
+ type: "status_change",
538
+ status: statusMsg.status ?? null,
539
+ };
540
+ if (statusMsg.permissionMode) {
541
+ statusChange.permissionMode = statusMsg.permissionMode;
542
+ }
543
+ this.browserMessageCb?.(statusChange as BrowserIncomingMessage);
544
+ return;
545
+ }
546
+
547
+ if (msg.subtype === "compact_boundary") {
548
+ const m = msg as CLICompactBoundaryMessage;
549
+ this.emitSystemEvent({
550
+ subtype: "compact_boundary",
551
+ compact_metadata: m.compact_metadata,
552
+ uuid: m.uuid,
553
+ session_id: m.session_id,
554
+ });
555
+ return;
556
+ }
557
+
558
+ if (msg.subtype === "task_notification") {
559
+ const m = msg as CLITaskNotificationMessage;
560
+ this.emitSystemEvent({
561
+ subtype: "task_notification",
562
+ task_id: m.task_id,
563
+ status: m.status,
564
+ output_file: m.output_file,
565
+ summary: m.summary,
566
+ uuid: m.uuid,
567
+ session_id: m.session_id,
568
+ });
569
+ return;
570
+ }
571
+
572
+ if (msg.subtype === "files_persisted") {
573
+ const m = msg as CLIFilesPersistedMessage;
574
+ this.emitSystemEvent({
575
+ subtype: "files_persisted",
576
+ files: m.files,
577
+ failed: m.failed,
578
+ processed_at: m.processed_at,
579
+ uuid: m.uuid,
580
+ session_id: m.session_id,
581
+ });
582
+ return;
583
+ }
584
+
585
+ if (msg.subtype === "hook_started") {
586
+ const m = msg as CLIHookStartedMessage;
587
+ this.emitSystemEvent({
588
+ subtype: "hook_started",
589
+ hook_id: m.hook_id,
590
+ hook_name: m.hook_name,
591
+ hook_event: m.hook_event,
592
+ uuid: m.uuid,
593
+ session_id: m.session_id,
594
+ });
595
+ return;
596
+ }
597
+
598
+ if (msg.subtype === "hook_progress") {
599
+ const m = msg as CLIHookProgressMessage;
600
+ // hook_progress is transient -- emitted but not persisted in message history.
601
+ // The bridge handler decides on persistence based on message type.
602
+ this.emitSystemEvent({
603
+ subtype: "hook_progress",
604
+ hook_id: m.hook_id,
605
+ hook_name: m.hook_name,
606
+ hook_event: m.hook_event,
607
+ stdout: m.stdout,
608
+ stderr: m.stderr,
609
+ output: m.output,
610
+ uuid: m.uuid,
611
+ session_id: m.session_id,
612
+ });
613
+ return;
614
+ }
615
+
616
+ if (msg.subtype === "hook_response") {
617
+ const m = msg as CLIHookResponseMessage;
618
+ this.emitSystemEvent({
619
+ subtype: "hook_response",
620
+ hook_id: m.hook_id,
621
+ hook_name: m.hook_name,
622
+ hook_event: m.hook_event,
623
+ output: m.output,
624
+ stdout: m.stdout,
625
+ stderr: m.stderr,
626
+ exit_code: m.exit_code,
627
+ outcome: m.outcome,
628
+ uuid: m.uuid,
629
+ session_id: m.session_id,
630
+ });
631
+ return;
632
+ }
633
+
634
+ // Unknown system subtypes are intentionally ignored until we map them.
635
+ }
636
+
637
+ private handleSystemInit(msg: CLISystemInitMessage): void {
638
+ // Emit session metadata so the bridge can update session state
639
+ this.sessionMetaCb?.({
640
+ cliSessionId: msg.session_id,
641
+ model: msg.model,
642
+ cwd: msg.cwd,
643
+ });
644
+
645
+ // Emit session_init to browsers with CLI-provided fields only.
646
+ // The bridge's attachBackendAdapter handler will merge these into the
647
+ // canonical session state (which owns git info, cost, etc.) and broadcast.
648
+ this.browserMessageCb?.({
649
+ type: "session_init",
650
+ session: {
651
+ session_id: msg.session_id,
652
+ model: msg.model,
653
+ cwd: msg.cwd,
654
+ tools: msg.tools,
655
+ permissionMode: msg.permissionMode,
656
+ claude_code_version: msg.claude_code_version,
657
+ mcp_servers: msg.mcp_servers,
658
+ agents: msg.agents ?? [],
659
+ slash_commands: msg.slash_commands ?? [],
660
+ skills: msg.skills ?? [],
661
+ } as SessionState,
662
+ });
663
+
664
+ // Flush any NDJSON messages queued before the CLI was initialized
665
+ // (e.g. user sent a message while the CLI was still starting up).
666
+ if (this.pendingMessages.length > 0) {
667
+ console.log(
668
+ `[claude-adapter] Flushing ${this.pendingMessages.length} queued message(s) after init for session ${this.sessionId}`,
669
+ );
670
+ const queued = this.pendingMessages.splice(0);
671
+ for (const ndjson of queued) {
672
+ this.sendRaw(ndjson);
673
+ }
674
+ }
675
+ }
676
+
677
+ // -- Assistant, result, stream ----------------------------------------------
678
+
679
+ private handleAssistantMessage(msg: CLIAssistantMessage): void {
680
+ this.browserMessageCb?.({
681
+ type: "assistant",
682
+ message: msg.message,
683
+ parent_tool_use_id: msg.parent_tool_use_id,
684
+ timestamp: Date.now(),
685
+ });
686
+ }
687
+
688
+ private handleResultMessage(msg: CLIResultMessage): void {
689
+ this.browserMessageCb?.({
690
+ type: "result",
691
+ data: msg,
692
+ });
693
+ }
694
+
695
+ private handleStreamEvent(msg: CLIStreamEventMessage): void {
696
+ this.browserMessageCb?.({
697
+ type: "stream_event",
698
+ event: msg.event,
699
+ parent_tool_use_id: msg.parent_tool_use_id,
700
+ });
701
+ }
702
+
703
+ // -- Control request (permission) -------------------------------------------
704
+
705
+ private handleControlRequest(msg: CLIControlRequestMessage): void {
706
+ if (msg.request.subtype === "can_use_tool") {
707
+ const perm: PermissionRequest = {
708
+ request_id: msg.request_id,
709
+ tool_name: msg.request.tool_name,
710
+ input: msg.request.input,
711
+ permission_suggestions: msg.request.permission_suggestions,
712
+ description: msg.request.description,
713
+ tool_use_id: msg.request.tool_use_id,
714
+ agent_id: msg.request.agent_id,
715
+ title: msg.request.title,
716
+ display_name: msg.request.display_name,
717
+ blocked_path: msg.request.blocked_path,
718
+ decision_reason: msg.request.decision_reason,
719
+ timestamp: Date.now(),
720
+ };
721
+
722
+ this.browserMessageCb?.({
723
+ type: "permission_request",
724
+ request: perm,
725
+ });
726
+ }
727
+ }
728
+
729
+ // -- Control cancel request ------------------------------------------------
730
+
731
+ private handleControlCancelRequest(msg: CLIControlCancelRequestMessage): void {
732
+ // Clean up any pending async control request in the adapter
733
+ this.pendingControlRequests.delete(msg.request_id);
734
+ // Emit permission_cancelled so the bridge removes from pendingPermissions
735
+ this.browserMessageCb?.({
736
+ type: "permission_cancelled",
737
+ request_id: msg.request_id,
738
+ });
739
+ }
740
+
741
+ // -- Streamlined messages (simplified output mode) -------------------------
742
+
743
+ private handleStreamlinedText(msg: CLIStreamlinedTextMessage): void {
744
+ this.browserMessageCb?.({
745
+ type: "streamlined_text",
746
+ text: msg.text,
747
+ } as BrowserIncomingMessage);
748
+ }
749
+
750
+ private handleStreamlinedToolUseSummary(msg: CLIStreamlinedToolUseSummaryMessage): void {
751
+ this.browserMessageCb?.({
752
+ type: "streamlined_tool_use_summary",
753
+ tool_summary: msg.tool_summary,
754
+ } as BrowserIncomingMessage);
755
+ }
756
+
757
+ // -- Prompt suggestions ----------------------------------------------------
758
+
759
+ private handlePromptSuggestion(msg: CLIPromptSuggestionMessage): void {
760
+ this.browserMessageCb?.({
761
+ type: "prompt_suggestion",
762
+ suggestions: msg.suggestions,
763
+ } as BrowserIncomingMessage);
764
+ }
765
+
766
+ // -- Control response (for pending control requests like MCP status) --------
767
+
768
+ private handleControlResponse(msg: CLIControlResponseMessage): void {
769
+ const reqId = msg.response.request_id;
770
+ const pending = this.pendingControlRequests.get(reqId);
771
+ if (!pending) return;
772
+ this.pendingControlRequests.delete(reqId);
773
+ if (msg.response.subtype === "error") {
774
+ console.warn(
775
+ `[claude-adapter] Control request ${pending.subtype} failed: ${msg.response.error}`,
776
+ );
777
+ return;
778
+ }
779
+ pending.resolve(msg.response.response ?? {});
780
+ }
781
+
782
+ // -- Tool progress & summary ------------------------------------------------
783
+
784
+ private handleToolProgress(msg: CLIToolProgressMessage): void {
785
+ this.browserMessageCb?.({
786
+ type: "tool_progress",
787
+ tool_use_id: msg.tool_use_id,
788
+ tool_name: msg.tool_name,
789
+ elapsed_time_seconds: msg.elapsed_time_seconds,
790
+ });
791
+ }
792
+
793
+ private handleToolUseSummary(msg: CLIToolUseSummaryMessage): void {
794
+ this.browserMessageCb?.({
795
+ type: "tool_use_summary",
796
+ summary: msg.summary,
797
+ tool_use_ids: msg.preceding_tool_use_ids,
798
+ });
799
+ }
800
+
801
+ // -- Auth status ------------------------------------------------------------
802
+
803
+ private handleAuthStatus(msg: CLIAuthStatusMessage): void {
804
+ this.browserMessageCb?.({
805
+ type: "auth_status",
806
+ isAuthenticating: msg.isAuthenticating,
807
+ output: msg.output,
808
+ error: msg.error,
809
+ });
810
+ }
811
+
812
+ // -- Helpers ----------------------------------------------------------------
813
+
814
+ /**
815
+ * Emit a system_event BrowserIncomingMessage to browsers.
816
+ */
817
+ private emitSystemEvent(
818
+ event: Extract<BrowserIncomingMessage, { type: "system_event" }>["event"],
819
+ ): void {
820
+ this.browserMessageCb?.({
821
+ type: "system_event",
822
+ event,
823
+ timestamp: Date.now(),
824
+ });
825
+ }
826
+
827
+ /**
828
+ * Send a control_request to the CLI and optionally track the pending response.
829
+ */
830
+ private sendControlRequest(
831
+ request: Record<string, unknown>,
832
+ onResponse?: { subtype: string; resolve: (response: unknown) => void },
833
+ ): void {
834
+ const requestId = randomUUID();
835
+ if (onResponse) {
836
+ this.pendingControlRequests.set(requestId, onResponse);
837
+ }
838
+ const ndjson = JSON.stringify({
839
+ type: "control_request",
840
+ request_id: requestId,
841
+ request,
842
+ });
843
+ this.sendToBackend(ndjson);
844
+ }
845
+
846
+ /**
847
+ * Send a raw NDJSON string to the CLI, bypassing the BrowserOutgoingMessage
848
+ * translation layer. Used for Claude-specific control requests (e.g. initialize)
849
+ * that don't map to a BrowserOutgoingMessage type.
850
+ */
851
+ sendRawNDJSON(ndjson: string): void {
852
+ this.sendToBackend(ndjson);
853
+ }
854
+
855
+ /**
856
+ * Send an NDJSON string to the CLI. If the CLI socket is not yet connected,
857
+ * queues the message for later delivery (flushed in attachWebSocket).
858
+ */
859
+ private sendToBackend(ndjson: string): void {
860
+ if (!this.cliSocket) {
861
+ console.log(
862
+ `[claude-adapter] CLI not yet connected for session ${this.sessionId}, queuing message`,
863
+ );
864
+ this.pendingMessages.push(ndjson);
865
+ return;
866
+ }
867
+ this.sendRaw(ndjson);
868
+ }
869
+
870
+ /**
871
+ * Low-level send: writes NDJSON to the CLI socket with newline delimiter.
872
+ * Records the outgoing message. Assumes cliSocket is non-null.
873
+ */
874
+ private sendRaw(ndjson: string): void {
875
+ // Record raw outgoing CLI message
876
+ this.recorder?.record(
877
+ this.sessionId, "out", ndjson, "cli", "claude", "",
878
+ );
879
+ try {
880
+ // NDJSON requires a newline delimiter
881
+ this.cliSocket!.send(ndjson + "\n");
882
+ } catch (err) {
883
+ console.error(
884
+ `[claude-adapter] Failed to send to CLI for session ${this.sessionId}:`,
885
+ err,
886
+ );
887
+ }
888
+ }
889
+ }