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