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.
- package/bin/cli.ts +182 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
- package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
- package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
- package/dist/assets/arc-D9SEA8dX.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
- package/dist/assets/channel-CxhcHn7S.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
- package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
- package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
- package/dist/assets/clone-C6kt1rE7.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
- package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
- package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
- package/dist/assets/graph-DKRbrxcs.js +1 -0
- package/dist/assets/index-Byjg9zgI.css +32 -0
- package/dist/assets/index-Cwj3m0hT.js +284 -0
- package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
- package/dist/assets/katex-BlHpptmG.js +261 -0
- package/dist/assets/layout-DnxSwLDJ.js +1 -0
- package/dist/assets/linear-CWbbIT8l.js +1 -0
- package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
- package/dist/assets/ordinal-BENe2yWM.js +1 -0
- package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
- package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
- package/dist/favicon.svg +3 -0
- package/dist/fonts/Geist-Variable.woff2 +0 -0
- package/dist/fonts/GeistMono-Variable.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +33 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo.svg +3 -0
- package/dist/manifest.json +26 -0
- package/dist/og-image.png +0 -0
- package/dist/sw.js +130 -0
- package/package.json +92 -0
- package/server/ADAPTERS.md +121 -0
- package/server/adapter-registry-types.ts +28 -0
- package/server/adapter-registry.ts +293 -0
- package/server/adapter-types.ts +51 -0
- package/server/agent-executor.ts +249 -0
- package/server/agent-mcp-bridge.ts +148 -0
- package/server/agent-mcp-stdio.ts +171 -0
- package/server/agent-mcp-tools.ts +76 -0
- package/server/agent-store.ts +183 -0
- package/server/agent-types.ts +79 -0
- package/server/aider-adapter.ts +525 -0
- package/server/auth.ts +153 -0
- package/server/auto-namer.ts +132 -0
- package/server/capability-discovery.ts +431 -0
- package/server/claude-container-auth.ts +40 -0
- package/server/claude-stdio-adapter.ts +452 -0
- package/server/clawhub-export.ts +218 -0
- package/server/cli-launcher.ts +1613 -0
- package/server/codex-adapter.ts +1987 -0
- package/server/codex-container-auth.ts +40 -0
- package/server/codex-home.ts +26 -0
- package/server/collective-intelligence.ts +401 -0
- package/server/commands-discovery.ts +160 -0
- package/server/constants.ts +5 -0
- package/server/container-manager.ts +328 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +149 -0
- package/server/cron-types.ts +63 -0
- package/server/deliberation-engine.ts +323 -0
- package/server/embedding.ts +89 -0
- package/server/env-manager.ts +146 -0
- package/server/environment-detector.ts +106 -0
- package/server/environment-rules.ts +106 -0
- package/server/gallery-store.ts +216 -0
- package/server/gallery-types.ts +52 -0
- package/server/gallery-votes.ts +101 -0
- package/server/git-utils.ts +384 -0
- package/server/github-pr.ts +379 -0
- package/server/goose-adapter.ts +929 -0
- package/server/image-pull-manager.ts +144 -0
- package/server/index.ts +363 -0
- package/server/linear-project-manager.ts +78 -0
- package/server/moltbook-client.ts +160 -0
- package/server/openclaw-adapter.ts +916 -0
- package/server/openclaw-channel/channel.ts +188 -0
- package/server/openclaw-channel/index.ts +68 -0
- package/server/openclaw-channel/package.json +19 -0
- package/server/openclaw-channel/types.ts +93 -0
- package/server/opencode-adapter.ts +951 -0
- package/server/openhands-adapter.ts +775 -0
- package/server/orchestrator-executor.ts +184 -0
- package/server/orchestrator-store.ts +106 -0
- package/server/orchestrator-types.ts +86 -0
- package/server/path-resolver.ts +168 -0
- package/server/pr-poller.ts +162 -0
- package/server/proactive-keepalive.ts +121 -0
- package/server/prompt-manager.ts +103 -0
- package/server/protocol-monitor.ts +197 -0
- package/server/race-controller.ts +295 -0
- package/server/race-store.ts +82 -0
- package/server/recorder.ts +359 -0
- package/server/recording-hub/compat-validator.ts +122 -0
- package/server/recording-hub/diagnostics.ts +289 -0
- package/server/recording-hub/hub-routes.ts +146 -0
- package/server/recording-hub/hub-store.ts +224 -0
- package/server/replay.ts +78 -0
- package/server/routes/adapter-routes.ts +31 -0
- package/server/routes/agent-mcp-routes.ts +35 -0
- package/server/routes/agent-routes.ts +185 -0
- package/server/routes/auth-routes.ts +101 -0
- package/server/routes/ci-routes.ts +165 -0
- package/server/routes/commands-routes.ts +94 -0
- package/server/routes/cron-routes.ts +100 -0
- package/server/routes/env-routes.ts +50 -0
- package/server/routes/folder-routes.ts +56 -0
- package/server/routes/fs-routes.ts +403 -0
- package/server/routes/gallery-routes.ts +410 -0
- package/server/routes/git-routes.ts +108 -0
- package/server/routes/index.ts +62 -0
- package/server/routes/linear-routes.ts +254 -0
- package/server/routes/monitor-routes.ts +11 -0
- package/server/routes/orchestrator-routes.ts +210 -0
- package/server/routes/prompt-routes.ts +47 -0
- package/server/routes/race-routes.ts +103 -0
- package/server/routes/recording-routes.ts +101 -0
- package/server/routes/route-deps.ts +32 -0
- package/server/routes/session-routes.ts +560 -0
- package/server/routes/settings-routes.ts +108 -0
- package/server/routes/skills-routes.ts +52 -0
- package/server/routes/system-routes.ts +243 -0
- package/server/routes/webhook-routes.ts +135 -0
- package/server/routes.ts +44 -0
- package/server/security-middleware.ts +100 -0
- package/server/semantic-memory.ts +389 -0
- package/server/service.ts +733 -0
- package/server/session-folders.ts +107 -0
- package/server/session-git-info.ts +89 -0
- package/server/session-linear-issues.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-store.ts +169 -0
- package/server/session-types.ts +415 -0
- package/server/settings-manager.ts +120 -0
- package/server/shared-context.ts +401 -0
- package/server/skills-manager.ts +250 -0
- package/server/sub-session-manager.ts +263 -0
- package/server/terminal-manager.ts +185 -0
- package/server/update-checker.ts +114 -0
- package/server/usage-limits.ts +192 -0
- package/server/webhook-manager.ts +228 -0
- package/server/webhook-store.ts +168 -0
- package/server/webhook-types.ts +66 -0
- package/server/worktree-tracker.ts +84 -0
- 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
|
+
}
|