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