the-campfire 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 (180) hide show
  1. package/bin/cli.ts +182 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
  4. package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
  5. package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
  6. package/dist/assets/arc-D9SEA8dX.js +1 -0
  7. package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
  8. package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
  9. package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
  10. package/dist/assets/channel-CxhcHn7S.js +1 -0
  11. package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
  12. package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
  13. package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
  14. package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
  15. package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
  16. package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
  17. package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
  18. package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
  19. package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
  20. package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
  21. package/dist/assets/clone-C6kt1rE7.js +1 -0
  22. package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
  23. package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  24. package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
  25. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  26. package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
  27. package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
  28. package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
  29. package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
  30. package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
  31. package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
  32. package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
  33. package/dist/assets/graph-DKRbrxcs.js +1 -0
  34. package/dist/assets/index-Byjg9zgI.css +32 -0
  35. package/dist/assets/index-Cwj3m0hT.js +284 -0
  36. package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
  37. package/dist/assets/init-Gi6I4Gst.js +1 -0
  38. package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
  39. package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
  40. package/dist/assets/katex-BlHpptmG.js +261 -0
  41. package/dist/assets/layout-DnxSwLDJ.js +1 -0
  42. package/dist/assets/linear-CWbbIT8l.js +1 -0
  43. package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
  44. package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
  45. package/dist/assets/ordinal-BENe2yWM.js +1 -0
  46. package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
  47. package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
  48. package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
  49. package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
  50. package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
  51. package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
  52. package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
  53. package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
  54. package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
  55. package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
  56. package/dist/favicon.svg +3 -0
  57. package/dist/fonts/Geist-Variable.woff2 +0 -0
  58. package/dist/fonts/GeistMono-Variable.woff2 +0 -0
  59. package/dist/icon-192.png +0 -0
  60. package/dist/icon-512.png +0 -0
  61. package/dist/index.html +33 -0
  62. package/dist/logo-codex.svg +14 -0
  63. package/dist/logo.svg +3 -0
  64. package/dist/manifest.json +26 -0
  65. package/dist/og-image.png +0 -0
  66. package/dist/sw.js +130 -0
  67. package/package.json +92 -0
  68. package/server/ADAPTERS.md +121 -0
  69. package/server/adapter-registry-types.ts +28 -0
  70. package/server/adapter-registry.ts +293 -0
  71. package/server/adapter-types.ts +51 -0
  72. package/server/agent-executor.ts +249 -0
  73. package/server/agent-mcp-bridge.ts +148 -0
  74. package/server/agent-mcp-stdio.ts +171 -0
  75. package/server/agent-mcp-tools.ts +76 -0
  76. package/server/agent-store.ts +183 -0
  77. package/server/agent-types.ts +79 -0
  78. package/server/aider-adapter.ts +525 -0
  79. package/server/auth.ts +153 -0
  80. package/server/auto-namer.ts +132 -0
  81. package/server/capability-discovery.ts +431 -0
  82. package/server/claude-container-auth.ts +40 -0
  83. package/server/claude-stdio-adapter.ts +452 -0
  84. package/server/clawhub-export.ts +218 -0
  85. package/server/cli-launcher.ts +1613 -0
  86. package/server/codex-adapter.ts +1987 -0
  87. package/server/codex-container-auth.ts +40 -0
  88. package/server/codex-home.ts +26 -0
  89. package/server/collective-intelligence.ts +401 -0
  90. package/server/commands-discovery.ts +160 -0
  91. package/server/constants.ts +5 -0
  92. package/server/container-manager.ts +328 -0
  93. package/server/cron-scheduler.ts +243 -0
  94. package/server/cron-store.ts +149 -0
  95. package/server/cron-types.ts +63 -0
  96. package/server/deliberation-engine.ts +323 -0
  97. package/server/embedding.ts +89 -0
  98. package/server/env-manager.ts +146 -0
  99. package/server/environment-detector.ts +106 -0
  100. package/server/environment-rules.ts +106 -0
  101. package/server/gallery-store.ts +216 -0
  102. package/server/gallery-types.ts +52 -0
  103. package/server/gallery-votes.ts +101 -0
  104. package/server/git-utils.ts +384 -0
  105. package/server/github-pr.ts +379 -0
  106. package/server/goose-adapter.ts +929 -0
  107. package/server/image-pull-manager.ts +144 -0
  108. package/server/index.ts +363 -0
  109. package/server/linear-project-manager.ts +78 -0
  110. package/server/moltbook-client.ts +160 -0
  111. package/server/openclaw-adapter.ts +916 -0
  112. package/server/openclaw-channel/channel.ts +188 -0
  113. package/server/openclaw-channel/index.ts +68 -0
  114. package/server/openclaw-channel/package.json +19 -0
  115. package/server/openclaw-channel/types.ts +93 -0
  116. package/server/opencode-adapter.ts +951 -0
  117. package/server/openhands-adapter.ts +775 -0
  118. package/server/orchestrator-executor.ts +184 -0
  119. package/server/orchestrator-store.ts +106 -0
  120. package/server/orchestrator-types.ts +86 -0
  121. package/server/path-resolver.ts +168 -0
  122. package/server/pr-poller.ts +162 -0
  123. package/server/proactive-keepalive.ts +121 -0
  124. package/server/prompt-manager.ts +103 -0
  125. package/server/protocol-monitor.ts +197 -0
  126. package/server/race-controller.ts +295 -0
  127. package/server/race-store.ts +82 -0
  128. package/server/recorder.ts +359 -0
  129. package/server/recording-hub/compat-validator.ts +122 -0
  130. package/server/recording-hub/diagnostics.ts +289 -0
  131. package/server/recording-hub/hub-routes.ts +146 -0
  132. package/server/recording-hub/hub-store.ts +224 -0
  133. package/server/replay.ts +78 -0
  134. package/server/routes/adapter-routes.ts +31 -0
  135. package/server/routes/agent-mcp-routes.ts +35 -0
  136. package/server/routes/agent-routes.ts +185 -0
  137. package/server/routes/auth-routes.ts +101 -0
  138. package/server/routes/ci-routes.ts +165 -0
  139. package/server/routes/commands-routes.ts +94 -0
  140. package/server/routes/cron-routes.ts +100 -0
  141. package/server/routes/env-routes.ts +50 -0
  142. package/server/routes/folder-routes.ts +56 -0
  143. package/server/routes/fs-routes.ts +403 -0
  144. package/server/routes/gallery-routes.ts +410 -0
  145. package/server/routes/git-routes.ts +108 -0
  146. package/server/routes/index.ts +62 -0
  147. package/server/routes/linear-routes.ts +254 -0
  148. package/server/routes/monitor-routes.ts +11 -0
  149. package/server/routes/orchestrator-routes.ts +210 -0
  150. package/server/routes/prompt-routes.ts +47 -0
  151. package/server/routes/race-routes.ts +103 -0
  152. package/server/routes/recording-routes.ts +101 -0
  153. package/server/routes/route-deps.ts +32 -0
  154. package/server/routes/session-routes.ts +560 -0
  155. package/server/routes/settings-routes.ts +108 -0
  156. package/server/routes/skills-routes.ts +52 -0
  157. package/server/routes/system-routes.ts +243 -0
  158. package/server/routes/webhook-routes.ts +135 -0
  159. package/server/routes.ts +44 -0
  160. package/server/security-middleware.ts +100 -0
  161. package/server/semantic-memory.ts +389 -0
  162. package/server/service.ts +733 -0
  163. package/server/session-folders.ts +107 -0
  164. package/server/session-git-info.ts +89 -0
  165. package/server/session-linear-issues.ts +94 -0
  166. package/server/session-names.ts +67 -0
  167. package/server/session-store.ts +169 -0
  168. package/server/session-types.ts +415 -0
  169. package/server/settings-manager.ts +120 -0
  170. package/server/shared-context.ts +401 -0
  171. package/server/skills-manager.ts +250 -0
  172. package/server/sub-session-manager.ts +263 -0
  173. package/server/terminal-manager.ts +185 -0
  174. package/server/update-checker.ts +114 -0
  175. package/server/usage-limits.ts +192 -0
  176. package/server/webhook-manager.ts +228 -0
  177. package/server/webhook-store.ts +168 -0
  178. package/server/webhook-types.ts +66 -0
  179. package/server/worktree-tracker.ts +84 -0
  180. package/server/ws-bridge.ts +2322 -0
