@schlessera/brain-ui-server 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/agent/backend.d.ts +30 -0
  4. package/dist/agent/backend.d.ts.map +1 -0
  5. package/dist/agent/backend.js +226 -0
  6. package/dist/agent/backend.js.map +1 -0
  7. package/dist/app.d.ts +27 -0
  8. package/dist/app.d.ts.map +1 -0
  9. package/dist/app.js +116 -0
  10. package/dist/app.js.map +1 -0
  11. package/dist/brain/client.d.ts +43 -0
  12. package/dist/brain/client.d.ts.map +1 -0
  13. package/dist/brain/client.js +154 -0
  14. package/dist/brain/client.js.map +1 -0
  15. package/dist/brain/types.d.ts +41 -0
  16. package/dist/brain/types.d.ts.map +1 -0
  17. package/dist/brain/types.js +2 -0
  18. package/dist/brain/types.js.map +1 -0
  19. package/dist/cron/scheduler.d.ts +9 -0
  20. package/dist/cron/scheduler.d.ts.map +1 -0
  21. package/dist/cron/scheduler.js +58 -0
  22. package/dist/cron/scheduler.js.map +1 -0
  23. package/dist/db/client.d.ts +10 -0
  24. package/dist/db/client.d.ts.map +1 -0
  25. package/dist/db/client.js +73 -0
  26. package/dist/db/client.js.map +1 -0
  27. package/dist/files/walker.d.ts +63 -0
  28. package/dist/files/walker.d.ts.map +1 -0
  29. package/dist/files/walker.js +416 -0
  30. package/dist/files/walker.js.map +1 -0
  31. package/dist/index.d.ts +18 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +25 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/middleware/auth.d.ts +59 -0
  36. package/dist/middleware/auth.d.ts.map +1 -0
  37. package/dist/middleware/auth.js +246 -0
  38. package/dist/middleware/auth.js.map +1 -0
  39. package/dist/middleware/passkeys.d.ts +36 -0
  40. package/dist/middleware/passkeys.d.ts.map +1 -0
  41. package/dist/middleware/passkeys.js +427 -0
  42. package/dist/middleware/passkeys.js.map +1 -0
  43. package/dist/middleware/tailscale.d.ts +31 -0
  44. package/dist/middleware/tailscale.d.ts.map +1 -0
  45. package/dist/middleware/tailscale.js +64 -0
  46. package/dist/middleware/tailscale.js.map +1 -0
  47. package/dist/render/template.d.ts +7 -0
  48. package/dist/render/template.d.ts.map +1 -0
  49. package/dist/render/template.js +112 -0
  50. package/dist/render/template.js.map +1 -0
  51. package/dist/routes/brain.d.ts +155 -0
  52. package/dist/routes/brain.d.ts.map +1 -0
  53. package/dist/routes/brain.js +250 -0
  54. package/dist/routes/brain.js.map +1 -0
  55. package/dist/routes/files.d.ts +89 -0
  56. package/dist/routes/files.d.ts.map +1 -0
  57. package/dist/routes/files.js +91 -0
  58. package/dist/routes/files.js.map +1 -0
  59. package/dist/routes/health.d.ts +37 -0
  60. package/dist/routes/health.d.ts.map +1 -0
  61. package/dist/routes/health.js +27 -0
  62. package/dist/routes/health.js.map +1 -0
  63. package/dist/routes/providers.d.ts +33 -0
  64. package/dist/routes/providers.d.ts.map +1 -0
  65. package/dist/routes/providers.js +9 -0
  66. package/dist/routes/providers.js.map +1 -0
  67. package/dist/routes/render.d.ts +25 -0
  68. package/dist/routes/render.d.ts.map +1 -0
  69. package/dist/routes/render.js +47 -0
  70. package/dist/routes/render.js.map +1 -0
  71. package/dist/routes/sessions.d.ts +79 -0
  72. package/dist/routes/sessions.d.ts.map +1 -0
  73. package/dist/routes/sessions.js +37 -0
  74. package/dist/routes/sessions.js.map +1 -0
  75. package/dist/routes/voice.d.ts +92 -0
  76. package/dist/routes/voice.d.ts.map +1 -0
  77. package/dist/routes/voice.js +64 -0
  78. package/dist/routes/voice.js.map +1 -0
  79. package/dist/voice/deepgram-token.d.ts +5 -0
  80. package/dist/voice/deepgram-token.d.ts.map +1 -0
  81. package/dist/voice/deepgram-token.js +63 -0
  82. package/dist/voice/deepgram-token.js.map +1 -0
  83. package/dist/voice/keyterm-builder.d.ts +18 -0
  84. package/dist/voice/keyterm-builder.d.ts.map +1 -0
  85. package/dist/voice/keyterm-builder.js +391 -0
  86. package/dist/voice/keyterm-builder.js.map +1 -0
  87. package/dist/voice/speech-providers.d.ts +25 -0
  88. package/dist/voice/speech-providers.d.ts.map +1 -0
  89. package/dist/voice/speech-providers.js +104 -0
  90. package/dist/voice/speech-providers.js.map +1 -0
  91. package/dist/ws/attachments.d.ts +10 -0
  92. package/dist/ws/attachments.d.ts.map +1 -0
  93. package/dist/ws/attachments.js +63 -0
  94. package/dist/ws/attachments.js.map +1 -0
  95. package/dist/ws/bridge.d.ts +7 -0
  96. package/dist/ws/bridge.d.ts.map +1 -0
  97. package/dist/ws/bridge.js +86 -0
  98. package/dist/ws/bridge.js.map +1 -0
  99. package/dist/ws/clients.d.ts +20 -0
  100. package/dist/ws/clients.d.ts.map +1 -0
  101. package/dist/ws/clients.js +48 -0
  102. package/dist/ws/clients.js.map +1 -0
  103. package/dist/ws/connection.d.ts +8 -0
  104. package/dist/ws/connection.d.ts.map +1 -0
  105. package/dist/ws/connection.js +116 -0
  106. package/dist/ws/connection.js.map +1 -0
  107. package/dist/ws/dispatch.d.ts +5 -0
  108. package/dist/ws/dispatch.d.ts.map +1 -0
  109. package/dist/ws/dispatch.js +139 -0
  110. package/dist/ws/dispatch.js.map +1 -0
  111. package/dist/ws/frames.d.ts +17 -0
  112. package/dist/ws/frames.d.ts.map +1 -0
  113. package/dist/ws/frames.js +37 -0
  114. package/dist/ws/frames.js.map +1 -0
  115. package/dist/ws/handler.d.ts +34 -0
  116. package/dist/ws/handler.d.ts.map +1 -0
  117. package/dist/ws/handler.js +33 -0
  118. package/dist/ws/handler.js.map +1 -0
  119. package/dist/ws/history.d.ts +17 -0
  120. package/dist/ws/history.d.ts.map +1 -0
  121. package/dist/ws/history.js +45 -0
  122. package/dist/ws/history.js.map +1 -0
  123. package/dist/ws/host.d.ts +40 -0
  124. package/dist/ws/host.d.ts.map +1 -0
  125. package/dist/ws/host.js +49 -0
  126. package/dist/ws/host.js.map +1 -0
  127. package/dist/ws/routing.d.ts +12 -0
  128. package/dist/ws/routing.d.ts.map +1 -0
  129. package/dist/ws/routing.js +31 -0
  130. package/dist/ws/routing.js.map +1 -0
  131. package/dist/ws/run-session.d.ts +17 -0
  132. package/dist/ws/run-session.d.ts.map +1 -0
  133. package/dist/ws/run-session.js +160 -0
  134. package/dist/ws/run-session.js.map +1 -0
  135. package/dist/ws/session-catalog.d.ts +25 -0
  136. package/dist/ws/session-catalog.d.ts.map +1 -0
  137. package/dist/ws/session-catalog.js +45 -0
  138. package/dist/ws/session-catalog.js.map +1 -0
  139. package/dist/ws/shrink.d.ts +34 -0
  140. package/dist/ws/shrink.d.ts.map +1 -0
  141. package/dist/ws/shrink.js +91 -0
  142. package/dist/ws/shrink.js.map +1 -0
  143. package/dist/ws/turns.d.ts +77 -0
  144. package/dist/ws/turns.d.ts.map +1 -0
  145. package/dist/ws/turns.js +89 -0
  146. package/dist/ws/turns.js.map +1 -0
  147. package/migrations/001_initial.sql +29 -0
  148. package/migrations/002_session_provider.sql +1 -0
  149. package/migrations/003_session_backend.sql +5 -0
  150. package/migrations/004_drop_gemini_backend.sql +20 -0
  151. package/migrations/005_passkey_credentials.sql +18 -0
  152. package/package.json +72 -0
  153. package/src/agent/backend.ts +308 -0
  154. package/src/app.ts +159 -0
  155. package/src/brain/client.ts +186 -0
  156. package/src/brain/types.ts +44 -0
  157. package/src/cron/scheduler.ts +80 -0
  158. package/src/db/client.ts +85 -0
  159. package/src/files/walker.ts +418 -0
  160. package/src/index.ts +39 -0
  161. package/src/middleware/auth.ts +321 -0
  162. package/src/middleware/passkeys.ts +510 -0
  163. package/src/middleware/tailscale.ts +81 -0
  164. package/src/render/template.ts +124 -0
  165. package/src/routes/brain.ts +305 -0
  166. package/src/routes/files.ts +99 -0
  167. package/src/routes/health.ts +29 -0
  168. package/src/routes/providers.ts +9 -0
  169. package/src/routes/render.ts +68 -0
  170. package/src/routes/sessions.ts +49 -0
  171. package/src/routes/voice.ts +80 -0
  172. package/src/voice/deepgram-token.ts +80 -0
  173. package/src/voice/keyterm-builder.ts +426 -0
  174. package/src/voice/speech-providers.ts +111 -0
  175. package/src/ws/attachments.ts +86 -0
  176. package/src/ws/bridge.ts +139 -0
  177. package/src/ws/clients.ts +57 -0
  178. package/src/ws/connection.ts +132 -0
  179. package/src/ws/dispatch.ts +162 -0
  180. package/src/ws/frames.ts +42 -0
  181. package/src/ws/handler.ts +50 -0
  182. package/src/ws/history.ts +51 -0
  183. package/src/ws/host.ts +69 -0
  184. package/src/ws/routing.ts +43 -0
  185. package/src/ws/run-session.ts +192 -0
  186. package/src/ws/session-catalog.ts +97 -0
  187. package/src/ws/shrink.ts +95 -0
  188. package/src/ws/turns.ts +151 -0