@@ -0,0 +1,2322 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import { randomUUID } from "node:crypto";
3
+ import { execFileSync } from "node:child_process";
4
+ import { resolve } from "node:path";
5
+ import type {
6
+ CLIMessage,
7
+ CLISystemInitMessage,
8
+ CLISystemStatusMessage,
9
+ CLIAssistantMessage,
10
+ CLIResultMessage,
11
+ CLIStreamEventMessage,
12
+ CLIToolProgressMessage,
13
+ CLIToolUseSummaryMessage,
14
+ CLIControlRequestMessage,
15
+ CLIControlResponseMessage,
16
+ CLIAuthStatusMessage,
17
+ BrowserOutgoingMessage,
18
+ BrowserIncomingMessage,
19
+ ReplayableBrowserIncomingMessage,
20
+ BufferedBrowserEvent,
21
+ SessionState,
22
+ PermissionRequest,
23
+ BackendType,
24
+ SessionRole,
25
+ PresenceViewer,
26
+ VotingPolicy,
27
+ PermissionVote,
28
+ McpServerDetail,
29
+ McpServerConfig,
30
+ DetectedEnvironment,
31
+ SubAgentUpdate,
32
+ } from "./session-types.js";
33
+ import type { SessionStore } from "./session-store.js";
34
+ import type { CodexAdapter } from "./codex-adapter.js";
35
+ import type { AgentAdapter } from "./adapter-types.js";
36
+ import type { RecorderManager } from "./recorder.js";
37
+ import type { CollectiveIntelligenceLayer } from "./collective-intelligence.js";
38
+
39
+ // ─── WebSocket data tags ──────────────────────────────────────────────────────
40
+
41
+ interface CLISocketData {
42
+ kind: "cli";
43
+ sessionId: string;
44
+ }
45
+
46
+ interface BrowserSocketData {
47
+ kind: "browser";
48
+ sessionId: string;
49
+ subscribed?: boolean;
50
+ lastAckSeq?: number;
51
+ /** Unique viewer ID assigned on connect (short random string) */
52
+ viewerId?: string;
53
+ /** Display name for presence (defaults to "Viewer N") */
54
+ viewerName?: string;
55
+ /** Role for RBAC enforcement */
56
+ role?: SessionRole;
57
+ /** Role from invite token, set at WebSocket upgrade time */
58
+ _joinRole?: SessionRole;
59
+ }
60
+
61
+ interface TerminalSocketData {
62
+ kind: "terminal";
63
+ terminalId: string;
64
+ }
65
+
66
+ export type SocketData = CLISocketData | BrowserSocketData | TerminalSocketData;
67
+
68
+ // ─── Session ──────────────────────────────────────────────────────────────────
69
+
70
+ /** Tracks a pending control_request sent to CLI that expects a control_response. */
71
+ interface PendingControlRequest {
72
+ subtype: string;
73
+ resolve: (response: unknown) => void;
74
+ }
75
+
76
+ /** Tracks in-progress vote collection for a single permission request */
77
+ interface PendingVoteCollection {
78
+ requestId: string;
79
+ votes: Map<string, PermissionVote>; // viewerId → vote
80
+ deadline: number; // timestamp when voting closes
81
+ timer: ReturnType<typeof setTimeout>;
82
+ /** The original permission response message (from first voter, used as template) */
83
+ templateMsg: {
84
+ updated_input?: Record<string, unknown>;
85
+ updated_permissions?: unknown[];
86
+ message?: string;
87
+ };
88
+ }
89
+
90
+ interface Session {
91
+ id: string;
92
+ backendType: BackendType;
93
+ cliSocket: ServerWebSocket<SocketData> | null;
94
+ adapter: AgentAdapter | null;
95
+ browserSockets: Set<ServerWebSocket<SocketData>>;
96
+ state: SessionState;
97
+ pendingPermissions: Map<string, PermissionRequest>;
98
+ /** Pending control_requests sent TO CLI, keyed by request_id */
99
+ pendingControlRequests: Map<string, PendingControlRequest>;
100
+ messageHistory: BrowserIncomingMessage[];
101
+ /** Messages queued while waiting for CLI to connect */
102
+ pendingMessages: string[];
103
+ /** Monotonic sequence for broadcast events */
104
+ nextEventSeq: number;
105
+ /** Recent broadcast events for reconnect replay */
106
+ eventBuffer: BufferedBrowserEvent[];
107
+ /** Highest acknowledged seq seen from any browser for this session */
108
+ lastAckSeq: number;
109
+ /** Recently processed browser client_msg_id values for idempotency on reconnect retries */
110
+ processedClientMessageIds: string[];
111
+ processedClientMessageIdSet: Set<string>;
112
+ /** In-progress vote collections for permission requests (multi-viewer sessions) */
113
+ pendingVotes: Map<string, PendingVoteCollection>;
114
+ }
115
+
116
+ type GitSessionKey = "git_branch" | "is_worktree" | "repo_root" | "git_ahead" | "git_behind";
117
+
118
+ interface AgentMcpBridgeHook {
119
+ onSessionReady(sessionId: string, backendType: BackendType, cwd: string): void;
120
+ handlePermissionRequest?(sessionId: string, msg: CLIControlRequestMessage): boolean;
121
+ handleAdapterPermissionRequest?(
122
+ sessionId: string,
123
+ request: PermissionRequest,
124
+ respond: (msg: Extract<BrowserOutgoingMessage, { type: "permission_response" }>) => void,
125
+ ): boolean;
126
+ }
127
+
128
+ function makeDefaultState(sessionId: string, backendType: BackendType = "claude"): SessionState {
129
+ return {
130
+ session_id: sessionId,
131
+ backend_type: backendType,
132
+ model: "",
133
+ cwd: "",
134
+ tools: [],
135
+ permissionMode: "default",
136
+ claude_code_version: "",
137
+ mcp_servers: [],
138
+ agents: [],
139
+ slash_commands: [],
140
+ skills: [],
141
+ total_cost_usd: 0,
142
+ num_turns: 0,
143
+ context_used_percent: 0,
144
+ is_compacting: false,
145
+ git_branch: "",
146
+ is_worktree: false,
147
+ repo_root: "",
148
+ git_start_commit: "",
149
+ git_ahead: 0,
150
+ git_behind: 0,
151
+ total_lines_added: 0,
152
+ total_lines_removed: 0,
153
+ total_duration_api_ms: 0,
154
+ };
155
+ }
156
+
157
+ // ─── Git info helper ─────────────────────────────────────────────────────────
158
+
159
+ const BIN_GIT = "/usr/bin/git";
160
+
161
+ /** Run a git command in the given directory, returning trimmed output or null on failure. */
162
+ function gitExec(args: string, cwd: string, timeout = 3000): string | null {
163
+ try {
164
+ return execFileSync(BIN_GIT, args.split(" "), { cwd, encoding: "utf-8", timeout }).trim();
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /** Parse insertions/deletions from `git diff --shortstat` output. */
171
+ function parseShortstat(stat: string): { added: number; removed: number } {
172
+ const ins = /\b(\d+) insertion/.exec(stat);
173
+ const del = /\b(\d+) deletion/.exec(stat);
174
+ return {
175
+ added: ins ? Number.parseInt(ins[1], 10) : 0,
176
+ removed: del ? Number.parseInt(del[1], 10) : 0,
177
+ };
178
+ }
179
+
180
+ /** Resolve the base ref for diffing (upstream tracking branch, or main/master). */
181
+ function resolveBaseRef(cwd: string): string {
182
+ const upstream = gitExec("rev-parse --abbrev-ref @{upstream}", cwd);
183
+ if (upstream) return upstream;
184
+ // No upstream — try main or master
185
+ for (const candidate of ["main", "master"]) {
186
+ if (gitExec(`rev-parse --verify ${candidate}`, cwd) !== null) {
187
+ return candidate;
188
+ }
189
+ }
190
+ return "";
191
+ }
192
+
193
+ /** Compute total lines added/removed (committed + uncommitted). */
194
+ function resolveLineChanges(cwd: string): { added: number; removed: number } {
195
+ let totalAdded = 0;
196
+ let totalRemoved = 0;
197
+
198
+ // 1) Committed changes: diff from merge-base with upstream or main/master
199
+ const baseRef = resolveBaseRef(cwd);
200
+ if (baseRef) {
201
+ const mergeBase = gitExec(`merge-base ${baseRef} HEAD`, cwd);
202
+ if (mergeBase) {
203
+ const stat = gitExec(`diff --shortstat ${mergeBase} HEAD`, cwd, 5000);
204
+ if (stat) {
205
+ const { added, removed } = parseShortstat(stat);
206
+ totalAdded += added;
207
+ totalRemoved += removed;
208
+ }
209
+ }
210
+ }
211
+
212
+ // 2) Uncommitted changes (staged + unstaged) on top of HEAD
213
+ const uncommittedStat = gitExec("diff --shortstat HEAD", cwd, 5000);
214
+ if (uncommittedStat) {
215
+ const { added, removed } = parseShortstat(uncommittedStat);
216
+ totalAdded += added;
217
+ totalRemoved += removed;
218
+ }
219
+
220
+ return { added: totalAdded, removed: totalRemoved };
221
+ }
222
+
223
+ function resolveGitInfo(state: SessionState): void {
224
+ if (!state.cwd) return;
225
+ const cwd = state.cwd;
226
+
227
+ const branch = gitExec("rev-parse --abbrev-ref HEAD", cwd);
228
+ if (branch === null) {
229
+ // Not a git repo or git not available
230
+ state.git_branch = "";
231
+ state.is_worktree = false;
232
+ state.repo_root = "";
233
+ state.git_ahead = 0;
234
+ state.git_behind = 0;
235
+ return;
236
+ }
237
+
238
+ state.git_branch = branch;
239
+
240
+ // Capture starting commit once (used as diff base for the session)
241
+ if (!state.git_start_commit) {
242
+ state.git_start_commit = gitExec("rev-parse HEAD", cwd) ?? "";
243
+ }
244
+
245
+ const gitDir = gitExec("rev-parse --git-dir", cwd);
246
+ if (gitDir) {
247
+ state.is_worktree = gitDir.includes("/worktrees/");
248
+ }
249
+
250
+ if (state.is_worktree) {
251
+ const commonDir = gitExec("rev-parse --git-common-dir", cwd);
252
+ if (commonDir) {
253
+ state.repo_root = resolve(cwd, commonDir, "..");
254
+ }
255
+ } else {
256
+ state.repo_root = gitExec("rev-parse --show-toplevel", cwd) ?? "";
257
+ }
258
+
259
+ const counts = gitExec("rev-list --left-right --count @{upstream}...HEAD", cwd);
260
+ if (counts) {
261
+ const [behind, ahead] = counts.split(/\s+/).map(Number);
262
+ state.git_ahead = ahead || 0;
263
+ state.git_behind = behind || 0;
264
+ } else {
265
+ state.git_ahead = 0;
266
+ state.git_behind = 0;
267
+ }
268
+
269
+ try {
270
+ const { added, removed } = resolveLineChanges(cwd);
271
+ state.total_lines_added = added;
272
+ state.total_lines_removed = removed;
273
+ } catch {
274
+ // git diff can fail if there's no HEAD commit yet
275
+ }
276
+ }
277
+
278
+ // ─── Bridge ───────────────────────────────────────────────────────────────────
279
+
280
+ export class WsBridge {
281
+ private static readonly EVENT_BUFFER_LIMIT = 600;
282
+ private static readonly PROCESSED_CLIENT_MSG_ID_LIMIT = 1000;
283
+ private static readonly IDEMPOTENT_BROWSER_MESSAGE_TYPES = new Set<string>([
284
+ "user_message",
285
+ "permission_response",
286
+ "interrupt",
287
+ "set_model",
288
+ "set_permission_mode",
289
+ "mcp_get_status",
290
+ "mcp_toggle",
291
+ "mcp_reconnect",
292
+ "mcp_set_servers",
293
+ ]);
294
+ private static readonly VOTE_DEADLINE_MS = 30_000; // 30 seconds to vote
295
+ private static readonly INVITE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
296
+ private readonly sessions = new Map<string, Session>();
297
+ private readonly inviteTokens = new Map<string, { sessionId: string; role: SessionRole; expiresAt: number }>();
298
+ private viewerCounter = 0;
299
+ private votingPolicy: VotingPolicy = "majority-rules";
300
+ private store: SessionStore | null = null;
301
+ private recorder: RecorderManager | null = null;
302
+ private protocolMonitor: import("./protocol-monitor.js").ProtocolMonitor | null = null;
303
+ private webhookManager: import("./webhook-manager.js").WebhookManager | null = null;
304
+ private onCLISessionId: ((sessionId: string, cliSessionId: string) => void) | null = null;
305
+ private onCLIRelaunchNeeded: ((sessionId: string) => boolean | void) | null = null;
306
+ private onFirstTurnCompleted: ((sessionId: string, firstUserMessage: string) => boolean | void | Promise<boolean | void>) | null = null;
307
+ private agentMcpBridge: AgentMcpBridgeHook | null = null;
308
+ private readonly environmentMcpInjected = new Set<string>();
309
+ private readonly autoNamingAttempted = new Set<string>();
310
+ private userMsgCounter = 0;
311
+ private onGitInfoReady: ((sessionId: string, cwd: string, branch: string) => void) | null = null;
312
+ private static readonly GIT_SESSION_KEYS: GitSessionKey[] = [
313
+ "git_branch",
314
+ "is_worktree",
315
+ "repo_root",
316
+ "git_ahead",
317
+ "git_behind",
318
+ ];
319
+
320
+ /** Register a callback for when we learn the CLI's internal session ID. */
321
+ onCLISessionIdReceived(cb: (sessionId: string, cliSessionId: string) => void): void {
322
+ this.onCLISessionId = cb;
323
+ }
324
+
325
+ /** Register a callback for when a browser connects but CLI is dead. */
326
+ onCLIRelaunchNeededCallback(cb: (sessionId: string) => boolean | void): void {
327
+ this.onCLIRelaunchNeeded = cb;
328
+ }
329
+
330
+ /** Register a callback for when a session completes its first turn. */
331
+ onFirstTurnCompletedCallback(cb: (sessionId: string, firstUserMessage: string) => boolean | void | Promise<boolean | void>): void {
332
+ this.onFirstTurnCompleted = cb;
333
+ }
334
+
335
+ /** Register a callback for when git info is resolved and branch is known. */
336
+ onSessionGitInfoReadyCallback(cb: (sessionId: string, cwd: string, branch: string) => void): void {
337
+ this.onGitInfoReady = cb;
338
+ }
339
+
340
+ /** Push a message to all connected browsers for a session (public, for PRPoller etc.). */
341
+ broadcastToSession(sessionId: string, msg: BrowserIncomingMessage): void {
342
+ const session = this.sessions.get(sessionId);
343
+ if (!session) return;
344
+ this.broadcastToBrowsers(session, msg);
345
+ }
346
+
347
+ setAgentMcpBridge(bridge: AgentMcpBridgeHook): void {
348
+ this.agentMcpBridge = bridge;
349
+ }
350
+
351
+ markSessionOrchestration(
352
+ sessionId: string,
353
+ data: {
354
+ parentSessionId?: string;
355
+ role?: "lead" | "subagent" | "race_entry";
356
+ detectedEnvironment?: DetectedEnvironment;
357
+ },
358
+ ): void {
359
+ const session = this.getOrCreateSession(sessionId);
360
+ if (data.parentSessionId) session.state.parent_session_id = data.parentSessionId;
361
+ if (data.role) session.state.orchestration_role = data.role;
362
+ if (data.detectedEnvironment) session.state.detected_environment = data.detectedEnvironment;
363
+ this.persistSession(session);
364
+ }
365
+
366
+ broadcastSubAgentUpdate(parentSessionId: string, agent: SubAgentUpdate): void {
367
+ this.broadcastToSession(parentSessionId, { type: "sub_agent_update", agent });
368
+ }
369
+
370
+ addSubAgentCost(parentSessionId: string, costUsd: number): void {
371
+ if (!Number.isFinite(costUsd) || costUsd <= 0) return;
372
+ const session = this.sessions.get(parentSessionId);
373
+ if (!session) return;
374
+ session.state.sub_agent_cost_usd = (session.state.sub_agent_cost_usd || 0) + costUsd;
375
+ this.broadcastToBrowsers(session, {
376
+ type: "session_update",
377
+ session: { sub_agent_cost_usd: session.state.sub_agent_cost_usd },
378
+ });
379
+ this.persistSession(session);
380
+ }
381
+
382
+ setMcpServers(sessionId: string, servers: Record<string, McpServerConfig>): void {
383
+ const session = this.sessions.get(sessionId);
384
+ if (!session) return;
385
+ this.routeBrowserMessage(session, { type: "mcp_set_servers", servers });
386
+ }
387
+
388
+ private injectDetectedEnvironmentMcp(session: Session): void {
389
+ if (this.environmentMcpInjected.has(session.id)) return;
390
+ if (session.state.parent_session_id || session.state.orchestration_role === "subagent") return;
391
+ if (session.backendType !== "claude" && session.backendType !== "codex") return;
392
+ const servers = session.state.detected_environment?.mcpServers;
393
+ if (!servers || Object.keys(servers).length === 0) return;
394
+ if (process.env.CAMPFIRE_AUTO_INJECT_ENV_MCP === "0") return;
395
+ this.environmentMcpInjected.add(session.id);
396
+ this.routeBrowserMessage(session, { type: "mcp_set_servers", servers });
397
+ }
398
+
399
+ // ── Invite tokens ────────────────────────────────────────────────────
400
+
401
+ /** Drop expired invite tokens (called opportunistically on create/resolve). */
402
+ private pruneExpiredInviteTokens(): void {
403
+ const now = Date.now();
404
+ for (const [token, entry] of this.inviteTokens) {
405
+ if (entry.expiresAt <= now) this.inviteTokens.delete(token);
406
+ }
407
+ }
408
+
409
+ /** Generate a short invite token for a session with a specified role.
410
+ * Tokens expire after 24 hours — an invite link is a bearer credential
411
+ * that bypasses password auth, so it must not live forever. */
412
+ createInviteToken(sessionId: string, role: SessionRole = "collaborator"): string | null {
413
+ if (!this.sessions.has(sessionId)) return null;
414
+
415
+ this.pruneExpiredInviteTokens();
416
+ // Generate a 12-char URL-safe token (always new — different roles may be desired)
417
+ const token = randomUUID().replaceAll("-", "").substring(0, 12);
418
+ this.inviteTokens.set(token, {
419
+ sessionId,
420
+ role,
421
+ expiresAt: Date.now() + WsBridge.INVITE_TOKEN_TTL_MS,
422
+ });
423
+ return token;
424
+ }
425
+
426
+ private getLiveInviteEntry(token: string): { sessionId: string; role: SessionRole } | null {
427
+ const entry = this.inviteTokens.get(token);
428
+ if (!entry) return null;
429
+ if (entry.expiresAt <= Date.now()) {
430
+ this.inviteTokens.delete(token);
431
+ return null;
432
+ }
433
+ return entry;
434
+ }
435
+
436
+ /** Resolve an invite token to a session ID. Returns null if invalid or expired. */
437
+ resolveInviteToken(token: string): string | null {
438
+ return this.getLiveInviteEntry(token)?.sessionId ?? null;
439
+ }
440
+
441
+ /** Get the role associated with an invite token. Returns null if invalid or expired. */
442
+ resolveInviteTokenRole(token: string): SessionRole | null {
443
+ return this.getLiveInviteEntry(token)?.role ?? null;
444
+ }
445
+
446
+ /** Get presence info for all connected viewers in a session. */
447
+ getSessionViewers(sessionId: string): PresenceViewer[] {
448
+ const session = this.sessions.get(sessionId);
449
+ if (!session) return [];
450
+ const viewers: PresenceViewer[] = [];
451
+ for (const ws of session.browserSockets) {
452
+ const data = ws.data as BrowserSocketData;
453
+ if (data.viewerId) {
454
+ viewers.push({
455
+ id: data.viewerId,
456
+ name: data.viewerName || `Viewer ${data.viewerId.slice(0, 4)}`,
457
+ role: data.role || "spectator",
458
+ });
459
+ }
460
+ }
461
+ return viewers;
462
+ }
463
+
464
+ /** Broadcast current presence to all browsers in a session. */
465
+ private broadcastPresence(session: Session): void {
466
+ const viewers = this.getSessionViewers(session.id);
467
+ this.broadcastToBrowsers(session, {
468
+ type: "presence_update",
469
+ viewers,
470
+ });
471
+ }
472
+
473
+ /** Set the voting policy for permission requests in multi-viewer sessions. */
474
+ setVotingPolicy(policy: VotingPolicy): void {
475
+ this.votingPolicy = policy;
476
+ }
477
+
478
+ /** Get the current voting policy. */
479
+ getVotingPolicy(): VotingPolicy {
480
+ return this.votingPolicy;
481
+ }
482
+
483
+ /** Attach a persistent store. Call restoreFromDisk() after. */
484
+ setStore(store: SessionStore): void {
485
+ this.store = store;
486
+ }
487
+
488
+ /** Attach a protocol monitor for real-time metrics. */
489
+ setProtocolMonitor(monitor: import("./protocol-monitor.js").ProtocolMonitor): void {
490
+ this.protocolMonitor = monitor;
491
+ }
492
+
493
+ /** Attach a recorder for raw message capture. */
494
+ setRecorder(recorder: RecorderManager): void {
495
+ this.recorder = recorder;
496
+ }
497
+
498
+ setWebhookManager(wm: import("./webhook-manager.js").WebhookManager): void {
499
+ this.webhookManager = wm;
500
+ }
501
+
502
+ setCollectiveIntelligence(ci: CollectiveIntelligenceLayer): void {
503
+ this.collectiveIntelligence = ci;
504
+ // Give CI access to broadcastToSession so it can emit CI messages to browsers
505
+ ci.setBroadcast((sessionId, msg) => {
506
+ if (sessionId === "__all__") {
507
+ // Broadcast to all active sessions (used for deliberation resolutions)
508
+ for (const session of this.sessions.values()) {
509
+ this.broadcastToBrowsers(session, msg);
510
+ }
511
+ } else {
512
+ this.broadcastToSession(sessionId, msg);
513
+ }
514
+ });
515
+ }
516
+
517
+ private collectiveIntelligence: CollectiveIntelligenceLayer | null = null;
518
+
519
+ /** Hydrate a persisted session record into a live Session object. */
520
+ private hydrateSession(p: ReturnType<SessionStore["loadAll"]>[number]): Session {
521
+ const clientMsgIds = Array.isArray(p.processedClientMessageIds)
522
+ ? p.processedClientMessageIds
523
+ : [];
524
+ const session: Session = {
525
+ id: p.id,
526
+ backendType: p.state.backend_type || "claude",
527
+ cliSocket: null,
528
+ adapter: null,
529
+ browserSockets: new Set(),
530
+ state: p.state,
531
+ pendingPermissions: new Map(p.pendingPermissions || []),
532
+ pendingControlRequests: new Map(),
533
+ messageHistory: p.messageHistory || [],
534
+ pendingMessages: p.pendingMessages || [],
535
+ nextEventSeq: p.nextEventSeq && p.nextEventSeq > 0 ? p.nextEventSeq : 1,
536
+ eventBuffer: Array.isArray(p.eventBuffer) ? p.eventBuffer : [],
537
+ lastAckSeq: typeof p.lastAckSeq === "number" ? p.lastAckSeq : 0,
538
+ processedClientMessageIds: clientMsgIds,
539
+ processedClientMessageIdSet: new Set(clientMsgIds),
540
+ pendingVotes: new Map(),
541
+ };
542
+ session.state.backend_type = session.backendType;
543
+ resolveGitInfo(session.state);
544
+ return session;
545
+ }
546
+
547
+ /** Restore sessions from disk (call once at startup). */
548
+ restoreFromDisk(): number {
549
+ if (!this.store) return 0;
550
+ const persisted = this.store.loadAll();
551
+ let count = 0;
552
+ for (const p of persisted) {
553
+ if (this.sessions.has(p.id)) continue; // don't overwrite live sessions
554
+ const session = this.hydrateSession(p);
555
+ this.sessions.set(p.id, session);
556
+ // Restored sessions with completed turns don't need auto-naming re-triggered
557
+ if (session.state.num_turns > 0) {
558
+ this.autoNamingAttempted.add(session.id);
559
+ }
560
+ count++;
561
+ }
562
+ if (count > 0) {
563
+ console.log(`[ws-bridge] Restored ${count} session(s) from disk`);
564
+ }
565
+ return count;
566
+ }
567
+
568
+ /** Persist a session to disk (debounced). */
569
+ private persistSession(session: Session): void {
570
+ if (!this.store) return;
571
+ this.store.save({
572
+ id: session.id,
573
+ state: session.state,
574
+ messageHistory: session.messageHistory,
575
+ pendingMessages: session.pendingMessages,
576
+ pendingPermissions: Array.from(session.pendingPermissions.entries()),
577
+ eventBuffer: session.eventBuffer,
578
+ nextEventSeq: session.nextEventSeq,
579
+ lastAckSeq: session.lastAckSeq,
580
+ processedClientMessageIds: session.processedClientMessageIds,
581
+ });
582
+ }
583
+
584
+ private refreshGitInfo(
585
+ session: Session,
586
+ options: { broadcastUpdate?: boolean; notifyPoller?: boolean } = {},
587
+ ): void {
588
+ const before = {
589
+ git_branch: session.state.git_branch,
590
+ is_worktree: session.state.is_worktree,
591
+ repo_root: session.state.repo_root,
592
+ git_ahead: session.state.git_ahead,
593
+ git_behind: session.state.git_behind,
594
+ total_lines_added: session.state.total_lines_added,
595
+ total_lines_removed: session.state.total_lines_removed,
596
+ };
597
+
598
+ resolveGitInfo(session.state);
599
+
600
+ let changed = false;
601
+ for (const key of WsBridge.GIT_SESSION_KEYS) {
602
+ if (session.state[key] !== before[key]) {
603
+ changed = true;
604
+ break;
605
+ }
606
+ }
607
+ if (!changed) {
608
+ if (session.state.total_lines_added !== before.total_lines_added ||
609
+ session.state.total_lines_removed !== before.total_lines_removed) {
610
+ changed = true;
611
+ }
612
+ }
613
+
614
+ if (changed) {
615
+ if (options.broadcastUpdate) {
616
+ this.broadcastToBrowsers(session, {
617
+ type: "session_update",
618
+ session: {
619
+ git_branch: session.state.git_branch,
620
+ is_worktree: session.state.is_worktree,
621
+ repo_root: session.state.repo_root,
622
+ git_ahead: session.state.git_ahead,
623
+ git_behind: session.state.git_behind,
624
+ total_lines_added: session.state.total_lines_added,
625
+ total_lines_removed: session.state.total_lines_removed,
626
+ total_duration_api_ms: session.state.total_duration_api_ms,
627
+ },
628
+ });
629
+ }
630
+ this.persistSession(session);
631
+ }
632
+
633
+ if (options.notifyPoller && session.state.git_branch && session.state.cwd && this.onGitInfoReady) {
634
+ this.onGitInfoReady(session.id, session.state.cwd, session.state.git_branch);
635
+ }
636
+ }
637
+
638
+ // ── Session management ──────────────────────────────────────────────────
639
+
640
+ getOrCreateSession(sessionId: string, backendType?: BackendType): Session {
641
+ let session = this.sessions.get(sessionId);
642
+ if (!session) {
643
+ const type = backendType || "claude";
644
+ session = {
645
+ id: sessionId,
646
+ backendType: type,
647
+ cliSocket: null,
648
+ adapter: null,
649
+ browserSockets: new Set(),
650
+ state: makeDefaultState(sessionId, type),
651
+ pendingPermissions: new Map(),
652
+ pendingControlRequests: new Map(),
653
+ messageHistory: [],
654
+ pendingMessages: [],
655
+ nextEventSeq: 1,
656
+ eventBuffer: [],
657
+ lastAckSeq: 0,
658
+ processedClientMessageIds: [],
659
+ processedClientMessageIdSet: new Set(),
660
+ pendingVotes: new Map(),
661
+ };
662
+ this.sessions.set(sessionId, session);
663
+ } else if (backendType) {
664
+ // Only overwrite backendType when explicitly provided (e.g. attachAdapter)
665
+ // Prevents handleBrowserOpen from resetting codex/goose→claude
666
+ session.backendType = backendType;
667
+ session.state.backend_type = backendType;
668
+ }
669
+ return session;
670
+ }
671
+
672
+ getSession(sessionId: string): Session | undefined {
673
+ return this.sessions.get(sessionId);
674
+ }
675
+
676
+ /** Return all session IDs that currently have at least one connected browser. */
677
+ getConnectedSessionIds(): string[] {
678
+ const ids: string[] = [];
679
+ for (const [id, session] of this.sessions) {
680
+ if (session.browserSockets.size > 0) ids.push(id);
681
+ }
682
+ return ids;
683
+ }
684
+
685
+ /** Notify browsers that the CLI backend is being launched for a session. */
686
+ notifyLaunching(sessionId: string): void {
687
+ const session = this.sessions.get(sessionId);
688
+ if (session) {
689
+ this.broadcastToBrowsers(session, { type: "cli_launching" });
690
+ }
691
+ }
692
+
693
+ /** Pre-populate a session's message history (e.g. for forked sessions). */
694
+ seedMessageHistory(sessionId: string, messages: BrowserIncomingMessage[]): void {
695
+ const session = this.getOrCreateSession(sessionId);
696
+ session.messageHistory = [...messages];
697
+ }
698
+
699
+ getAllSessions(): SessionState[] {
700
+ return Array.from(this.sessions.values()).map((s) => s.state);
701
+ }
702
+
703
+ getCodexRateLimits(sessionId: string) {
704
+ const session = this.sessions.get(sessionId);
705
+ if (session?.backendType !== "codex" || !session.adapter) return null;
706
+ return (session.adapter as CodexAdapter).getRateLimits();
707
+ }
708
+
709
+ isCliConnected(sessionId: string): boolean {
710
+ const session = this.sessions.get(sessionId);
711
+ if (!session) return false;
712
+ if (session.adapter) return session.adapter.isConnected();
713
+ return !!session.cliSocket;
714
+ }
715
+
716
+ /** Run collective-intelligence end-of-session hooks (memory consolidation,
717
+ * shared-context promotion) before a session's state is discarded. */
718
+ private notifySessionEnded(session: Session): void {
719
+ if (!this.collectiveIntelligence) return;
720
+ const repoRoot = session.state.repo_root || session.state.cwd || "";
721
+ void this.collectiveIntelligence
722
+ .onSessionEnd(session.id, session.backendType, repoRoot)
723
+ .catch((err) => console.warn(`[ws-bridge] CI onSessionEnd failed for ${session.id}:`, err));
724
+ }
725
+
726
+ removeSession(sessionId: string) {
727
+ const session = this.sessions.get(sessionId);
728
+ if (session) this.notifySessionEnded(session);
729
+ this.sessions.delete(sessionId);
730
+ this.autoNamingAttempted.delete(sessionId);
731
+ this.store?.remove(sessionId);
732
+ }
733
+
734
+ /**
735
+ * Close all sockets (CLI + browsers) for a session and remove it.
736
+ */
737
+ closeSession(sessionId: string) {
738
+ const session = this.sessions.get(sessionId);
739
+ if (!session) return;
740
+
741
+ this.notifySessionEnded(session);
742
+
743
+ // Close CLI socket (Claude)
744
+ if (session.cliSocket) {
745
+ try { session.cliSocket.close(); } catch {}
746
+ session.cliSocket = null;
747
+ }
748
+
749
+ // Disconnect adapter (Codex/Goose)
750
+ if (session.adapter) {
751
+ session.adapter.disconnect().catch(() => {});
752
+ session.adapter = null;
753
+ }
754
+
755
+ // Close all browser sockets
756
+ for (const ws of session.browserSockets) {
757
+ try { ws.close(); } catch {}
758
+ }
759
+ session.browserSockets.clear();
760
+
761
+ this.sessions.delete(sessionId);
762
+ this.autoNamingAttempted.delete(sessionId);
763
+ this.store?.remove(sessionId);
764
+ }
765
+
766
+ // ── Adapter attachment (Codex / Goose / future adapters) ────────────────
767
+
768
+ /**
769
+ * Attach an AgentAdapter to a session. The adapter handles all message
770
+ * translation between the backend process (stdio JSON-RPC, etc.) and the
771
+ * browser WebSocket protocol.
772
+ */
773
+ attachAdapter(sessionId: string, adapter: AgentAdapter, backendType: BackendType): void {
774
+ const session = this.getOrCreateSession(sessionId, backendType);
775
+ session.backendType = backendType;
776
+ session.state.backend_type = backendType;
777
+ session.adapter = adapter;
778
+
779
+ // Emit webhook: session.created
780
+ this.webhookManager?.emit("session.created", sessionId, {
781
+ backendType,
782
+ cwd: session.state.cwd,
783
+ model: session.state.model,
784
+ });
785
+
786
+ // Forward translated messages to browsers
787
+ adapter.onBrowserMessage((msg) => {
788
+ this.handleAdapterBrowserMessage(session, msg, backendType);
789
+ });
790
+
791
+ // Handle session metadata updates
792
+ adapter.onSessionMeta((meta) => {
793
+ if (meta.cliSessionId && this.onCLISessionId) {
794
+ this.onCLISessionId(session.id, meta.cliSessionId);
795
+ }
796
+ if (meta.model) session.state.model = meta.model;
797
+ if (meta.cwd) session.state.cwd = meta.cwd;
798
+ session.state.backend_type = backendType;
799
+ this.refreshGitInfo(session, { broadcastUpdate: true, notifyPoller: true });
800
+ this.persistSession(session);
801
+ });
802
+
803
+ // Handle disconnect
804
+ adapter.onDisconnect(() => {
805
+ for (const [reqId] of session.pendingPermissions) {
806
+ this.broadcastToBrowsers(session, { type: "permission_cancelled", request_id: reqId });
807
+ }
808
+ session.pendingPermissions.clear();
809
+ session.adapter = null;
810
+ this.persistSession(session);
811
+ console.log(`[ws-bridge] ${backendType} adapter disconnected for session ${sessionId}`);
812
+ this.broadcastToBrowsers(session, { type: "cli_disconnected" });
813
+ });
814
+
815
+ // Flush any messages queued while waiting for the adapter
816
+ if (session.pendingMessages.length > 0) {
817
+ console.log(`[ws-bridge] Flushing ${session.pendingMessages.length} queued message(s) to ${backendType} adapter for session ${sessionId}`);
818
+ const queued = session.pendingMessages.splice(0);
819
+ for (const raw of queued) {
820
+ try {
821
+ const msg = this.normalizeQueuedAdapterMessage(raw, backendType);
822
+ if (msg) adapter.sendBrowserMessage(msg);
823
+ } catch {
824
+ console.warn(`[ws-bridge] Failed to parse queued message for ${backendType}: ${raw.substring(0, 100)}`);
825
+ }
826
+ }
827
+ }
828
+
829
+ // Notify browsers that the backend is connected
830
+ this.broadcastToBrowsers(session, { type: "cli_connected" });
831
+ console.log(`[ws-bridge] ${backendType} adapter attached for session ${sessionId}`);
832
+ }
833
+
834
+ private normalizeQueuedAdapterMessage(raw: string, backendType: BackendType): BrowserOutgoingMessage | null {
835
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
836
+ if (parsed.type === "user_message" || parsed.type === "permission_response" || parsed.type === "interrupt"
837
+ || parsed.type === "set_model" || parsed.type === "set_permission_mode" || parsed.type === "mcp_get_status"
838
+ || parsed.type === "mcp_toggle" || parsed.type === "mcp_reconnect" || parsed.type === "mcp_set_servers") {
839
+ return parsed as BrowserOutgoingMessage;
840
+ }
841
+
842
+ if (backendType !== "claude") return null;
843
+
844
+ if (parsed.type === "user") {
845
+ const message = parsed.message as { content?: unknown } | undefined;
846
+ const content = message?.content;
847
+ if (typeof content === "string") {
848
+ return { type: "user_message", content };
849
+ }
850
+ if (Array.isArray(content)) {
851
+ const text = content
852
+ .filter((block): block is { type: string; text: string } =>
853
+ !!block && typeof block === "object"
854
+ && (block as { type?: unknown }).type === "text"
855
+ && typeof (block as { text?: unknown }).text === "string")
856
+ .map((block) => block.text)
857
+ .join("\n");
858
+ return { type: "user_message", content: text };
859
+ }
860
+ }
861
+
862
+ if (parsed.type === "control_request") {
863
+ const request = parsed.request as Record<string, unknown> | undefined;
864
+ if (request?.subtype === "interrupt") return { type: "interrupt" };
865
+ if (request?.subtype === "set_model" && typeof request.model === "string") {
866
+ return { type: "set_model", model: request.model };
867
+ }
868
+ if (request?.subtype === "set_permission_mode" && typeof request.mode === "string") {
869
+ return { type: "set_permission_mode", mode: request.mode };
870
+ }
871
+ if (request?.subtype === "mcp_status") return { type: "mcp_get_status" };
872
+ if (request?.subtype === "mcp_toggle" && typeof request.serverName === "string" && typeof request.enabled === "boolean") {
873
+ return { type: "mcp_toggle", serverName: request.serverName, enabled: request.enabled };
874
+ }
875
+ if (request?.subtype === "mcp_reconnect" && typeof request.serverName === "string") {
876
+ return { type: "mcp_reconnect", serverName: request.serverName };
877
+ }
878
+ if (request?.subtype === "mcp_set_servers" && request.servers && typeof request.servers === "object") {
879
+ return { type: "mcp_set_servers", servers: request.servers as Record<string, McpServerConfig> };
880
+ }
881
+ }
882
+
883
+ if (parsed.type === "control_response") {
884
+ const responseEnvelope = parsed.response as { request_id?: unknown; response?: unknown } | undefined;
885
+ const response = responseEnvelope?.response as Record<string, unknown> | undefined;
886
+ const requestId = responseEnvelope?.request_id;
887
+ if (typeof requestId === "string" && (response?.behavior === "allow" || response?.behavior === "deny")) {
888
+ return {
889
+ type: "permission_response",
890
+ request_id: requestId,
891
+ behavior: response.behavior,
892
+ updated_input: response.updatedInput as Record<string, unknown> | undefined,
893
+ updated_permissions: response.updatedPermissions as import("./session-types.js").PermissionUpdate[] | undefined,
894
+ message: typeof response.message === "string" ? response.message : undefined,
895
+ };
896
+ }
897
+ }
898
+
899
+ console.warn(`[ws-bridge] Dropping unsupported queued ${backendType} adapter message: ${raw.substring(0, 100)}`);
900
+ return null;
901
+ }
902
+
903
+ // ── Adapter message dispatch (extracted for reduced complexity) ────────
904
+
905
+ /**
906
+ * Preserve persisted session stats when adapter reinitializes with zero values.
907
+ * Happens on server restart when a fresh adapter has no cost history.
908
+ */
909
+ private preserveSessionStatsOnInit(
910
+ session: Session,
911
+ newState: Partial<SessionState>,
912
+ backendType: BackendType,
913
+ ): void {
914
+ const preserved = {
915
+ cost: session.state.total_cost_usd || 0,
916
+ turns: session.state.num_turns || 0,
917
+ duration: session.state.total_duration_api_ms || 0,
918
+ linesAdded: session.state.total_lines_added || 0,
919
+ linesRemoved: session.state.total_lines_removed || 0,
920
+ contextPct: session.state.context_used_percent || 0,
921
+ codexDetails: session.state.codex_token_details,
922
+ claudeDetails: session.state.claude_token_details,
923
+ };
924
+
925
+ session.state = { ...session.state, ...newState, backend_type: backendType };
926
+
927
+ if (!session.state.total_cost_usd && preserved.cost > 0) {
928
+ session.state.total_cost_usd = preserved.cost;
929
+ }
930
+ if (!session.state.num_turns && preserved.turns > 0) {
931
+ session.state.num_turns = preserved.turns;
932
+ }
933
+ if (!session.state.total_duration_api_ms && preserved.duration > 0) {
934
+ session.state.total_duration_api_ms = preserved.duration;
935
+ }
936
+ if (!session.state.total_lines_added && preserved.linesAdded > 0) {
937
+ session.state.total_lines_added = preserved.linesAdded;
938
+ }
939
+ if (!session.state.total_lines_removed && preserved.linesRemoved > 0) {
940
+ session.state.total_lines_removed = preserved.linesRemoved;
941
+ }
942
+ if (!session.state.context_used_percent && preserved.contextPct > 0) {
943
+ session.state.context_used_percent = preserved.contextPct;
944
+ }
945
+ if (!session.state.codex_token_details && preserved.codexDetails) {
946
+ session.state.codex_token_details = preserved.codexDetails;
947
+ }
948
+ if (!session.state.claude_token_details && preserved.claudeDetails) {
949
+ session.state.claude_token_details = preserved.claudeDetails;
950
+ }
951
+ }
952
+
953
+ /** Persist adapter result cost/turns into session state so they survive reconnects. */
954
+ private persistAdapterResultData(session: Session, msg: BrowserIncomingMessage): void {
955
+ const resultData = (msg as { data?: CLIResultMessage }).data;
956
+ if (!resultData) return;
957
+ if (typeof resultData.total_cost_usd === "number" && resultData.total_cost_usd > (session.state.total_cost_usd || 0)) {
958
+ session.state.total_cost_usd = resultData.total_cost_usd;
959
+ }
960
+ if (typeof resultData.num_turns === "number") {
961
+ session.state.num_turns = resultData.num_turns;
962
+ }
963
+ }
964
+
965
+ /** Preserve Claude-specific result handling when Claude runs through the stdio adapter. */
966
+ private handleClaudeAdapterResult(session: Session, msg: CLIResultMessage): void {
967
+ this.applyResultToSessionState(session, msg);
968
+ this.applyModelUsage(session, msg);
969
+ this.refreshGitInfo(session, { broadcastUpdate: true, notifyPoller: true });
970
+
971
+ if (!session.state.total_lines_added && !session.state.total_lines_removed) {
972
+ const { added, removed } = this.computeLinesFromToolBlocks(session);
973
+ if (added > 0 || removed > 0) {
974
+ session.state.total_lines_added = added;
975
+ session.state.total_lines_removed = removed;
976
+ }
977
+ }
978
+
979
+ this.broadcastToBrowsers(session, {
980
+ type: "session_update",
981
+ session: {
982
+ total_duration_api_ms: session.state.total_duration_api_ms,
983
+ total_lines_added: session.state.total_lines_added,
984
+ total_lines_removed: session.state.total_lines_removed,
985
+ },
986
+ });
987
+
988
+ this.emitResultWebhooks(session, msg);
989
+ }
990
+
991
+ /** Try to trigger auto-naming after the first successful result in a session. */
992
+ private tryAutoNaming(session: Session, msg: BrowserIncomingMessage): void {
993
+ if (msg.type !== "result") return;
994
+ if ((msg as { data?: { is_error?: boolean } }).data?.is_error) return;
995
+ if (!this.onFirstTurnCompleted) return;
996
+ if (this.autoNamingAttempted.has(session.id)) return;
997
+
998
+ const firstUserMsg = session.messageHistory.find((m) => m.type === "user_message");
999
+ if (firstUserMsg?.type === "user_message") {
1000
+ this.autoNamingAttempted.add(session.id);
1001
+ void Promise.resolve(this.onFirstTurnCompleted(session.id, firstUserMsg.content))
1002
+ .then((completed) => {
1003
+ if (completed === false) this.autoNamingAttempted.delete(session.id);
1004
+ })
1005
+ .catch((err) => {
1006
+ console.warn(`[ws-bridge] Auto-naming callback failed for session ${session.id}:`, err);
1007
+ this.autoNamingAttempted.delete(session.id);
1008
+ });
1009
+ }
1010
+ }
1011
+
1012
+ /** Handle a message from an adapter (Codex/Goose/etc.) and route to browsers. */
1013
+ private handleAdapterBrowserMessage(
1014
+ session: Session,
1015
+ msg: BrowserIncomingMessage,
1016
+ backendType: BackendType,
1017
+ ): void {
1018
+ // Update session state based on message type
1019
+ if (msg.type === "session_init") {
1020
+ this.preserveSessionStatsOnInit(session, msg.session, backendType);
1021
+ this.refreshGitInfo(session, { notifyPoller: true });
1022
+ this.persistSession(session);
1023
+ this.injectDetectedEnvironmentMcp(session);
1024
+ this.agentMcpBridge?.onSessionReady(session.id, backendType, session.state.cwd);
1025
+ } else if (msg.type === "session_update") {
1026
+ session.state = { ...session.state, ...msg.session, backend_type: backendType };
1027
+ this.refreshGitInfo(session, { notifyPoller: true });
1028
+ this.persistSession(session);
1029
+ } else if (msg.type === "status_change") {
1030
+ session.state.is_compacting = msg.status === "compacting";
1031
+ this.persistSession(session);
1032
+ }
1033
+
1034
+ // Store assistant/result messages in history for replay
1035
+ if (msg.type === "assistant") {
1036
+ session.messageHistory.push({ ...msg, timestamp: msg.timestamp || Date.now() });
1037
+ this.persistSession(session);
1038
+ } else if (msg.type === "result") {
1039
+ if (backendType === "claude") {
1040
+ this.handleClaudeAdapterResult(session, msg.data);
1041
+ } else {
1042
+ this.persistAdapterResultData(session, msg);
1043
+ }
1044
+ session.messageHistory.push(msg);
1045
+ this.persistSession(session);
1046
+ }
1047
+
1048
+ // Handle permission requests
1049
+ if (msg.type === "permission_request") {
1050
+ if (this.agentMcpBridge?.handleAdapterPermissionRequest?.(session.id, msg.request, (response) => {
1051
+ session.adapter?.sendBrowserMessage(response);
1052
+ })) {
1053
+ return;
1054
+ }
1055
+
1056
+ session.pendingPermissions.set(msg.request.request_id, msg.request);
1057
+ this.persistSession(session);
1058
+ this.webhookManager?.emit("permission.requested", session.id, {
1059
+ backendType: session.backendType,
1060
+ toolName: msg.request.tool_name,
1061
+ requestId: msg.request.request_id,
1062
+ });
1063
+ }
1064
+
1065
+ this.broadcastToBrowsers(session, msg);
1066
+ this.tryAutoNaming(session, msg);
1067
+ }
1068
+
1069
+ // ── CLI WebSocket handlers ──────────────────────────────────────────────
1070
+
1071
+ handleCLIOpen(ws: ServerWebSocket<SocketData>, sessionId: string) {
1072
+ const session = this.getOrCreateSession(sessionId);
1073
+ session.cliSocket = ws;
1074
+ console.log(`[ws-bridge] CLI connected for session ${sessionId}`);
1075
+ this.broadcastToBrowsers(session, { type: "cli_connected" });
1076
+
1077
+ // Flush any messages that were queued while waiting for CLI to connect
1078
+ if (session.pendingMessages.length > 0) {
1079
+ console.log(`[ws-bridge] Flushing ${session.pendingMessages.length} queued message(s) for session ${sessionId}`);
1080
+ for (const ndjson of session.pendingMessages) {
1081
+ this.sendToCLI(session, ndjson);
1082
+ }
1083
+ session.pendingMessages = [];
1084
+ }
1085
+ }
1086
+
1087
+ handleCLIMessage(ws: ServerWebSocket<SocketData>, raw: string | Buffer) {
1088
+ const data = typeof raw === "string" ? raw : raw.toString("utf-8");
1089
+ const sessionId = (ws.data as CLISocketData).sessionId;
1090
+ const session = this.sessions.get(sessionId);
1091
+ if (!session) return;
1092
+
1093
+ // Record raw incoming CLI message before any parsing
1094
+ this.recorder?.record(sessionId, "in", data, "cli", session.backendType, session.state.cwd);
1095
+ this.protocolMonitor?.recordMessage(sessionId, "in", "cli", "cli_message", session.backendType);
1096
+
1097
+ // NDJSON: split on newlines, parse each line
1098
+ const lines = data.split("\n").filter((l) => l.trim());
1099
+ for (const line of lines) {
1100
+ let msg: CLIMessage;
1101
+ try {
1102
+ msg = JSON.parse(line);
1103
+ } catch {
1104
+ console.warn(`[ws-bridge] Failed to parse CLI message: ${line.substring(0, 200)}`);
1105
+ continue;
1106
+ }
1107
+ this.routeCLIMessage(session, msg);
1108
+ }
1109
+ }
1110
+
1111
+ handleCLIClose(ws: ServerWebSocket<SocketData>) {
1112
+ const sessionId = (ws.data as CLISocketData).sessionId;
1113
+ const session = this.sessions.get(sessionId);
1114
+ if (!session) return;
1115
+
1116
+ session.cliSocket = null;
1117
+ console.log(`[ws-bridge] CLI disconnected for session ${sessionId}`);
1118
+ this.broadcastToBrowsers(session, { type: "cli_disconnected" });
1119
+
1120
+ // Cancel any pending permission requests
1121
+ for (const [reqId] of session.pendingPermissions) {
1122
+ this.broadcastToBrowsers(session, { type: "permission_cancelled", request_id: reqId });
1123
+ }
1124
+ session.pendingPermissions.clear();
1125
+ }
1126
+
1127
+ // ── Browser WebSocket handlers ──────────────────────────────────────────
1128
+
1129
+ handleBrowserOpen(ws: ServerWebSocket<SocketData>, sessionId: string) {
1130
+ const session = this.getOrCreateSession(sessionId);
1131
+ const browserData = ws.data as BrowserSocketData;
1132
+ browserData.subscribed = false;
1133
+ browserData.lastAckSeq = 0;
1134
+ // Assign viewer identity
1135
+ const viewerNum = ++this.viewerCounter;
1136
+ const viewerId = randomUUID().replaceAll("-", "").substring(0, 8);
1137
+ const role: SessionRole = session.browserSockets.size === 0
1138
+ ? "owner"
1139
+ : (browserData._joinRole || "collaborator");
1140
+ browserData.viewerId = viewerId;
1141
+ browserData.viewerName = `Viewer ${viewerNum}`;
1142
+ browserData.role = role;
1143
+ session.browserSockets.add(ws);
1144
+ console.log(`[ws-bridge] Browser connected for session ${sessionId} (${session.browserSockets.size} browsers, role=${role})`);
1145
+
1146
+ // Refresh git state on browser connect so branch changes made mid-session are reflected.
1147
+ this.refreshGitInfo(session, { notifyPoller: true });
1148
+
1149
+ // Fallback: if git didn't produce line counts, compute from tool blocks
1150
+ if (!session.state.total_lines_added && !session.state.total_lines_removed) {
1151
+ const { added, removed } = this.computeLinesFromToolBlocks(session);
1152
+ if (added > 0 || removed > 0) {
1153
+ session.state.total_lines_added = added;
1154
+ session.state.total_lines_removed = removed;
1155
+ this.persistSession(session);
1156
+ }
1157
+ }
1158
+
1159
+ // Send current session state as snapshot
1160
+ const snapshot: BrowserIncomingMessage = {
1161
+ type: "session_init",
1162
+ session: session.state,
1163
+ };
1164
+ this.sendToBrowser(ws, snapshot);
1165
+
1166
+ // Replay message history so the browser can reconstruct the conversation
1167
+ if (session.messageHistory.length > 0) {
1168
+ this.sendToBrowser(ws, {
1169
+ type: "message_history",
1170
+ messages: session.messageHistory,
1171
+ });
1172
+ }
1173
+
1174
+ // Tell this browser its assigned role BEFORE sending permissions,
1175
+ // so the frontend knows whether to enable/disable voting controls.
1176
+ this.sendToBrowser(ws, {
1177
+ type: "role_assigned",
1178
+ role,
1179
+ viewerId,
1180
+ });
1181
+
1182
+ // Send any pending permission requests
1183
+ for (const perm of session.pendingPermissions.values()) {
1184
+ this.sendToBrowser(ws, { type: "permission_request", request: perm });
1185
+ }
1186
+
1187
+ // Notify if backend is not connected and request relaunch.
1188
+ // Treat an attached adapter as "alive" during init — `isConnected()` flips
1189
+ // true only after initialize/thread start, and relaunching during that
1190
+ // window can kill a healthy startup.
1191
+ const backendConnected = session.adapter
1192
+ ? !!session.adapter
1193
+ : !!session.cliSocket;
1194
+
1195
+ if (!backendConnected) {
1196
+ if (this.onCLIRelaunchNeeded) {
1197
+ const accepted = this.onCLIRelaunchNeeded(sessionId);
1198
+ if (accepted !== false) {
1199
+ // Backend is dead but we're about to relaunch — tell browser we're starting
1200
+ this.sendToBrowser(ws, { type: "cli_launching" });
1201
+ console.log(`[ws-bridge] Browser connected but backend is dead for session ${sessionId}, requesting relaunch`);
1202
+ } else {
1203
+ this.sendToBrowser(ws, { type: "cli_disconnected" });
1204
+ console.log(`[ws-bridge] Browser connected but backend is dead for session ${sessionId}; relaunch declined`);
1205
+ }
1206
+ } else {
1207
+ this.sendToBrowser(ws, { type: "cli_disconnected" });
1208
+ }
1209
+ }
1210
+
1211
+ // Broadcast updated presence to all browsers
1212
+ this.broadcastPresence(session);
1213
+ }
1214
+
1215
+ handleBrowserMessage(ws: ServerWebSocket<SocketData>, raw: string | Buffer) {
1216
+ const data = typeof raw === "string" ? raw : raw.toString("utf-8");
1217
+ const sessionId = (ws.data as BrowserSocketData).sessionId;
1218
+ const session = this.sessions.get(sessionId);
1219
+ if (!session) return;
1220
+
1221
+ // Record raw incoming browser message
1222
+ this.recorder?.record(sessionId, "in", data, "browser", session.backendType, session.state.cwd);
1223
+ this.protocolMonitor?.recordMessage(sessionId, "in", "browser", "browser_message", session.backendType);
1224
+
1225
+ let msg: BrowserOutgoingMessage;
1226
+ try {
1227
+ msg = JSON.parse(data);
1228
+ } catch {
1229
+ console.warn(`[ws-bridge] Failed to parse browser message: ${data.substring(0, 200)}`);
1230
+ return;
1231
+ }
1232
+
1233
+ this.routeBrowserMessage(session, msg, ws);
1234
+ }
1235
+
1236
+ /** Send a user message into a session programmatically (no browser required).
1237
+ * Used by the cron scheduler to send prompts to autonomous sessions. */
1238
+ injectUserMessage(sessionId: string, content: string): void {
1239
+ const session = this.sessions.get(sessionId);
1240
+ if (!session) {
1241
+ console.error(`[ws-bridge] Cannot inject message: session ${sessionId} not found`);
1242
+ return;
1243
+ }
1244
+ this.routeBrowserMessage(session, { type: "user_message", content });
1245
+ }
1246
+
1247
+ /** Inject an agent message into a session and broadcast to connected browsers.
1248
+ * Used by the OpenClaw channel plugin to deliver agent responses. */
1249
+ injectAgentMessage(sessionId: string, content: string, _metadata?: Record<string, unknown>): void {
1250
+ const session = this.sessions.get(sessionId);
1251
+ if (!session) {
1252
+ console.error(`[ws-bridge] Cannot inject agent message: session ${sessionId} not found`);
1253
+ return;
1254
+ }
1255
+
1256
+ const messageId = `openclaw-${Date.now()}-${randomUUID().slice(0, 4)}`;
1257
+ this.broadcastToBrowsers(session, {
1258
+ type: "assistant",
1259
+ message: {
1260
+ id: messageId,
1261
+ type: "message",
1262
+ role: "assistant",
1263
+ model: "openclaw",
1264
+ content: [{ type: "text", text: content }],
1265
+ stop_reason: "end_turn",
1266
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1267
+ },
1268
+ parent_tool_use_id: null,
1269
+ });
1270
+ }
1271
+
1272
+ handleBrowserClose(ws: ServerWebSocket<SocketData>) {
1273
+ const sessionId = (ws.data as BrowserSocketData).sessionId;
1274
+ const session = this.sessions.get(sessionId);
1275
+ if (!session) return;
1276
+
1277
+ session.browserSockets.delete(ws);
1278
+ console.log(`[ws-bridge] Browser disconnected for session ${sessionId} (${session.browserSockets.size} browsers)`);
1279
+
1280
+ // Broadcast updated presence to remaining browsers
1281
+ if (session.browserSockets.size > 0) {
1282
+ this.broadcastPresence(session);
1283
+ }
1284
+ }
1285
+
1286
+ // ── CLI message routing ─────────────────────────────────────────────────
1287
+
1288
+ private routeCLIMessage(session: Session, msg: CLIMessage) {
1289
+ switch (msg.type) {
1290
+ case "system":
1291
+ this.handleSystemMessage(session, msg);
1292
+ break;
1293
+
1294
+ case "assistant":
1295
+ this.handleAssistantMessage(session, msg);
1296
+ break;
1297
+
1298
+ case "result":
1299
+ this.handleResultMessage(session, msg);
1300
+ break;
1301
+
1302
+ case "stream_event":
1303
+ this.handleStreamEvent(session, msg);
1304
+ break;
1305
+
1306
+ case "control_request":
1307
+ this.handleControlRequest(session, msg);
1308
+ break;
1309
+
1310
+ case "tool_progress":
1311
+ this.handleToolProgress(session, msg);
1312
+ break;
1313
+
1314
+ case "tool_use_summary":
1315
+ this.handleToolUseSummary(session, msg);
1316
+ break;
1317
+
1318
+ case "auth_status":
1319
+ this.handleAuthStatus(session, msg);
1320
+ break;
1321
+
1322
+ case "control_response":
1323
+ this.handleControlResponse(session, msg);
1324
+ break;
1325
+
1326
+ case "keep_alive":
1327
+ // Silently consume keepalives
1328
+ break;
1329
+
1330
+ default:
1331
+ // Forward unknown messages as-is for debugging
1332
+ break;
1333
+ }
1334
+ }
1335
+
1336
+ private handleSystemMessage(session: Session, msg: CLISystemInitMessage | CLISystemStatusMessage) {
1337
+ if (msg.subtype === "init") {
1338
+ // Keep the launcher-assigned session_id as the canonical ID.
1339
+ // The CLI may report its own internal session_id which differs
1340
+ // from the launcher UUID, causing duplicate entries in the sidebar.
1341
+
1342
+ // Store the CLI's internal session_id so we can --resume on relaunch
1343
+ if (msg.session_id && this.onCLISessionId) {
1344
+ this.onCLISessionId(session.id, msg.session_id);
1345
+ }
1346
+
1347
+ session.state.model = msg.model;
1348
+ session.state.cwd = msg.cwd;
1349
+ session.state.tools = msg.tools;
1350
+ session.state.permissionMode = msg.permissionMode;
1351
+ session.state.claude_code_version = msg.claude_code_version;
1352
+ session.state.mcp_servers = msg.mcp_servers;
1353
+ session.state.agents = msg.agents ?? [];
1354
+ session.state.slash_commands = msg.slash_commands ?? [];
1355
+ session.state.skills = msg.skills ?? [];
1356
+
1357
+ // Resolve and publish git info
1358
+ this.refreshGitInfo(session, { notifyPoller: true });
1359
+
1360
+ this.broadcastToBrowsers(session, {
1361
+ type: "session_init",
1362
+ session: session.state,
1363
+ });
1364
+ this.persistSession(session);
1365
+ this.injectDetectedEnvironmentMcp(session);
1366
+ this.agentMcpBridge?.onSessionReady(session.id, session.backendType, session.state.cwd);
1367
+ } else if (msg.subtype === "status") {
1368
+ session.state.is_compacting = msg.status === "compacting";
1369
+
1370
+ if (msg.permissionMode) {
1371
+ session.state.permissionMode = msg.permissionMode;
1372
+ }
1373
+
1374
+ this.broadcastToBrowsers(session, {
1375
+ type: "status_change",
1376
+ status: msg.status ?? null,
1377
+ });
1378
+ }
1379
+ // Other system subtypes (compact_boundary, task_notification, etc.) can be forwarded as needed
1380
+ }
1381
+
1382
+ private handleAssistantMessage(session: Session, msg: CLIAssistantMessage) {
1383
+ const browserMsg: BrowserIncomingMessage = {
1384
+ type: "assistant",
1385
+ message: msg.message,
1386
+ parent_tool_use_id: msg.parent_tool_use_id,
1387
+ timestamp: Date.now(),
1388
+ };
1389
+ session.messageHistory.push(browserMsg);
1390
+ this.broadcastToBrowsers(session, browserMsg);
1391
+ this.persistSession(session);
1392
+ // CI Layer 1 & 4: extract memory + feed thoughts (non-blocking)
1393
+ if (this.collectiveIntelligence) {
1394
+ this.collectiveIntelligence.processAgentMessage(
1395
+ session.id,
1396
+ session.backendType,
1397
+ browserMsg,
1398
+ { branch: session.state.git_branch ?? "unknown", repoRoot: session.state.repo_root ?? session.state.cwd ?? "" },
1399
+ );
1400
+ }
1401
+ }
1402
+
1403
+ // Regex to extract heredoc content from: cat > file <<'EOF'\n...\nEOF
1404
+ // Matches both <<'EOF' and <<EOF variants
1405
+ private static readonly HEREDOC_RE = /cat\s+>\s+\S+\s+<<'?EOF'?\n([\s\S]*?)\nEOF/g;
1406
+
1407
+ /** Count lines added/removed from a single tool_use block. */
1408
+ private countLinesFromToolBlock(
1409
+ block: { name: string; input: Record<string, unknown> },
1410
+ ): { added: number; removed: number } {
1411
+ const input = block.input;
1412
+
1413
+ if (block.name === "Write" && typeof input.content === "string") {
1414
+ return { added: input.content.split("\n").length, removed: 0 };
1415
+ }
1416
+
1417
+ if (block.name === "Edit" && typeof input.old_string === "string" && typeof input.new_string === "string") {
1418
+ const oldLines = input.old_string.split("\n").length;
1419
+ const newLines = input.new_string.split("\n").length;
1420
+ const diff = newLines - oldLines;
1421
+ return diff > 0 ? { added: diff, removed: 0 } : { added: 0, removed: -diff };
1422
+ }
1423
+
1424
+ if (block.name === "Bash" && typeof input.command === "string") {
1425
+ let addedLines = 0;
1426
+ WsBridge.HEREDOC_RE.lastIndex = 0;
1427
+ let match: RegExpExecArray | null;
1428
+ while ((match = WsBridge.HEREDOC_RE.exec(input.command)) !== null) {
1429
+ addedLines += match[1].split("\n").length;
1430
+ }
1431
+ return { added: addedLines, removed: 0 };
1432
+ }
1433
+
1434
+ return { added: 0, removed: 0 };
1435
+ }
1436
+
1437
+ /**
1438
+ * Compute lines added/removed by scanning tool_use blocks in message history.
1439
+ * Handles Claude (Write/Edit), Codex (Bash heredocs), and other backends.
1440
+ * Used as fallback when git-based line counting isn't available (non-git repos).
1441
+ */
1442
+ private computeLinesFromToolBlocks(session: Session): { added: number; removed: number } {
1443
+ let added = 0;
1444
+ let removed = 0;
1445
+
1446
+ for (const histMsg of session.messageHistory) {
1447
+ if ((histMsg as any).type !== "assistant") continue;
1448
+ const content = (histMsg as any).message?.content;
1449
+ if (!Array.isArray(content)) continue;
1450
+
1451
+ for (const block of content) {
1452
+ if (block.type !== "tool_use") continue;
1453
+ const delta = this.countLinesFromToolBlock(block);
1454
+ added += delta.added;
1455
+ removed += delta.removed;
1456
+ }
1457
+ }
1458
+
1459
+ return { added, removed };
1460
+ }
1461
+
1462
+ /** Update session state fields from a CLI result message (cost, turns, duration, lines). */
1463
+ private applyResultToSessionState(session: Session, msg: CLIResultMessage): void {
1464
+ // Only update cost if the new value is higher — slash commands like /cost
1465
+ // can emit result messages with total_cost_usd: 0, which would erase the
1466
+ // accumulated cost. The CLI's total_cost_usd is cumulative and should only rise.
1467
+ if (typeof msg.total_cost_usd === "number" && msg.total_cost_usd > (session.state.total_cost_usd || 0)) {
1468
+ session.state.total_cost_usd = msg.total_cost_usd;
1469
+ }
1470
+ session.state.num_turns = msg.num_turns;
1471
+
1472
+ // Accumulate API duration across turns
1473
+ if (typeof msg.duration_api_ms === "number" && msg.duration_api_ms > 0) {
1474
+ session.state.total_duration_api_ms =
1475
+ (session.state.total_duration_api_ms || 0) + msg.duration_api_ms;
1476
+ }
1477
+
1478
+ // Update lines changed (CLI may send these in result)
1479
+ if (typeof msg.total_lines_added === "number") {
1480
+ session.state.total_lines_added = msg.total_lines_added;
1481
+ }
1482
+ if (typeof msg.total_lines_removed === "number") {
1483
+ session.state.total_lines_removed = msg.total_lines_removed;
1484
+ }
1485
+ }
1486
+
1487
+ /** Compute context usage from modelUsage and store Claude token details. */
1488
+ private applyModelUsage(session: Session, msg: CLIResultMessage): void {
1489
+ if (!msg.modelUsage) return;
1490
+
1491
+ let totalInput = 0, totalOutput = 0, totalCacheRead = 0, totalCacheCreation = 0;
1492
+ let contextWindow = 0, totalCostUsd = 0;
1493
+ for (const usage of Object.values(msg.modelUsage)) {
1494
+ totalInput += usage.inputTokens;
1495
+ totalOutput += usage.outputTokens;
1496
+ totalCacheRead += usage.cacheReadInputTokens;
1497
+ totalCacheCreation += usage.cacheCreationInputTokens;
1498
+ totalCostUsd += usage.costUSD ?? 0;
1499
+ if (usage.contextWindow > 0) {
1500
+ contextWindow = usage.contextWindow;
1501
+ const pct = Math.round(
1502
+ ((usage.inputTokens + usage.outputTokens) / usage.contextWindow) * 100
1503
+ );
1504
+ session.state.context_used_percent = Math.max(0, Math.min(pct, 100));
1505
+ }
1506
+ }
1507
+ session.state.claude_token_details = {
1508
+ inputTokens: totalInput,
1509
+ outputTokens: totalOutput,
1510
+ cacheReadInputTokens: totalCacheRead,
1511
+ cacheCreationInputTokens: totalCacheCreation,
1512
+ contextWindow,
1513
+ costUsd: totalCostUsd,
1514
+ };
1515
+ }
1516
+
1517
+ /** Emit result-related webhooks (turn.completed, session.completed/failed, cost threshold). */
1518
+ private emitResultWebhooks(session: Session, msg: CLIResultMessage): void {
1519
+ if (!this.webhookManager) return;
1520
+ const webhookData = {
1521
+ backendType: session.backendType,
1522
+ cwd: session.state.cwd,
1523
+ model: session.state.model,
1524
+ totalCostUsd: msg.total_cost_usd,
1525
+ numTurns: msg.num_turns,
1526
+ isError: msg.is_error,
1527
+ };
1528
+ this.webhookManager.emit("turn.completed", session.id, webhookData);
1529
+ if (msg.is_error) {
1530
+ this.webhookManager.emit("session.failed", session.id, webhookData);
1531
+ } else {
1532
+ this.webhookManager.emit("session.completed", session.id, webhookData);
1533
+ }
1534
+ this.webhookManager.checkCostThreshold(session.id, msg.total_cost_usd);
1535
+ }
1536
+
1537
+ private handleResultMessage(session: Session, msg: CLIResultMessage) {
1538
+ this.applyResultToSessionState(session, msg);
1539
+ this.applyModelUsage(session, msg);
1540
+
1541
+ // Re-check git state after each turn (also computes lines added/removed).
1542
+ this.refreshGitInfo(session, { broadcastUpdate: true, notifyPoller: true });
1543
+
1544
+ // Fallback: if git didn't produce line counts (non-git repo or no commits),
1545
+ // compute from Edit/Write tool blocks in message history.
1546
+ if (!session.state.total_lines_added && !session.state.total_lines_removed) {
1547
+ const { added, removed } = this.computeLinesFromToolBlocks(session);
1548
+ if (added > 0 || removed > 0) {
1549
+ session.state.total_lines_added = added;
1550
+ session.state.total_lines_removed = removed;
1551
+ }
1552
+ }
1553
+
1554
+ // Always push updated stats to browsers (duration, lines may not trigger git change detection)
1555
+ this.broadcastToBrowsers(session, {
1556
+ type: "session_update",
1557
+ session: {
1558
+ total_duration_api_ms: session.state.total_duration_api_ms,
1559
+ total_lines_added: session.state.total_lines_added,
1560
+ total_lines_removed: session.state.total_lines_removed,
1561
+ },
1562
+ });
1563
+
1564
+ const browserMsg: BrowserIncomingMessage = {
1565
+ type: "result",
1566
+ data: msg,
1567
+ };
1568
+ session.messageHistory.push(browserMsg);
1569
+ this.broadcastToBrowsers(session, browserMsg);
1570
+ this.persistSession(session);
1571
+
1572
+ this.emitResultWebhooks(session, msg);
1573
+
1574
+ // Trigger auto-naming after the first successful result
1575
+ this.tryAutoNaming(session, browserMsg);
1576
+ }
1577
+
1578
+ private handleStreamEvent(session: Session, msg: CLIStreamEventMessage) {
1579
+ this.broadcastToBrowsers(session, {
1580
+ type: "stream_event",
1581
+ event: msg.event,
1582
+ parent_tool_use_id: msg.parent_tool_use_id,
1583
+ });
1584
+ }
1585
+
1586
+ private handleControlRequest(session: Session, msg: CLIControlRequestMessage) {
1587
+ if (msg.request.subtype === "can_use_tool") {
1588
+ if (this.agentMcpBridge?.handlePermissionRequest?.(session.id, msg)) {
1589
+ return;
1590
+ }
1591
+ const perm: PermissionRequest = {
1592
+ request_id: msg.request_id,
1593
+ tool_name: msg.request.tool_name,
1594
+ input: msg.request.input,
1595
+ permission_suggestions: msg.request.permission_suggestions,
1596
+ description: msg.request.description,
1597
+ tool_use_id: msg.request.tool_use_id,
1598
+ agent_id: msg.request.agent_id,
1599
+ timestamp: Date.now(),
1600
+ };
1601
+ session.pendingPermissions.set(msg.request_id, perm);
1602
+
1603
+ this.broadcastToBrowsers(session, {
1604
+ type: "permission_request",
1605
+ request: perm,
1606
+ });
1607
+ this.persistSession(session);
1608
+
1609
+ // Emit webhook: permission.requested
1610
+ this.webhookManager?.emit("permission.requested", session.id, {
1611
+ backendType: session.backendType,
1612
+ toolName: perm.tool_name,
1613
+ requestId: perm.request_id,
1614
+ });
1615
+ }
1616
+ }
1617
+
1618
+ private handleToolProgress(session: Session, msg: CLIToolProgressMessage) {
1619
+ this.broadcastToBrowsers(session, {
1620
+ type: "tool_progress",
1621
+ tool_use_id: msg.tool_use_id,
1622
+ tool_name: msg.tool_name,
1623
+ elapsed_time_seconds: msg.elapsed_time_seconds,
1624
+ });
1625
+ }
1626
+
1627
+ private handleToolUseSummary(session: Session, msg: CLIToolUseSummaryMessage) {
1628
+ this.broadcastToBrowsers(session, {
1629
+ type: "tool_use_summary",
1630
+ summary: msg.summary,
1631
+ tool_use_ids: msg.preceding_tool_use_ids,
1632
+ });
1633
+ }
1634
+
1635
+ private handleAuthStatus(session: Session, msg: CLIAuthStatusMessage) {
1636
+ this.broadcastToBrowsers(session, {
1637
+ type: "auth_status",
1638
+ isAuthenticating: msg.isAuthenticating,
1639
+ output: msg.output,
1640
+ error: msg.error,
1641
+ });
1642
+ }
1643
+
1644
+ // ── Browser message routing ─────────────────────────────────────────────
1645
+
1646
+ /** Message types that spectators are NOT allowed to send. */
1647
+ private static readonly SPECTATOR_BLOCKED_TYPES = new Set<string>([
1648
+ "user_message",
1649
+ "permission_response",
1650
+ "interrupt",
1651
+ "set_model",
1652
+ "set_permission_mode",
1653
+ "mcp_toggle",
1654
+ "mcp_reconnect",
1655
+ "mcp_set_servers",
1656
+ // CI actions — spectators can observe but not mutate
1657
+ "memory_store",
1658
+ "deliberation_respond",
1659
+ "deliberation_resolve",
1660
+ "route_task",
1661
+ "inject_thought",
1662
+ ]);
1663
+
1664
+ private static readonly CI_MESSAGE_TYPES = new Set([
1665
+ "memory_query", "memory_store", "deliberation_respond",
1666
+ "deliberation_resolve", "route_task", "inject_thought",
1667
+ "capability_probe_response",
1668
+ ]);
1669
+
1670
+ /** Intercept CI-specific messages. Returns true if the message was consumed by CI. */
1671
+ private interceptCIMessage(session: Session, msg: BrowserOutgoingMessage): boolean {
1672
+ if (!this.collectiveIntelligence) return false;
1673
+ if (!WsBridge.CI_MESSAGE_TYPES.has(msg.type)) return false;
1674
+
1675
+ // These are fully consumed by CI — don't forward to agent
1676
+ this.collectiveIntelligence.processBrowserMessage(session.id, msg).catch((err) => {
1677
+ console.warn("[ws-bridge] CI processBrowserMessage error:", err);
1678
+ });
1679
+ return true;
1680
+ }
1681
+
1682
+ /** Route a browser message to an adapter-based backend (Codex, Goose, etc.). */
1683
+ private routeToAdapter(
1684
+ session: Session,
1685
+ msg: BrowserOutgoingMessage,
1686
+ ws?: ServerWebSocket<SocketData>,
1687
+ ): void {
1688
+ // Store user messages in history for replay with stable ID for dedup on reconnect
1689
+ if (msg.type === "user_message") {
1690
+ const ts = Date.now();
1691
+ session.messageHistory.push({
1692
+ type: "user_message",
1693
+ content: msg.content,
1694
+ timestamp: ts,
1695
+ id: `user-${ts}-${this.userMsgCounter++}`,
1696
+ });
1697
+ this.persistSession(session);
1698
+ }
1699
+
1700
+ // Permission responses go through voting system for multi-viewer sessions
1701
+ let outboundMsg = msg;
1702
+ if (msg.type === "permission_response") {
1703
+ const eligibleVoters = this.countEligibleVoters(session);
1704
+ if (eligibleVoters > 1) {
1705
+ this.recordVote(session, msg.request_id, ws, msg.behavior, msg);
1706
+ return;
1707
+ }
1708
+ const pending = session.pendingPermissions.get(msg.request_id);
1709
+ if (session.backendType === "claude" && msg.behavior === "allow" && !msg.updated_input && pending) {
1710
+ outboundMsg = { ...msg, updated_input: pending.input };
1711
+ }
1712
+ session.pendingPermissions.delete(msg.request_id);
1713
+ this.persistSession(session);
1714
+ }
1715
+
1716
+ if (session.adapter) {
1717
+ session.adapter.sendBrowserMessage(outboundMsg);
1718
+ } else {
1719
+ // Adapter not yet attached — queue for when it's ready.
1720
+ console.log(`[ws-bridge] ${session.backendType} adapter not yet attached for session ${session.id}, queuing ${msg.type}`);
1721
+ session.pendingMessages.push(JSON.stringify(outboundMsg));
1722
+ }
1723
+ }
1724
+
1725
+ /** Route a browser message to the Claude Code CLI backend. */
1726
+ private routeToClaude(
1727
+ session: Session,
1728
+ msg: BrowserOutgoingMessage,
1729
+ ws?: ServerWebSocket<SocketData>,
1730
+ ): void {
1731
+ switch (msg.type) {
1732
+ case "user_message":
1733
+ this.handleUserMessage(session, msg);
1734
+ break;
1735
+ case "permission_response":
1736
+ this.handlePermissionResponse(session, msg, ws);
1737
+ break;
1738
+ case "interrupt":
1739
+ this.handleInterrupt(session);
1740
+ break;
1741
+ case "set_model":
1742
+ this.handleSetModel(session, msg.model);
1743
+ break;
1744
+ case "set_permission_mode":
1745
+ this.handleSetPermissionMode(session, msg.mode);
1746
+ break;
1747
+ case "mcp_get_status":
1748
+ this.handleMcpGetStatus(session);
1749
+ break;
1750
+ case "mcp_toggle":
1751
+ this.handleMcpToggle(session, msg.serverName, msg.enabled);
1752
+ break;
1753
+ case "mcp_reconnect":
1754
+ this.handleMcpReconnect(session, msg.serverName);
1755
+ break;
1756
+ case "mcp_set_servers":
1757
+ this.handleMcpSetServers(session, msg.servers);
1758
+ break;
1759
+ }
1760
+ }
1761
+
1762
+ private routeBrowserMessage(
1763
+ session: Session,
1764
+ msg: BrowserOutgoingMessage,
1765
+ ws?: ServerWebSocket<SocketData>,
1766
+ ) {
1767
+ if (msg.type === "session_subscribe") {
1768
+ this.handleSessionSubscribe(session, ws, msg.last_seq);
1769
+ return;
1770
+ }
1771
+
1772
+ if (msg.type === "session_ack") {
1773
+ this.handleSessionAck(session, ws, msg.last_seq);
1774
+ return;
1775
+ }
1776
+
1777
+ if (this.interceptCIMessage(session, msg)) return;
1778
+
1779
+ // RBAC enforcement: spectators can only subscribe/ack and read MCP status
1780
+ if (ws) {
1781
+ const browserData = ws.data as BrowserSocketData;
1782
+ if (browserData.role === "spectator" && WsBridge.SPECTATOR_BLOCKED_TYPES.has(msg.type)) {
1783
+ this.sendToBrowser(ws, {
1784
+ type: "error",
1785
+ message: "Spectators cannot perform this action",
1786
+ });
1787
+ return;
1788
+ }
1789
+ }
1790
+
1791
+ if (
1792
+ WsBridge.IDEMPOTENT_BROWSER_MESSAGE_TYPES.has(msg.type)
1793
+ && "client_msg_id" in msg
1794
+ && msg.client_msg_id
1795
+ ) {
1796
+ if (this.isDuplicateClientMessage(session, msg.client_msg_id)) {
1797
+ return;
1798
+ }
1799
+ this.rememberClientMessage(session, msg.client_msg_id);
1800
+ }
1801
+
1802
+ if (session.adapter) {
1803
+ this.routeToAdapter(session, msg, ws);
1804
+ } else if (session.backendType === "claude") {
1805
+ this.routeToClaude(session, msg, ws);
1806
+ } else {
1807
+ this.routeToAdapter(session, msg, ws);
1808
+ }
1809
+ }
1810
+
1811
+ private isDuplicateClientMessage(session: Session, clientMsgId: string): boolean {
1812
+ return session.processedClientMessageIdSet.has(clientMsgId);
1813
+ }
1814
+
1815
+ private rememberClientMessage(session: Session, clientMsgId: string): void {
1816
+ session.processedClientMessageIds.push(clientMsgId);
1817
+ session.processedClientMessageIdSet.add(clientMsgId);
1818
+ if (session.processedClientMessageIds.length > WsBridge.PROCESSED_CLIENT_MSG_ID_LIMIT) {
1819
+ const overflow = session.processedClientMessageIds.length - WsBridge.PROCESSED_CLIENT_MSG_ID_LIMIT;
1820
+ const removed = session.processedClientMessageIds.splice(0, overflow);
1821
+ for (const id of removed) {
1822
+ session.processedClientMessageIdSet.delete(id);
1823
+ }
1824
+ }
1825
+ this.persistSession(session);
1826
+ }
1827
+
1828
+ private handleSessionSubscribe(
1829
+ session: Session,
1830
+ ws: ServerWebSocket<SocketData> | undefined,
1831
+ lastSeq: number,
1832
+ ) {
1833
+ if (!ws) return;
1834
+ const data = ws.data as BrowserSocketData;
1835
+ data.subscribed = true;
1836
+ const lastAckSeq = Number.isFinite(lastSeq) ? Math.max(0, Math.floor(lastSeq)) : 0;
1837
+ data.lastAckSeq = lastAckSeq;
1838
+
1839
+ if (session.eventBuffer.length === 0) return;
1840
+ if (lastAckSeq >= session.nextEventSeq - 1) return;
1841
+
1842
+ const earliest = session.eventBuffer[0]?.seq ?? session.nextEventSeq;
1843
+ const hasGap = lastAckSeq > 0 && lastAckSeq < earliest - 1;
1844
+ if (hasGap) {
1845
+ this.sendToBrowser(ws, {
1846
+ type: "message_history",
1847
+ messages: session.messageHistory,
1848
+ });
1849
+ const transientMissed = session.eventBuffer
1850
+ .filter((evt) => evt.seq > lastAckSeq && !this.isHistoryBackedEvent(evt.message));
1851
+ if (transientMissed.length > 0) {
1852
+ this.sendToBrowser(ws, {
1853
+ type: "event_replay",
1854
+ events: transientMissed,
1855
+ });
1856
+ }
1857
+ return;
1858
+ }
1859
+
1860
+ const missed = session.eventBuffer.filter((evt) => evt.seq > lastAckSeq);
1861
+ if (missed.length === 0) return;
1862
+ this.sendToBrowser(ws, {
1863
+ type: "event_replay",
1864
+ events: missed,
1865
+ });
1866
+ }
1867
+
1868
+ private handleSessionAck(
1869
+ session: Session,
1870
+ ws: ServerWebSocket<SocketData> | undefined,
1871
+ lastSeq: number,
1872
+ ) {
1873
+ const normalized = Number.isFinite(lastSeq) ? Math.max(0, Math.floor(lastSeq)) : 0;
1874
+ if (ws) {
1875
+ const data = ws.data as BrowserSocketData;
1876
+ const prior = typeof data.lastAckSeq === "number" ? data.lastAckSeq : 0;
1877
+ data.lastAckSeq = Math.max(prior, normalized);
1878
+ }
1879
+ if (normalized > session.lastAckSeq) {
1880
+ session.lastAckSeq = normalized;
1881
+ this.persistSession(session);
1882
+ }
1883
+ }
1884
+
1885
+ private handleUserMessage(
1886
+ session: Session,
1887
+ msg: { type: "user_message"; content: string; session_id?: string; images?: { media_type: string; data: string }[] }
1888
+ ) {
1889
+ // Store user message in history for replay with stable ID for dedup on reconnect
1890
+ const ts = Date.now();
1891
+ session.messageHistory.push({
1892
+ type: "user_message",
1893
+ content: msg.content,
1894
+ timestamp: ts,
1895
+ id: `user-${ts}-${this.userMsgCounter++}`,
1896
+ });
1897
+
1898
+ // Build content: if images are present, use content block array; otherwise plain string
1899
+ let content: string | unknown[];
1900
+ if (msg.images?.length) {
1901
+ const blocks: unknown[] = [];
1902
+ for (const img of msg.images) {
1903
+ blocks.push({
1904
+ type: "image",
1905
+ source: { type: "base64", media_type: img.media_type, data: img.data },
1906
+ });
1907
+ }
1908
+ blocks.push({ type: "text", text: msg.content });
1909
+ content = blocks;
1910
+ } else {
1911
+ content = msg.content;
1912
+ }
1913
+
1914
+ const ndjson = JSON.stringify({
1915
+ type: "user",
1916
+ message: { role: "user", content },
1917
+ parent_tool_use_id: null,
1918
+ session_id: msg.session_id || session.state.session_id || "",
1919
+ });
1920
+ this.sendToCLI(session, ndjson);
1921
+ this.persistSession(session);
1922
+ }
1923
+
1924
+ private handlePermissionResponse(
1925
+ session: Session,
1926
+ msg: { type: "permission_response"; request_id: string; behavior: "allow" | "deny"; updated_input?: Record<string, unknown>; updated_permissions?: unknown[]; message?: string },
1927
+ ws?: ServerWebSocket<SocketData>,
1928
+ ) {
1929
+ // Count eligible voters (owner + collaborators, not spectators)
1930
+ const eligibleVoters = this.countEligibleVoters(session);
1931
+
1932
+ // Single voter or owner-decides policy — resolve immediately
1933
+ if (eligibleVoters <= 1 || this.votingPolicy === "owner-decides") {
1934
+ // For owner-decides, only the owner's vote counts
1935
+ if (this.votingPolicy === "owner-decides" && ws) {
1936
+ const browserData = ws.data as BrowserSocketData;
1937
+ if (browserData.role !== "owner") {
1938
+ // Record vote but don't resolve — wait for owner
1939
+ this.recordVote(session, msg.request_id, ws, msg.behavior, msg);
1940
+ return;
1941
+ }
1942
+ }
1943
+ this.resolvePermission(session, msg.request_id, msg.behavior, msg);
1944
+ return;
1945
+ }
1946
+
1947
+ // Multi-voter: collect votes
1948
+ this.recordVote(session, msg.request_id, ws, msg.behavior, msg);
1949
+ }
1950
+
1951
+ /** Count browsers with voting rights (owner + collaborators). */
1952
+ private countEligibleVoters(session: Session): number {
1953
+ let count = 0;
1954
+ for (const ws of session.browserSockets) {
1955
+ const data = ws.data as BrowserSocketData;
1956
+ if (data.role === "owner" || data.role === "collaborator") {
1957
+ count++;
1958
+ }
1959
+ }
1960
+ return count;
1961
+ }
1962
+
1963
+ /** Record a vote for a permission request and check if the vote can be resolved. */
1964
+ private recordVote(
1965
+ session: Session,
1966
+ requestId: string,
1967
+ ws: ServerWebSocket<SocketData> | undefined,
1968
+ behavior: "allow" | "deny",
1969
+ templateMsg: { updated_input?: Record<string, unknown>; updated_permissions?: unknown[]; message?: string },
1970
+ ): void {
1971
+ const viewerId = ws ? (ws.data as BrowserSocketData).viewerId || "unknown" : "system";
1972
+ const viewerName = ws ? (ws.data as BrowserSocketData).viewerName || "Unknown" : "System";
1973
+
1974
+ let collection = session.pendingVotes.get(requestId);
1975
+ if (!collection) {
1976
+ const deadline = Date.now() + WsBridge.VOTE_DEADLINE_MS;
1977
+ collection = {
1978
+ requestId,
1979
+ votes: new Map(),
1980
+ deadline,
1981
+ timer: setTimeout(() => this.resolveVoteByDeadline(session, requestId), WsBridge.VOTE_DEADLINE_MS),
1982
+ templateMsg,
1983
+ };
1984
+ session.pendingVotes.set(requestId, collection);
1985
+ }
1986
+
1987
+ collection.votes.set(viewerId, {
1988
+ viewerId,
1989
+ viewerName,
1990
+ vote: behavior,
1991
+ timestamp: Date.now(),
1992
+ });
1993
+
1994
+ const eligibleVoters = this.countEligibleVoters(session);
1995
+
1996
+ // Broadcast vote update to all browsers
1997
+ this.broadcastToBrowsers(session, {
1998
+ type: "vote_update",
1999
+ request_id: requestId,
2000
+ votes: Array.from(collection.votes.values()),
2001
+ voters_total: eligibleVoters,
2002
+ deadline: collection.deadline,
2003
+ });
2004
+
2005
+ // Check for early resolution
2006
+ this.checkVoteResolution(session, requestId);
2007
+ }
2008
+
2009
+ /** Check if all votes are in or if the policy allows early resolution. */
2010
+ private checkVoteResolution(session: Session, requestId: string): void {
2011
+ const collection = session.pendingVotes.get(requestId);
2012
+ if (!collection) return;
2013
+
2014
+ const eligibleVoters = this.countEligibleVoters(session);
2015
+ const votes = Array.from(collection.votes.values());
2016
+ const allowCount = votes.filter((v) => v.vote === "allow").length;
2017
+ const denyCount = votes.filter((v) => v.vote === "deny").length;
2018
+
2019
+ let resolved: "allow" | "deny" | null = null;
2020
+
2021
+ switch (this.votingPolicy) {
2022
+ case "any-deny-blocks":
2023
+ if (denyCount > 0) resolved = "deny";
2024
+ else if (allowCount >= eligibleVoters) resolved = "allow";
2025
+ break;
2026
+
2027
+ case "majority-rules":
2028
+ // Early resolution if majority is already decided
2029
+ if (allowCount > eligibleVoters / 2) resolved = "allow";
2030
+ else if (denyCount > eligibleVoters / 2) resolved = "deny";
2031
+ // Also resolve if all votes are in
2032
+ else if (votes.length >= eligibleVoters) {
2033
+ resolved = allowCount >= denyCount ? "allow" : "deny";
2034
+ }
2035
+ break;
2036
+
2037
+ case "owner-decides":
2038
+ // Should not reach here — handled in handlePermissionResponse
2039
+ break;
2040
+ }
2041
+
2042
+ if (resolved) {
2043
+ clearTimeout(collection.timer);
2044
+ session.pendingVotes.delete(requestId);
2045
+ this.broadcastToBrowsers(session, {
2046
+ type: "vote_resolved",
2047
+ request_id: requestId,
2048
+ result: resolved,
2049
+ policy: this.votingPolicy,
2050
+ });
2051
+ this.resolvePermission(session, requestId, resolved, collection.templateMsg);
2052
+ }
2053
+ }
2054
+
2055
+ /** Called when the voting deadline expires — resolve with votes cast so far. */
2056
+ private resolveVoteByDeadline(session: Session, requestId: string): void {
2057
+ const collection = session.pendingVotes.get(requestId);
2058
+ if (!collection) return;
2059
+
2060
+ session.pendingVotes.delete(requestId);
2061
+ const votes = Array.from(collection.votes.values());
2062
+ const allowCount = votes.filter((v) => v.vote === "allow").length;
2063
+ const denyCount = votes.filter((v) => v.vote === "deny").length;
2064
+
2065
+ let result: "allow" | "deny";
2066
+ switch (this.votingPolicy) {
2067
+ case "any-deny-blocks":
2068
+ result = denyCount > 0 ? "deny" : "allow";
2069
+ break;
2070
+ case "majority-rules":
2071
+ default:
2072
+ result = allowCount >= denyCount ? "allow" : "deny";
2073
+ break;
2074
+ }
2075
+
2076
+ this.broadcastToBrowsers(session, {
2077
+ type: "vote_resolved",
2078
+ request_id: requestId,
2079
+ result,
2080
+ policy: this.votingPolicy,
2081
+ });
2082
+ this.resolvePermission(session, requestId, result, collection.templateMsg);
2083
+ }
2084
+
2085
+ /** Send the final permission response to the backend (CLI or adapter). */
2086
+ private resolvePermission(
2087
+ session: Session,
2088
+ requestId: string,
2089
+ behavior: "allow" | "deny",
2090
+ msg: { updated_input?: Record<string, unknown>; updated_permissions?: unknown[]; message?: string },
2091
+ ): void {
2092
+ const pending = session.pendingPermissions.get(requestId);
2093
+ session.pendingPermissions.delete(requestId);
2094
+ this.persistSession(session);
2095
+
2096
+ // Emit webhook: permission.resolved
2097
+ this.webhookManager?.emit("permission.resolved", session.id, {
2098
+ backendType: session.backendType,
2099
+ requestId,
2100
+ behavior,
2101
+ toolName: pending?.tool_name,
2102
+ });
2103
+
2104
+ // For adapter-based sessions, send via adapter
2105
+ if (session.adapter) {
2106
+ session.adapter.sendBrowserMessage({
2107
+ type: "permission_response",
2108
+ request_id: requestId,
2109
+ behavior,
2110
+ updated_input: msg.updated_input,
2111
+ updated_permissions: msg.updated_permissions as import("./session-types.js").PermissionUpdate[] | undefined,
2112
+ message: msg.message,
2113
+ });
2114
+ return;
2115
+ }
2116
+
2117
+ // Claude Code path — send NDJSON to CLI
2118
+ if (behavior === "allow") {
2119
+ const response: Record<string, unknown> = {
2120
+ behavior: "allow",
2121
+ updatedInput: msg.updated_input ?? pending?.input ?? {},
2122
+ };
2123
+ if (msg.updated_permissions?.length) {
2124
+ response.updatedPermissions = msg.updated_permissions;
2125
+ }
2126
+ this.sendToCLI(session, JSON.stringify({
2127
+ type: "control_response",
2128
+ response: {
2129
+ subtype: "success",
2130
+ request_id: requestId,
2131
+ response,
2132
+ },
2133
+ }));
2134
+ } else {
2135
+ this.sendToCLI(session, JSON.stringify({
2136
+ type: "control_response",
2137
+ response: {
2138
+ subtype: "success",
2139
+ request_id: requestId,
2140
+ response: {
2141
+ behavior: "deny",
2142
+ message: msg.message || "Denied by vote",
2143
+ },
2144
+ },
2145
+ }));
2146
+ }
2147
+ }
2148
+
2149
+ private handleInterrupt(session: Session) {
2150
+ const ndjson = JSON.stringify({
2151
+ type: "control_request",
2152
+ request_id: randomUUID(),
2153
+ request: { subtype: "interrupt" },
2154
+ });
2155
+ this.sendToCLI(session, ndjson);
2156
+ }
2157
+
2158
+ private handleSetModel(session: Session, model: string) {
2159
+ const ndjson = JSON.stringify({
2160
+ type: "control_request",
2161
+ request_id: randomUUID(),
2162
+ request: { subtype: "set_model", model },
2163
+ });
2164
+ this.sendToCLI(session, ndjson);
2165
+ }
2166
+
2167
+ private handleSetPermissionMode(session: Session, mode: string) {
2168
+ const ndjson = JSON.stringify({
2169
+ type: "control_request",
2170
+ request_id: randomUUID(),
2171
+ request: { subtype: "set_permission_mode", mode },
2172
+ });
2173
+ this.sendToCLI(session, ndjson);
2174
+ }
2175
+
2176
+ // ── Control response handling ─────────────────────────────────────────
2177
+
2178
+ private handleControlResponse(
2179
+ session: Session,
2180
+ msg: CLIControlResponseMessage,
2181
+ ) {
2182
+ const reqId = msg.response.request_id;
2183
+ const pending = session.pendingControlRequests.get(reqId);
2184
+ if (!pending) return; // Not a request we're tracking
2185
+ session.pendingControlRequests.delete(reqId);
2186
+
2187
+ if (msg.response.subtype === "error") {
2188
+ console.warn(`[ws-bridge] Control request ${pending.subtype} failed: ${msg.response.error}`);
2189
+ return;
2190
+ }
2191
+
2192
+ pending.resolve(msg.response.response ?? {});
2193
+ }
2194
+
2195
+ // ── MCP control messages ──────────────────────────────────────────────
2196
+
2197
+ /** Send a control_request to CLI, optionally tracking the response via a callback. */
2198
+ private sendControlRequest(
2199
+ session: Session,
2200
+ request: Record<string, unknown>,
2201
+ onResponse?: PendingControlRequest,
2202
+ ) {
2203
+ const requestId = randomUUID();
2204
+ if (onResponse) {
2205
+ session.pendingControlRequests.set(requestId, onResponse);
2206
+ }
2207
+ this.sendToCLI(session, JSON.stringify({
2208
+ type: "control_request",
2209
+ request_id: requestId,
2210
+ request,
2211
+ }));
2212
+ }
2213
+
2214
+ private handleMcpGetStatus(session: Session) {
2215
+ this.sendControlRequest(session, { subtype: "mcp_status" }, {
2216
+ subtype: "mcp_status",
2217
+ resolve: (response) => {
2218
+ const servers = (response as { mcpServers?: McpServerDetail[] }).mcpServers ?? [];
2219
+ this.broadcastToBrowsers(session, { type: "mcp_status", servers });
2220
+ },
2221
+ });
2222
+ }
2223
+
2224
+ private handleMcpToggle(session: Session, serverName: string, enabled: boolean) {
2225
+ this.sendControlRequest(session, { subtype: "mcp_toggle", serverName, enabled });
2226
+ setTimeout(() => this.handleMcpGetStatus(session), 500);
2227
+ }
2228
+
2229
+ private handleMcpReconnect(session: Session, serverName: string) {
2230
+ this.sendControlRequest(session, { subtype: "mcp_reconnect", serverName });
2231
+ setTimeout(() => this.handleMcpGetStatus(session), 1000);
2232
+ }
2233
+
2234
+ private handleMcpSetServers(session: Session, servers: Record<string, McpServerConfig>) {
2235
+ this.sendControlRequest(session, { subtype: "mcp_set_servers", servers });
2236
+ setTimeout(() => this.handleMcpGetStatus(session), 2000);
2237
+ }
2238
+
2239
+ // ── Transport helpers ───────────────────────────────────────────────────
2240
+
2241
+ private sendToCLI(session: Session, ndjson: string) {
2242
+ if (!session.cliSocket) {
2243
+ // Queue the message — CLI might still be starting up.
2244
+ // Don't record here; the message will be recorded when flushed.
2245
+ console.log(`[ws-bridge] CLI not yet connected for session ${session.id}, queuing message`);
2246
+ session.pendingMessages.push(ndjson);
2247
+ return;
2248
+ }
2249
+ // Record raw outgoing CLI message (only when actually sending, not when queuing)
2250
+ this.recorder?.record(session.id, "out", ndjson, "cli", session.backendType, session.state.cwd);
2251
+ try {
2252
+ // NDJSON requires a newline delimiter
2253
+ session.cliSocket.send(ndjson + "\n");
2254
+ } catch (err) {
2255
+ console.error(`[ws-bridge] Failed to send to CLI for session ${session.id}:`, err);
2256
+ }
2257
+ }
2258
+
2259
+ /** Push a session name update to all connected browsers for a session. */
2260
+ broadcastNameUpdate(sessionId: string, name: string): void {
2261
+ const session = this.sessions.get(sessionId);
2262
+ if (!session) return;
2263
+ this.broadcastToBrowsers(session, { type: "session_name_update", name });
2264
+ }
2265
+
2266
+ private shouldBufferForReplay(msg: BrowserIncomingMessage): msg is ReplayableBrowserIncomingMessage {
2267
+ return msg.type !== "session_init"
2268
+ && msg.type !== "message_history"
2269
+ && msg.type !== "event_replay";
2270
+ }
2271
+
2272
+ private isHistoryBackedEvent(msg: ReplayableBrowserIncomingMessage): boolean {
2273
+ return msg.type === "assistant"
2274
+ || msg.type === "result"
2275
+ || msg.type === "user_message"
2276
+ || msg.type === "error";
2277
+ }
2278
+
2279
+ private sequenceEvent(
2280
+ session: Session,
2281
+ msg: BrowserIncomingMessage,
2282
+ ): BrowserIncomingMessage {
2283
+ const seq = session.nextEventSeq++;
2284
+ const sequenced = { ...msg, seq };
2285
+ if (this.shouldBufferForReplay(msg)) {
2286
+ session.eventBuffer.push({ seq, message: msg });
2287
+ if (session.eventBuffer.length > WsBridge.EVENT_BUFFER_LIMIT) {
2288
+ session.eventBuffer.splice(0, session.eventBuffer.length - WsBridge.EVENT_BUFFER_LIMIT);
2289
+ }
2290
+ this.persistSession(session);
2291
+ }
2292
+ return sequenced;
2293
+ }
2294
+
2295
+ private broadcastToBrowsers(session: Session, msg: BrowserIncomingMessage) {
2296
+ // Debug: warn when assistant messages are broadcast to 0 browsers (they may be lost)
2297
+ if (session.browserSockets.size === 0 && (msg.type === "assistant" || msg.type === "stream_event" || msg.type === "result")) {
2298
+ console.log(`[ws-bridge] ⚠ Broadcasting ${msg.type} to 0 browsers for session ${session.id} (stored in history: ${msg.type === "assistant" || msg.type === "result"})`);
2299
+ }
2300
+ const json = JSON.stringify(this.sequenceEvent(session, msg));
2301
+
2302
+ // Record raw outgoing browser message
2303
+ this.recorder?.record(session.id, "out", json, "browser", session.backendType, session.state.cwd);
2304
+ this.protocolMonitor?.recordMessage(session.id, "out", "browser", msg.type, session.backendType);
2305
+
2306
+ for (const ws of session.browserSockets) {
2307
+ try {
2308
+ ws.send(json);
2309
+ } catch {
2310
+ session.browserSockets.delete(ws);
2311
+ }
2312
+ }
2313
+ }
2314
+
2315
+ private sendToBrowser(ws: ServerWebSocket<SocketData>, msg: BrowserIncomingMessage) {
2316
+ try {
2317
+ ws.send(JSON.stringify(msg));
2318
+ } catch {
2319
+ // Socket will be cleaned up on close
2320
+ }
2321
+ }
2322
+ }