@@ -0,0 +1,86 @@
1
+ import type { ChatImageAttachment } from "@schlessera/brain-ui-sdk/protocol";
2
+ import {
3
+ ALLOWED_IMAGE_MEDIA_TYPES,
4
+ MAX_IMAGE_BYTES,
5
+ MAX_IMAGES_PER_MESSAGE,
6
+ MAX_TOTAL_IMAGE_BYTES,
7
+ } from "@schlessera/brain-ui-sdk/protocol";
8
+
9
+ export function estimateDecodedBase64Bytes(data: string): number {
10
+ const normalized = data.replace(/\s/g, "");
11
+ const padding = normalized.endsWith("==")
12
+ ? 2
13
+ : normalized.endsWith("=")
14
+ ? 1
15
+ : 0;
16
+ return Math.floor((normalized.length * 3) / 4) - padding;
17
+ }
18
+
19
+ export function validateAttachments(
20
+ attachments: unknown
21
+ ): { ok: true; attachments: ChatImageAttachment[] } | { ok: false; reason: string } {
22
+ if (attachments === undefined) {
23
+ return { ok: true, attachments: [] };
24
+ }
25
+
26
+ if (!Array.isArray(attachments)) {
27
+ return { ok: false, reason: "Attachments must be an array" };
28
+ }
29
+
30
+ if (attachments.length > MAX_IMAGES_PER_MESSAGE) {
31
+ return {
32
+ ok: false,
33
+ reason: `Too many images attached; maximum is ${MAX_IMAGES_PER_MESSAGE}`,
34
+ };
35
+ }
36
+
37
+ let totalBytes = 0;
38
+ const validated: ChatImageAttachment[] = [];
39
+
40
+ for (const attachment of attachments) {
41
+ if (
42
+ !attachment ||
43
+ typeof attachment !== "object" ||
44
+ typeof (attachment as ChatImageAttachment).data !== "string"
45
+ ) {
46
+ return { ok: false, reason: "Each attachment must include base64 image data" };
47
+ }
48
+
49
+ const data = (attachment as ChatImageAttachment).data;
50
+ if (
51
+ data.length === 0 ||
52
+ data.length % 4 !== 0 ||
53
+ !/^[A-Za-z0-9+/]+={0,2}$/.test(data)
54
+ ) {
55
+ return { ok: false, reason: "Attachment data is not valid base64" };
56
+ }
57
+
58
+ const mediaType = (attachment as ChatImageAttachment).mediaType;
59
+ if (!ALLOWED_IMAGE_MEDIA_TYPES.includes(mediaType)) {
60
+ return {
61
+ ok: false,
62
+ reason: `Unsupported image type: ${String(mediaType)}`,
63
+ };
64
+ }
65
+
66
+ const bytes = estimateDecodedBase64Bytes((attachment as ChatImageAttachment).data);
67
+ if (bytes > MAX_IMAGE_BYTES) {
68
+ return {
69
+ ok: false,
70
+ reason: `Image is too large; maximum decoded size is ${MAX_IMAGE_BYTES} bytes`,
71
+ };
72
+ }
73
+
74
+ totalBytes += bytes;
75
+ if (totalBytes > MAX_TOTAL_IMAGE_BYTES) {
76
+ return {
77
+ ok: false,
78
+ reason: `Attached images are too large; maximum total decoded size is ${MAX_TOTAL_IMAGE_BYTES} bytes`,
79
+ };
80
+ }
81
+
82
+ validated.push(attachment as ChatImageAttachment);
83
+ }
84
+
85
+ return { ok: true, attachments: validated };
86
+ }
@@ -0,0 +1,139 @@
1
+ import type {
2
+ BackendBridge,
3
+ PermissionDecision,
4
+ AskUserResult,
5
+ LocationFix,
6
+ } from "@schlessera/brain-ui-sdk/server";
7
+ import { BackendBusyError, BackendRequestError } from "@schlessera/brain-ui-sdk/server";
8
+ import { hasClients } from "./clients.js";
9
+ import { withTurnScope } from "./frames.js";
10
+ import type { RunningTurn } from "./turns.js";
11
+ import type { WsHost } from "./host.js";
12
+
13
+ /** Build the per-turn bridge the backend drives. */
14
+ export function makeBridge(
15
+ host: WsHost,
16
+ turn: RunningTurn,
17
+ promptText: string,
18
+ backendId: string
19
+ ): BackendBridge {
20
+ const { coordinator, catalog } = host;
21
+ // Capture the turn identity at construction: the slot's turnId is re-minted
22
+ // for each queued follow-up, and a backend can still emit late frames
23
+ // through this bridge after its startTurn resolved. Stamping from the live
24
+ // field would attribute those to the NEXT turn.
25
+ const turnId = turn.turnId;
26
+ return {
27
+ emit: (msg) => {
28
+ if (msg.type === "session_info") {
29
+ turn.sessionId = msg.sessionId;
30
+ if (msg.providerId) turn.providerId = msg.providerId;
31
+ coordinator.bySession.set(msg.sessionId, turn);
32
+ // Persist ownership the moment the identity exists — a turn that
33
+ // later fails or is cancelled must not leave an unowned transcript.
34
+ catalog.persistSessionStub(msg.sessionId, promptText, turn.providerId, backendId);
35
+ }
36
+ host.sendToClients(withTurnScope(msg, turn, turnId));
37
+ if (msg.type === "result") {
38
+ catalog.persistSession(msg, promptText, turn.providerId, backendId);
39
+ }
40
+ },
41
+ requestPermission: (req) => {
42
+ host.sendToClients(
43
+ withTurnScope(
44
+ {
45
+ type: "tool_approval_request",
46
+ toolUseId: req.toolUseId,
47
+ toolName: req.toolName,
48
+ input: req.input,
49
+ description: req.description,
50
+ },
51
+ turn,
52
+ turnId
53
+ )
54
+ );
55
+ host.sendToClients(
56
+ withTurnScope(
57
+ { type: "status", status: "tool_executing", detail: `Waiting for approval: ${req.toolName}` },
58
+ turn,
59
+ turnId
60
+ )
61
+ );
62
+ return new Promise<PermissionDecision>((resolve) => {
63
+ if (coordinator.collidesAcrossTurns(coordinator.pendingApprovals, req.toolUseId, turn)) {
64
+ resolve({ behavior: "deny", message: "Duplicate tool-approval id" });
65
+ return;
66
+ }
67
+ coordinator.pendingApprovals.set(req.toolUseId, { turn, turnId, resolve });
68
+ });
69
+ },
70
+ askUser: (requestId, questions) => {
71
+ host.sendToClients(
72
+ withTurnScope({ type: "ask_user_request", requestId, questions }, turn, turnId)
73
+ );
74
+ host.sendToClients(
75
+ withTurnScope(
76
+ { type: "status", status: "tool_executing", detail: "Waiting for your input" },
77
+ turn,
78
+ turnId
79
+ )
80
+ );
81
+ return new Promise<AskUserResult>((resolve, reject) => {
82
+ if (coordinator.collidesAcrossTurns(coordinator.pendingAskUser, requestId, turn)) {
83
+ reject(new Error("Duplicate ask-user request id"));
84
+ return;
85
+ }
86
+ coordinator.pendingAskUser.set(requestId, { turn, turnId, resolve, reject });
87
+ });
88
+ },
89
+ getLocation: (options) => {
90
+ if (!hasClients()) {
91
+ return Promise.reject(
92
+ new Error(`No ${host.appName} client is connected to read the location from.`)
93
+ );
94
+ }
95
+ const requestId = coordinator.nextLocationRequestId();
96
+ host.sendToClients(
97
+ withTurnScope({ type: "location_request", requestId, options }, turn, turnId)
98
+ );
99
+ host.sendToClients(
100
+ withTurnScope(
101
+ { type: "status", status: "tool_executing", detail: "Requesting your location" },
102
+ turn,
103
+ turnId
104
+ )
105
+ );
106
+ return new Promise<LocationFix>((resolve, reject) => {
107
+ if (coordinator.collidesAcrossTurns(coordinator.pendingLocation, requestId, turn)) {
108
+ reject(new Error("Duplicate location request id"));
109
+ return;
110
+ }
111
+ coordinator.pendingLocation.set(requestId, { turn, turnId, resolve, reject });
112
+ });
113
+ },
114
+ };
115
+ }
116
+
117
+ export function emitTurnError(host: WsHost, turn: RunningTurn, err: unknown): void {
118
+ // startTurn resolves for runtime failures (it emits its own error frame); it
119
+ // only rejects for caller errors.
120
+ if (err instanceof BackendBusyError) {
121
+ host.sendToClients(
122
+ withTurnScope(
123
+ { type: "error", code: "SESSION_BUSY", message: "That session already has a running turn." },
124
+ turn
125
+ )
126
+ );
127
+ } else if (err instanceof BackendRequestError) {
128
+ host.sendToClients(
129
+ withTurnScope({ type: "error", code: "BACKEND_REQUEST_ERROR", message: err.message }, turn)
130
+ );
131
+ } else {
132
+ host.sendToClients(
133
+ withTurnScope(
134
+ { type: "error", code: "BACKEND_ERROR", message: err instanceof Error ? err.message : String(err) },
135
+ turn
136
+ )
137
+ );
138
+ }
139
+ }
@@ -0,0 +1,57 @@
1
+ import type { ServerMessage } from "@schlessera/brain-ui-sdk/protocol";
2
+ import { shrinkForReplication } from "./shrink.js";
3
+
4
+ /** Minimal structural view of a live socket — all we need to write to it. */
5
+ export type WSContext = { send: (data: string) => void };
6
+
7
+ /**
8
+ * Every currently-attached client socket. The active agent turn is a
9
+ * process-global singleton (server/src/ws/handler.ts), so all sockets observe
10
+ * the same conversation; streamed output fans out to all of them.
11
+ * Previously the server held a single `activeWs`, so a second connection
12
+ * silently orphaned the first (it stayed open but never received output).
13
+ */
14
+ const clients = new Set<WSContext>();
15
+
16
+ export function addClient(ws: WSContext): void {
17
+ clients.add(ws);
18
+ }
19
+
20
+ export function removeClient(ws: WSContext): void {
21
+ clients.delete(ws);
22
+ }
23
+
24
+ export function clientCount(): number {
25
+ return clients.size;
26
+ }
27
+
28
+ export function hasClients(): boolean {
29
+ return clients.size > 0;
30
+ }
31
+
32
+ /** Test-only: drop all registered sockets. */
33
+ export function resetClientsForTests(): void {
34
+ clients.clear();
35
+ }
36
+
37
+ /** Serialize + size-bound a frame, then send it to one specific socket. */
38
+ export function sendTo(ws: WSContext, msg: ServerMessage): void {
39
+ ws.send(JSON.stringify(shrinkForReplication(msg)));
40
+ }
41
+
42
+ /**
43
+ * Broadcast a frame to every attached client. Serializes once. A failing
44
+ * socket is skipped (its `onClose` will prune it) so one dead peer can't
45
+ * block delivery to the others.
46
+ */
47
+ export function broadcast(msg: ServerMessage): void {
48
+ if (clients.size === 0) return;
49
+ const payload = JSON.stringify(shrinkForReplication(msg));
50
+ for (const ws of clients) {
51
+ try {
52
+ ws.send(payload);
53
+ } catch {
54
+ // Drop; the socket's onClose handler removes it from the set.
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,132 @@
1
+ import { upgradeWebSocket, websocket } from "hono/bun";
2
+ import { PROTOCOL_REV } from "@schlessera/brain-ui-sdk/protocol";
3
+ import { parseClientMessage } from "@schlessera/brain-ui-sdk/schemas";
4
+ import { getBackendForSession } from "../agent/backend.js";
5
+ import { addClient, removeClient, hasClients } from "./clients.js";
6
+ import { withTurnScope } from "./frames.js";
7
+ import { sendSessionHistory } from "./history.js";
8
+ import { handleClientMessage } from "./dispatch.js";
9
+ import type { WsHost } from "./host.js";
10
+
11
+ export { websocket };
12
+
13
+ /** Build the Hono WebSocket upgrade handler bound to one host. */
14
+ export function createWsUpgrade(host: WsHost) {
15
+ return upgradeWebSocket(() => ({
16
+ async onOpen(_evt, ws) {
17
+ console.log("[ws] Client connected");
18
+ const { coordinator, catalog } = host;
19
+
20
+ // Handshake first (rev 2, additive): protocol revision + coarse
21
+ // capabilities, so the client can gate behavior instead of sniffing.
22
+ host.sendMessage(ws, {
23
+ type: "server_hello",
24
+ protocolRev: PROTOCOL_REV,
25
+ capabilities: { multiSession: true, askUser: true, location: true },
26
+ });
27
+
28
+ // Snapshot-on-connect only for the single-running-session case (backward
29
+ // compatible). With zero or several running sessions the client rehydrates
30
+ // itself per-session via session_resume, and live frames (sessionId-scoped)
31
+ // fan out to it once it joins the broadcast set.
32
+ const runningTurns = [...coordinator.running].filter((t) => t.sessionId);
33
+ if (runningTurns.length === 1) {
34
+ const turn = runningTurns[0];
35
+ const sid = turn.sessionId!;
36
+ host.sendMessage(
37
+ ws,
38
+ // Stamp the live turn identity: a reconnecting client must learn the
39
+ // current turnId, or the correlation guarantee dies at the reconnect
40
+ // it exists for.
41
+ withTurnScope(
42
+ {
43
+ type: "session_info",
44
+ sessionId: sid,
45
+ isNew: false,
46
+ providerId: turn.providerId ?? catalog.getStoredProviderId(sid) ?? undefined,
47
+ },
48
+ turn
49
+ )
50
+ );
51
+ try {
52
+ const backend = await getBackendForSession(
53
+ catalog.getStoredBackendId(sid) ?? turn.backend.id
54
+ );
55
+ const history = await backend.getHistory(sid);
56
+ if (history.length > 0) sendSessionHistory(ws, sid, history);
57
+ } catch (err) {
58
+ console.error("[ws] snapshot-on-connect failed:", err);
59
+ } finally {
60
+ addClient(ws);
61
+ host.sendMessage(
62
+ ws,
63
+ withTurnScope(
64
+ {
65
+ type: "status",
66
+ status: "thinking",
67
+ detail: "Session in progress",
68
+ sessionId: sid,
69
+ },
70
+ turn
71
+ )
72
+ );
73
+ }
74
+ return;
75
+ }
76
+
77
+ addClient(ws);
78
+ host.sendMessage(ws, {
79
+ type: "status",
80
+ status: "idle",
81
+ detail: `Connected to ${host.appName}`,
82
+ });
83
+ },
84
+
85
+ onMessage(evt, ws) {
86
+ // Boundary validation (rev 2): byte cap + JSON decode + schema, in one
87
+ // place. No more casting client JSON to ClientMessage.
88
+ //
89
+ // Text frames only: hono's Bun adapter hands binary frames over as the
90
+ // underlying POOLED ArrayBuffer (byteOffset/byteLength discarded), so a
91
+ // binary frame cannot be decoded correctly here. The protocol is JSON
92
+ // text; reject anything else rather than parse a slab.
93
+ const raw = evt.data;
94
+ if (typeof raw !== "string") {
95
+ host.sendMessage(ws, {
96
+ type: "error",
97
+ code: "PARSE_ERROR",
98
+ message: "Binary frames are not supported; send JSON text",
99
+ });
100
+ return;
101
+ }
102
+ const parsed = parseClientMessage(raw);
103
+ if (!parsed.ok) {
104
+ host.sendMessage(ws, { type: "error", code: "PARSE_ERROR", message: parsed.error });
105
+ return;
106
+ }
107
+ // handleClientMessage is async — a rejection must not escape as an
108
+ // unhandled rejection with no frame sent.
109
+ void Promise.resolve()
110
+ .then(() => handleClientMessage(host, ws, parsed.message))
111
+ .catch(() => {
112
+ host.sendMessage(ws, {
113
+ type: "error",
114
+ code: "INTERNAL_ERROR",
115
+ message: "Failed to handle message",
116
+ });
117
+ });
118
+ },
119
+
120
+ onClose(_evt, ws) {
121
+ console.log("[ws] Client disconnected");
122
+ removeClient(ws);
123
+ // Turns keep running in the background. Only reject pending interactive
124
+ // requests once the LAST client leaves — while another client remains it
125
+ // can still answer them.
126
+ if (hasClients()) return;
127
+ for (const turn of host.coordinator.running) {
128
+ host.coordinator.drainPendingForTurn(turn, "Client disconnected");
129
+ }
130
+ },
131
+ }));
132
+ }
@@ -0,0 +1,162 @@
1
+ import type { ClientMessage } from "@schlessera/brain-ui-sdk/protocol";
2
+ import { getBackendForSession } from "../agent/backend.js";
3
+ import type { WSContext } from "./clients.js";
4
+ import { locationErrorText } from "./frames.js";
5
+ import { sendSessionHistory } from "./history.js";
6
+ import { validateAttachments } from "./attachments.js";
7
+ import { handleChatMessage } from "./run-session.js";
8
+ import type { WsHost } from "./host.js";
9
+
10
+ /**
11
+ * A client MAY echo the request's turnId (rev 2). When it does, it must match
12
+ * the turn that raised the request — a stale echo from before a reconnect or
13
+ * a follow-up would otherwise resolve the wrong turn's pending promise.
14
+ * Absent turnId stays valid: the field is optional on the wire.
15
+ */
16
+ function turnIdMatches(pending: { turnId: string }, echoed: string | undefined): boolean {
17
+ return echoed === undefined || echoed === pending.turnId;
18
+ }
19
+
20
+ export async function handleClientMessage(
21
+ host: WsHost,
22
+ ws: WSContext,
23
+ msg: ClientMessage
24
+ ): Promise<void> {
25
+ const { coordinator, catalog } = host;
26
+ switch (msg.type) {
27
+ case "chat_message": {
28
+ const attachmentResult = validateAttachments(msg.attachments);
29
+ if (!attachmentResult.ok) {
30
+ host.sendMessage(ws, {
31
+ type: "error",
32
+ code: "ATTACHMENT_REJECTED",
33
+ message: attachmentResult.reason,
34
+ ...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
35
+ });
36
+ return;
37
+ }
38
+ await handleChatMessage(
39
+ host,
40
+ ws,
41
+ msg.text,
42
+ msg.sessionId,
43
+ attachmentResult.attachments,
44
+ msg.providerId
45
+ );
46
+ break;
47
+ }
48
+
49
+ case "ask_user_response": {
50
+ const pending = coordinator.pendingAskUser.get(msg.requestId);
51
+ if (pending && turnIdMatches(pending, msg.turnId)) {
52
+ coordinator.pendingAskUser.delete(msg.requestId);
53
+ pending.resolve({ answers: msg.answers, annotations: msg.annotations });
54
+ }
55
+ break;
56
+ }
57
+
58
+ case "ask_user_cancel": {
59
+ const pending = coordinator.pendingAskUser.get(msg.requestId);
60
+ if (pending && turnIdMatches(pending, msg.turnId)) {
61
+ coordinator.pendingAskUser.delete(msg.requestId);
62
+ pending.reject(new Error(msg.reason || "User cancelled the question"));
63
+ }
64
+ break;
65
+ }
66
+
67
+ case "location_response": {
68
+ const pending = coordinator.pendingLocation.get(msg.requestId);
69
+ if (pending && turnIdMatches(pending, msg.turnId)) {
70
+ coordinator.pendingLocation.delete(msg.requestId);
71
+ pending.resolve({ coords: msg.coords, timestamp: msg.timestamp });
72
+ }
73
+ break;
74
+ }
75
+
76
+ case "location_error": {
77
+ const pending = coordinator.pendingLocation.get(msg.requestId);
78
+ if (pending && turnIdMatches(pending, msg.turnId)) {
79
+ coordinator.pendingLocation.delete(msg.requestId);
80
+ pending.reject(new Error(locationErrorText(msg.code, msg.message)));
81
+ }
82
+ break;
83
+ }
84
+
85
+ case "tool_approval": {
86
+ const pending = coordinator.pendingApprovals.get(msg.toolUseId);
87
+ if (pending && turnIdMatches(pending, msg.turnId)) {
88
+ coordinator.pendingApprovals.delete(msg.toolUseId);
89
+ pending.resolve(
90
+ msg.updatedInput
91
+ ? { behavior: "allow", updatedInput: msg.updatedInput }
92
+ : { behavior: "allow" }
93
+ );
94
+ }
95
+ break;
96
+ }
97
+
98
+ case "tool_denial": {
99
+ const pending = coordinator.pendingApprovals.get(msg.toolUseId);
100
+ if (pending && turnIdMatches(pending, msg.turnId)) {
101
+ coordinator.pendingApprovals.delete(msg.toolUseId);
102
+ pending.resolve({ behavior: "deny", message: msg.message });
103
+ }
104
+ break;
105
+ }
106
+
107
+ case "cancel": {
108
+ if (msg.sessionId) {
109
+ const turn = coordinator.bySession.get(msg.sessionId);
110
+ if (turn) coordinator.cancelTurn(turn, "Cancelled by user");
111
+ return;
112
+ }
113
+ // No sessionId: cancel the sole running session; ambiguous if several run.
114
+ if (coordinator.running.size === 0) return;
115
+ if (coordinator.running.size > 1) {
116
+ host.sendMessage(ws, {
117
+ type: "error",
118
+ code: "CANCEL_AMBIGUOUS",
119
+ message: "Several sessions are running — specify which to cancel.",
120
+ });
121
+ return;
122
+ }
123
+ coordinator.cancelTurn([...coordinator.running][0], "Cancelled by user");
124
+ break;
125
+ }
126
+
127
+ case "session_resume": {
128
+ host.sendMessage(ws, {
129
+ type: "session_info",
130
+ sessionId: msg.sessionId,
131
+ isNew: false,
132
+ providerId: catalog.getStoredProviderId(msg.sessionId) ?? undefined,
133
+ });
134
+
135
+ try {
136
+ const backend = await getBackendForSession(catalog.getStoredBackendId(msg.sessionId));
137
+ const messages = await backend.getHistory(msg.sessionId);
138
+ sendSessionHistory(ws, msg.sessionId, messages);
139
+ // A resume of a RUNNING session (reattach) must not report idle: idle
140
+ // would clear the client's running badge and finish its streaming
141
+ // message mid-turn. Mirror the snapshot-on-connect status instead.
142
+ const runningTurn = coordinator.bySession.get(msg.sessionId);
143
+ host.sendMessage(ws, {
144
+ type: "status",
145
+ ...(runningTurn
146
+ ? { status: "thinking" as const, detail: "Session in progress" }
147
+ : { status: "idle" as const, detail: "Session loaded" }),
148
+ sessionId: msg.sessionId,
149
+ ...(runningTurn ? { turnId: runningTurn.turnId } : {}),
150
+ });
151
+ } catch (err) {
152
+ host.sendMessage(ws, {
153
+ type: "error",
154
+ code: "SESSION_LOAD_ERROR",
155
+ message: err instanceof Error ? err.message : "Failed to load session",
156
+ sessionId: msg.sessionId,
157
+ });
158
+ }
159
+ break;
160
+ }
161
+ }
162
+ }
@@ -0,0 +1,42 @@
1
+ import type { ServerMessage } from "@schlessera/brain-ui-sdk/protocol";
2
+ import type { RunningTurn } from "./turns.js";
3
+
4
+ /**
5
+ * Stamp a session-scoped frame with a sessionId for client demux. Every
6
+ * ServerMessage is session-scoped (or carries its own sessionId), so this only
7
+ * fills in a missing id and never overwrites one the backend already set.
8
+ */
9
+ export function withSessionId(msg: ServerMessage, sessionId: string | null): ServerMessage {
10
+ if (!sessionId) return msg;
11
+ if ((msg as { sessionId?: string }).sessionId) return msg;
12
+ return { ...msg, sessionId } as ServerMessage;
13
+ }
14
+
15
+ /**
16
+ * Stamp a frame with the turn's session AND turn identity (rev 2). turnId lets
17
+ * the client correlate interactive round-trips (approvals / ask-user /
18
+ * location) with the exact turn that raised them, even across a reconnect.
19
+ */
20
+ export function withTurnScope(
21
+ msg: ServerMessage,
22
+ turn: RunningTurn,
23
+ turnId: string = turn.turnId
24
+ ): ServerMessage {
25
+ const scoped = withSessionId(msg, turn.sessionId);
26
+ if ((scoped as { turnId?: string }).turnId) return scoped;
27
+ return { ...scoped, turnId } as ServerMessage;
28
+ }
29
+
30
+ /** Human-readable reason for a GeolocationPositionError code. */
31
+ export function locationErrorText(code: number, fallback: string): string {
32
+ switch (code) {
33
+ case 1:
34
+ return "The user denied permission to share their location.";
35
+ case 2:
36
+ return "The user's location is currently unavailable.";
37
+ case 3:
38
+ return "Timed out while retrieving the user's location.";
39
+ default:
40
+ return fallback || "Failed to retrieve the user's location.";
41
+ }
42
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Composition root for the WebSocket side: one process-wide host instance
3
+ * wired to the default SQLite session catalog and the backend registry. The
4
+ * actual behavior lives in the focused modules next to this file —
5
+ * turns/bridge/run-session/dispatch/connection — which all take the host
6
+ * explicitly. createApp() configures this host from its options before
7
+ * building the upgrade handler.
8
+ */
9
+ import type { ClientMessage } from "@schlessera/brain-ui-sdk/protocol";
10
+ import type { WSContext } from "./clients.js";
11
+ import { WsHost, type WsHostOptions } from "./host.js";
12
+ import { createWsUpgrade, websocket } from "./connection.js";
13
+ import { handleClientMessage as dispatchClientMessage } from "./dispatch.js";
14
+
15
+ export { websocket };
16
+ export { WsHost, type WsHostOptions } from "./host.js";
17
+ export { resolveTurnTarget } from "./routing.js";
18
+ export { validateAttachments, estimateDecodedBase64Bytes } from "./attachments.js";
19
+ export { createSessionCatalog, type SessionCatalog } from "./session-catalog.js";
20
+
21
+ /** The process-default host. */
22
+ export const defaultWsHost = new WsHost();
23
+
24
+ /** Apply embedder options to the default host (called by createApp). */
25
+ export function configureWsHost(options: WsHostOptions): void {
26
+ defaultWsHost.configure(options);
27
+ }
28
+
29
+ /** Hono upgrade handler bound to the default host. */
30
+ export const wsUpgrade = createWsUpgrade(defaultWsHost);
31
+
32
+ /** True while any session has a running turn. */
33
+ export function isTurnActive(): boolean {
34
+ return defaultWsHost.coordinator.isTurnActive();
35
+ }
36
+
37
+ /** Cancel every running turn (used on shutdown). */
38
+ export function cancelActiveTurn(): boolean {
39
+ return defaultWsHost.coordinator.cancelAll("Server shutting down");
40
+ }
41
+
42
+ /** Drive a client message against the default host (also the test seam). */
43
+ export async function handleClientMessage(ws: WSContext, msg: ClientMessage): Promise<void> {
44
+ return dispatchClientMessage(defaultWsHost, ws, msg);
45
+ }
46
+
47
+ /** Test seam: clear all turn/pending state on the default host. */
48
+ export function resetForTests(): void {
49
+ defaultWsHost.coordinator.reset();
50
+ }