@schlessera/brain-ui-server 0.16.0 → 0.18.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 (90) hide show
  1. package/README.md +2 -0
  2. package/dist/agent/backend.d.ts +31 -10
  3. package/dist/agent/backend.d.ts.map +1 -1
  4. package/dist/agent/backend.js +86 -26
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +51 -17
  8. package/dist/app.js.map +1 -1
  9. package/dist/config/env-core.d.ts +73 -0
  10. package/dist/config/env-core.d.ts.map +1 -0
  11. package/dist/config/env-core.js +62 -0
  12. package/dist/config/env-core.js.map +1 -0
  13. package/dist/config/env.d.ts +24 -5
  14. package/dist/config/env.d.ts.map +1 -1
  15. package/dist/config/env.js +55 -11
  16. package/dist/config/env.js.map +1 -1
  17. package/dist/cron/scheduler.d.ts +17 -0
  18. package/dist/cron/scheduler.d.ts.map +1 -1
  19. package/dist/cron/scheduler.js +33 -6
  20. package/dist/cron/scheduler.js.map +1 -1
  21. package/dist/db/settings.d.ts +1 -1
  22. package/dist/db/settings.d.ts.map +1 -1
  23. package/dist/db/settings.js +2 -2
  24. package/dist/db/settings.js.map +1 -1
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/middleware/auth.d.ts +5 -0
  30. package/dist/middleware/auth.d.ts.map +1 -1
  31. package/dist/middleware/auth.js +46 -1
  32. package/dist/middleware/auth.js.map +1 -1
  33. package/dist/routes/health.d.ts +4 -14
  34. package/dist/routes/health.d.ts.map +1 -1
  35. package/dist/routes/health.js +22 -6
  36. package/dist/routes/health.js.map +1 -1
  37. package/dist/routes/share.d.ts.map +1 -1
  38. package/dist/routes/share.js +2 -2
  39. package/dist/routes/share.js.map +1 -1
  40. package/dist/voice/keyterm-builder.js +2 -2
  41. package/dist/voice/keyterm-builder.js.map +1 -1
  42. package/dist/ws/bridge.d.ts.map +1 -1
  43. package/dist/ws/bridge.js +36 -4
  44. package/dist/ws/bridge.js.map +1 -1
  45. package/dist/ws/clients.d.ts +10 -3
  46. package/dist/ws/clients.d.ts.map +1 -1
  47. package/dist/ws/clients.js +16 -4
  48. package/dist/ws/clients.js.map +1 -1
  49. package/dist/ws/connection.d.ts +1 -1
  50. package/dist/ws/connection.d.ts.map +1 -1
  51. package/dist/ws/connection.js +43 -6
  52. package/dist/ws/connection.js.map +1 -1
  53. package/dist/ws/dispatch.d.ts.map +1 -1
  54. package/dist/ws/dispatch.js +3 -1
  55. package/dist/ws/dispatch.js.map +1 -1
  56. package/dist/ws/host.d.ts +36 -0
  57. package/dist/ws/host.d.ts.map +1 -1
  58. package/dist/ws/host.js +93 -4
  59. package/dist/ws/host.js.map +1 -1
  60. package/dist/ws/run-session.d.ts.map +1 -1
  61. package/dist/ws/run-session.js +31 -5
  62. package/dist/ws/run-session.js.map +1 -1
  63. package/dist/ws/session-catalog.d.ts +2 -1
  64. package/dist/ws/session-catalog.d.ts.map +1 -1
  65. package/dist/ws/session-catalog.js +20 -3
  66. package/dist/ws/session-catalog.js.map +1 -1
  67. package/dist/ws/turns.d.ts +30 -2
  68. package/dist/ws/turns.d.ts.map +1 -1
  69. package/dist/ws/turns.js +13 -0
  70. package/dist/ws/turns.js.map +1 -1
  71. package/package.json +7 -6
  72. package/src/agent/backend.ts +123 -29
  73. package/src/app.ts +59 -17
  74. package/src/config/env-core.ts +93 -0
  75. package/src/config/env.ts +80 -17
  76. package/src/cron/scheduler.ts +48 -14
  77. package/src/db/settings.ts +2 -2
  78. package/src/index.ts +6 -0
  79. package/src/middleware/auth.ts +50 -1
  80. package/src/routes/health.ts +22 -6
  81. package/src/routes/share.ts +11 -7
  82. package/src/voice/keyterm-builder.ts +2 -2
  83. package/src/ws/bridge.ts +36 -7
  84. package/src/ws/clients.ts +24 -6
  85. package/src/ws/connection.ts +59 -6
  86. package/src/ws/dispatch.ts +3 -1
  87. package/src/ws/host.ts +115 -6
  88. package/src/ws/run-session.ts +36 -8
  89. package/src/ws/session-catalog.ts +21 -3
  90. package/src/ws/turns.ts +37 -1
@@ -2,6 +2,45 @@ import { upgradeWebSocket, websocket } from "hono/bun";
2
2
  import { PROTOCOL_REV } from "@schlessera/brain-ui-sdk/protocol";
3
3
  import { parseClientMessage } from "@schlessera/brain-ui-sdk/schemas";
4
4
  import { withTurnScope } from "./frames.js";
5
+ import type { WSContext as WSContextType } from "./clients.js";
6
+
7
+ /**
8
+ * Re-send every pending approval and ask-user card to a client that just
9
+ * connected. These survive disconnects (see drainClientBoundForTurn) precisely
10
+ * so this re-delivery can happen: a phone that dropped its socket at screen
11
+ * lock reconnects and finds the card waiting instead of a dead turn. The
12
+ * frames carry their original turn scope, so answering them resolves the
13
+ * correct turn's promise through the normal dispatch path.
14
+ */
15
+ function resendPendingInteractive(host: WsHost, ws: WSContextType): void {
16
+ const { coordinator } = host;
17
+ for (const p of coordinator.pendingApprovals.values()) {
18
+ host.sendMessage(
19
+ ws,
20
+ withTurnScope(
21
+ {
22
+ type: "tool_approval_request",
23
+ toolUseId: p.request.toolUseId,
24
+ toolName: p.request.toolName,
25
+ input: p.request.input,
26
+ description: p.request.description,
27
+ },
28
+ p.turn,
29
+ p.turnId
30
+ )
31
+ );
32
+ }
33
+ for (const p of coordinator.pendingAskUser.values()) {
34
+ host.sendMessage(
35
+ ws,
36
+ withTurnScope(
37
+ { type: "ask_user_request", requestId: p.requestId, questions: p.questions },
38
+ p.turn,
39
+ p.turnId
40
+ )
41
+ );
42
+ }
43
+ }
5
44
  import { sendSessionHistory } from "./history.js";
6
45
  import { handleClientMessage, type ConnectionState } from "./dispatch.js";
7
46
  import type { WsHost } from "./host.js";
@@ -88,6 +127,7 @@ export function createWsHandlers(host: WsHost) {
88
127
  turn
89
128
  )
90
129
  );
130
+ resendPendingInteractive(host, ws);
91
131
  }
92
132
  return;
93
133
  }
@@ -98,6 +138,7 @@ export function createWsHandlers(host: WsHost) {
98
138
  status: "idle",
99
139
  detail: `Connected to ${host.appName}`,
100
140
  });
141
+ resendPendingInteractive(host, ws);
101
142
  },
102
143
 
103
144
  onMessage(evt: MessageEvent, ws: WSContext) {
@@ -141,7 +182,7 @@ export function createWsHandlers(host: WsHost) {
141
182
  // handleClientMessage is async — a rejection must not escape as an
142
183
  // unhandled rejection with no frame sent.
143
184
  void Promise.resolve()
144
- .then(() => handleClientMessage(host, ws, parsed.message))
185
+ .then(() => handleClientMessage(host, ws, parsed.message, connection))
145
186
  .catch((err) => {
146
187
  // This used to swallow the cause entirely: the client got a generic
147
188
  // frame and the server kept no record of what threw.
@@ -161,15 +202,27 @@ export function createWsHandlers(host: WsHost) {
161
202
  });
162
203
  },
163
204
 
164
- onClose(_evt: CloseEvent, ws: WSContext) {
205
+ onClose(evt: CloseEvent, ws: WSContext) {
165
206
  host.log.emit({ severityText: "INFO", body: "client disconnected" });
207
+ // No onError here on purpose: hono's Bun adapter never dispatches it
208
+ // (only open/message/close reach these handlers), so a transport failure
209
+ // is only visible as an abnormal close code. 1000/1001 are the two
210
+ // clean endings (normal closure, going away); anything else — before it
211
+ // was recorded — looked exactly like a clean disconnect.
212
+ const code = (evt as { code?: unknown }).code;
213
+ if (typeof code === "number" && code !== 1000 && code !== 1001) {
214
+ host.reportAbnormalClose(code);
215
+ }
166
216
  host.clients.remove(ws);
167
- // Turns keep running in the background. Only reject pending interactive
168
- // requests once the LAST client leaves while another client remains it
169
- // can still answer them.
217
+ // Turns keep running in the background. Once the LAST client leaves,
218
+ // reject only the requests that need a live client RIGHT NOW (location,
219
+ // mask). Approvals and ask-user cards survive the disconnect and are
220
+ // re-delivered on reconnect — a phone drops its socket at every screen
221
+ // lock, and denying pending approvals on that signal killed real work.
222
+ // The turn timeout remains their upper bound.
170
223
  if (host.clients.hasClients()) return;
171
224
  for (const turn of host.coordinator.running) {
172
- host.coordinator.drainPendingForTurn(turn, "Client disconnected");
225
+ host.coordinator.drainClientBoundForTurn(turn, "Client disconnected");
173
226
  }
174
227
  },
175
228
  };
@@ -202,10 +202,12 @@ export async function handleClientMessage(
202
202
  ...(runningTurn ? { turnId: runningTurn.turnId } : {}),
203
203
  });
204
204
  } catch (err) {
205
+ const message = err instanceof Error ? err.message : "Failed to load session";
206
+ host.reportTurnFailed("SESSION_LOAD_ERROR", { sessionId: msg.sessionId }, message);
205
207
  host.sendMessage(ws, {
206
208
  type: "error",
207
209
  code: "SESSION_LOAD_ERROR",
208
- message: err instanceof Error ? err.message : "Failed to load session",
210
+ message,
209
211
  sessionId: msg.sessionId,
210
212
  });
211
213
  }
package/src/ws/host.ts CHANGED
@@ -67,6 +67,25 @@ export interface WsHostOptions {
67
67
  wsRate?: { ratePerSecond: number; burst: number };
68
68
  }
69
69
 
70
+ /** Identity of one turn, as it appears on a log record. */
71
+ export interface TurnLogContext {
72
+ sessionId?: string | null;
73
+ turnId?: string | null;
74
+ providerId?: string | null;
75
+ }
76
+
77
+ /**
78
+ * Log attributes for one turn. Null fields are omitted rather than stringified
79
+ * — a new session has no sessionId until `session_info` names it.
80
+ */
81
+ export function turnLogAttributes(turn: TurnLogContext): Record<string, string> {
82
+ return {
83
+ ...(turn.sessionId ? { "session.id": turn.sessionId } : {}),
84
+ ...(turn.turnId ? { "turn.id": turn.turnId } : {}),
85
+ ...(turn.providerId ? { profile: turn.providerId } : {}),
86
+ };
87
+ }
88
+
70
89
  /**
71
90
  * Everything one WebSocket coordinator instance owns: turn state, the session
72
91
  * catalog, the backend registry, the attached client sockets, branding copy,
@@ -88,6 +107,18 @@ export class WsHost {
88
107
  private readonly framesDropped: ReturnType<
89
108
  ReturnType<Observability["meter"]>["createCounter"]
90
109
  >;
110
+ private readonly turnsStarted: ReturnType<
111
+ ReturnType<Observability["meter"]>["createCounter"]
112
+ >;
113
+ private readonly turnsCompleted: ReturnType<
114
+ ReturnType<Observability["meter"]>["createCounter"]
115
+ >;
116
+ private readonly turnsFailed: ReturnType<
117
+ ReturnType<Observability["meter"]>["createCounter"]
118
+ >;
119
+ private readonly wsErrors: ReturnType<
120
+ ReturnType<Observability["meter"]>["createCounter"]
121
+ >;
91
122
 
92
123
  constructor(options: WsHostOptions) {
93
124
  this.registry = options.registry;
@@ -100,11 +131,22 @@ export class WsHost {
100
131
  this.wsRate =
101
132
  options.wsRate && options.wsRate.ratePerSecond > 0 ? options.wsRate : null;
102
133
  this.log = this.observability.logger("ws");
103
- this.framesDropped = this.observability
104
- .meter("ws")
105
- .createCounter("ws.frames.dropped", {
106
- description: "Inbound frames refused before reaching a handler",
107
- });
134
+ const meter = this.observability.meter("ws");
135
+ this.framesDropped = meter.createCounter("ws.frames.dropped", {
136
+ description: "Inbound frames refused before reaching a handler",
137
+ });
138
+ this.turnsStarted = meter.createCounter("turns.started", {
139
+ description: "Turns handed to a backend",
140
+ });
141
+ this.turnsCompleted = meter.createCounter("turns.completed", {
142
+ description: "Turns whose backend call resolved without throwing",
143
+ });
144
+ this.turnsFailed = meter.createCounter("turns.failed", {
145
+ description: "Turn failures surfaced to the client, by error code",
146
+ });
147
+ this.wsErrors = meter.createCounter("ws.errors", {
148
+ description: "Transport errors reported by the socket layer",
149
+ });
108
150
  this.coordinator.log = this.log;
109
151
  }
110
152
 
@@ -130,9 +172,76 @@ export class WsHost {
130
172
  });
131
173
  }
132
174
 
175
+ /** A turn began executing: counted, and logged with its correlation ids. */
176
+ reportTurnStarted(turn: TurnLogContext): void {
177
+ this.turnsStarted.add(1);
178
+ this.log.emit({
179
+ severityText: "INFO",
180
+ body: "turn started",
181
+ attributes: turnLogAttributes(turn),
182
+ });
183
+ }
184
+
185
+ /** A turn's backend call resolved: counted, and logged with its duration. */
186
+ reportTurnCompleted(turn: TurnLogContext, durationMs: number): void {
187
+ this.turnsCompleted.add(1);
188
+ this.log.emit({
189
+ severityText: "INFO",
190
+ body: "turn completed",
191
+ attributes: { ...turnLogAttributes(turn), "duration.ms": durationMs },
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Record a turn failure the client is being told about. Before this existed
197
+ * every such failure was an error FRAME only — visible on one phone screen,
198
+ * absent from the server's own record.
199
+ *
200
+ * `code` is the bounded error-frame code (it feeds a counter attribute);
201
+ * `error` is the thrown message and rides only on the log record — never a
202
+ * caller-supplied frame body, per the reportDroppedFrame model.
203
+ */
204
+ reportTurnFailed(code: string, turn: TurnLogContext, error?: string): void {
205
+ this.turnsFailed.add(1, { code });
206
+ this.log.emit({
207
+ // A busy session is the client racing itself; everything else is a
208
+ // failure the operator should see.
209
+ severityText: code === "SESSION_BUSY" ? "WARN" : "ERROR",
210
+ body: "turn failed",
211
+ attributes: {
212
+ code,
213
+ ...turnLogAttributes(turn),
214
+ ...(error ? { error } : {}),
215
+ },
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Record a socket that closed abnormally. This is the transport-error signal
221
+ * available on Bun: hono's Bun adapter dispatches only open/message/close
222
+ * (never WSEvents.onError, and Bun's ServerWebSocket has no error callback),
223
+ * so a transport failure surfaces as a close with an abnormal code.
224
+ */
225
+ reportAbnormalClose(code: number): void {
226
+ this.wsErrors.add(1, { "close.code": code });
227
+ this.log.emit({
228
+ severityText: "WARN",
229
+ body: "websocket closed abnormally",
230
+ attributes: { "close.code": code },
231
+ });
232
+ }
233
+
133
234
  /** Fan a frame out to every attached client (size-bounded per frame). */
134
235
  sendToClients(msg: ServerMessage): void {
135
- this.clients.broadcast(msg);
236
+ this.clients.broadcast(msg, () => {
237
+ // Outbound counterpart of reportDroppedFrame: the peer never saw this
238
+ // frame. Counted only — a dead socket would otherwise WARN per frame
239
+ // until its onClose prunes it.
240
+ this.framesDropped.add(1, {
241
+ reason: "broadcast_send_failed",
242
+ direction: "outbound",
243
+ });
244
+ });
136
245
  }
137
246
 
138
247
  /** Send a frame to one specific socket (size-bounded). */
@@ -5,7 +5,13 @@ import { makeBridge, emitTurnError } from "./bridge.js";
5
5
  import { resolveTurnTarget } from "./routing.js";
6
6
  import type { QueuedFollowUp, RunningTurn } from "./turns.js";
7
7
  import { queuedBytes, queuedFollowUpBytes } from "./turns.js";
8
- import { MAX_SESSION_QUEUE, QUEUE_MAX_BYTES, QUEUE_WARN_BYTES, type WsHost } from "./host.js";
8
+ import {
9
+ MAX_SESSION_QUEUE,
10
+ QUEUE_MAX_BYTES,
11
+ QUEUE_WARN_BYTES,
12
+ turnLogAttributes,
13
+ type WsHost,
14
+ } from "./host.js";
9
15
 
10
16
  /**
11
17
  * Run one session slot: the initial turn, then any queued follow-up turns in
@@ -30,10 +36,14 @@ export async function runSession(
30
36
  try {
31
37
  target = await resolveTurnTarget(host.registry, host.catalog, initial.sessionId, initial.providerId);
32
38
  } catch (err) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ // No turn exists yet — routing failed before one was minted — so the
41
+ // report carries only the session identity the client asked for.
42
+ host.reportTurnFailed("BACKEND_ERROR", { sessionId: initial.sessionId }, message);
33
43
  host.sendToClients({
34
44
  type: "error",
35
45
  code: "BACKEND_ERROR",
36
- message: err instanceof Error ? err.message : String(err),
46
+ message,
37
47
  ...(initial.sessionId ? { sessionId: initial.sessionId } : {}),
38
48
  });
39
49
  return;
@@ -66,6 +76,7 @@ export async function runSession(
66
76
  timeoutHandle: setTimeout(() => {}, 0),
67
77
  queue: [],
68
78
  cancelled: false,
79
+ lastResult: null,
69
80
  };
70
81
  clearTimeout(turn.timeoutHandle);
71
82
  coordinator.running.add(turn);
@@ -90,7 +101,9 @@ export async function runSession(
90
101
  host.log.emit({
91
102
  severityText: "WARN",
92
103
  body: "turn timed out",
93
- attributes: { "timeout.ms": host.turnTimeoutMs },
104
+ // Read from the live turn: session_info may have named the session
105
+ // after this timer was armed.
106
+ attributes: { ...turnLogAttributes(turn), "timeout.ms": host.turnTimeoutMs },
94
107
  });
95
108
  abortController.abort();
96
109
  // Reject any pending interactive request for this turn too. A bridge
@@ -102,6 +115,8 @@ export async function runSession(
102
115
  turn.timeoutHandle = timeoutHandle;
103
116
 
104
117
  const bridge = makeBridge(host, turn, text, backend.id);
118
+ const startedAt = Date.now();
119
+ host.reportTurnStarted(turn);
105
120
  try {
106
121
  await backend.startTurn({
107
122
  prompt: text,
@@ -109,9 +124,21 @@ export async function runSession(
109
124
  sessionId: resumeId,
110
125
  profileId,
111
126
  signal: abortController.signal,
127
+ // The budget this very timer enforces, so the backend can put the
128
+ // real number in front of the model instead of it finding the cap
129
+ // mid-flight.
130
+ turnBudgetMs: host.turnTimeoutMs,
112
131
  bridge,
113
132
  ...(client ? { client } : {}),
114
133
  });
134
+ // A resolved startTurn is not a successful turn: backends resolve for
135
+ // runtime failures and report them on the terminal result frame, which
136
+ // the bridge recorded on the turn.
137
+ if (turn.lastResult === "error") {
138
+ host.reportTurnFailed("BACKEND_RESULT_ERROR", turn);
139
+ } else {
140
+ host.reportTurnCompleted(turn, Date.now() - startedAt);
141
+ }
115
142
  } catch (err) {
116
143
  emitTurnError(host, turn, err);
117
144
  } finally {
@@ -127,8 +154,10 @@ export async function runSession(
127
154
 
128
155
  if (!turn.cancelled && turn.queue.length > 0) {
129
156
  next = turn.queue.shift()!;
130
- // A queued follow-up is its own turn — give it a fresh identity.
157
+ // A queued follow-up is its own turn — give it a fresh identity, and
158
+ // its own terminal disposition.
131
159
  turn.turnId = crypto.randomUUID();
160
+ turn.lastResult = null;
132
161
  }
133
162
  }
134
163
  } finally {
@@ -168,11 +197,10 @@ export async function handleChatMessage(
168
197
  // device snapshot is deliberately not forwarded: a follow-up joins a
169
198
  // turn whose system prompt was already built and cannot be revised.
170
199
  backend.followUp({ sessionId, prompt: text, attachments }).catch((err) => {
200
+ const message = err instanceof Error ? err.message : String(err);
201
+ host.reportTurnFailed("FOLLOWUP_FAILED", runningTurn, message);
171
202
  host.sendToClients(
172
- withTurnScope(
173
- { type: "error", code: "FOLLOWUP_FAILED", message: err instanceof Error ? err.message : String(err) },
174
- runningTurn
175
- )
203
+ withTurnScope({ type: "error", code: "FOLLOWUP_FAILED", message }, runningTurn)
176
204
  );
177
205
  });
178
206
  } else {
@@ -1,4 +1,5 @@
1
1
  import type { Database } from "bun:sqlite";
2
+ import type { Logger } from "@opentelemetry/api-logs";
2
3
  import type { ServerResultMessage } from "@schlessera/brain-ui-sdk/protocol";
3
4
 
4
5
  /**
@@ -42,7 +43,20 @@ const UPSERT_SESSION_SQL = `INSERT INTO sessions (id, title, created_at, last_ac
42
43
  backend_id = COALESCE(excluded.backend_id, backend_id)`;
43
44
 
44
45
  /** SQLite-backed catalog over the app's own database (injected by createApp). */
45
- export function createSessionCatalog(db: () => Database): SessionCatalog {
46
+ export function createSessionCatalog(db: () => Database, log?: Logger): SessionCatalog {
47
+ // Persistence stays non-throwing — losing one accounting row must not kill
48
+ // the turn — but never silent: a full disk or locked database would
49
+ // otherwise drop every session record with nothing anywhere.
50
+ const reportWriteFailure = (sessionId: string, err: unknown): void => {
51
+ log?.emit({
52
+ severityText: "WARN",
53
+ body: "session persistence failed",
54
+ attributes: {
55
+ "session.id": sessionId,
56
+ error: err instanceof Error ? err.message : String(err),
57
+ },
58
+ });
59
+ };
46
60
  return {
47
61
  getStoredProviderId(sessionId) {
48
62
  const row = db()
@@ -72,7 +86,9 @@ export function createSessionCatalog(db: () => Database): SessionCatalog {
72
86
  providerId,
73
87
  backendId
74
88
  );
75
- } catch {}
89
+ } catch (err) {
90
+ reportWriteFailure(sessionId, err);
91
+ }
76
92
  },
77
93
 
78
94
  persistSession(msg, promptText, providerId, backendId) {
@@ -90,7 +106,9 @@ export function createSessionCatalog(db: () => Database): SessionCatalog {
90
106
  providerId,
91
107
  backendId
92
108
  );
93
- } catch {}
109
+ } catch (err) {
110
+ reportWriteFailure(msg.sessionId, err);
111
+ }
94
112
  },
95
113
  };
96
114
  }
package/src/ws/turns.ts CHANGED
@@ -2,10 +2,15 @@ import type { Logger } from "@opentelemetry/api-logs";
2
2
  import type {
3
3
  AgentBackend,
4
4
  PermissionDecision,
5
+ PermissionRequest,
5
6
  AskUserResult,
6
7
  LocationFix,
7
8
  } from "@schlessera/brain-ui-sdk/server";
8
- import type { ChatImageAttachment, ClientEnvironment } from "@schlessera/brain-ui-sdk/protocol";
9
+ import type {
10
+ AskUserQuestion,
11
+ ChatImageAttachment,
12
+ ClientEnvironment,
13
+ } from "@schlessera/brain-ui-sdk/protocol";
9
14
 
10
15
  export interface QueuedFollowUp {
11
16
  text: string;
@@ -55,6 +60,14 @@ export interface RunningTurn {
55
60
  timeoutHandle: ReturnType<typeof setTimeout>;
56
61
  queue: QueuedFollowUp[];
57
62
  cancelled: boolean;
63
+ /**
64
+ * Terminal disposition of the CURRENT turn's `result` frame, when one has
65
+ * streamed. Backends RESOLVE startTurn for runtime failures (the failure
66
+ * rides the result frame as outcome "error"), so completion accounting must
67
+ * read this — a resolved startTurn alone does not mean the turn succeeded.
68
+ * Reset when a queued follow-up becomes the next turn.
69
+ */
70
+ lastResult: "success" | "error" | "cancelled" | null;
58
71
  /**
59
72
  * Correlation id the client minted for this NEW conversation, echoed back on
60
73
  * `session_info` so the client can tell its own turn's identity from a
@@ -69,12 +82,21 @@ export interface RunningTurn {
69
82
  export interface PendingApproval {
70
83
  turn: RunningTurn;
71
84
  turnId: string;
85
+ /**
86
+ * The original request, kept so the card can be RE-DELIVERED to a client
87
+ * that connects later. On a phone the socket drops every time the screen
88
+ * locks; an approval must survive that and reappear, not silently die.
89
+ */
90
+ request: PermissionRequest;
72
91
  resolve: (decision: PermissionDecision) => void;
73
92
  }
74
93
 
75
94
  export interface PendingAskUser {
76
95
  turn: RunningTurn;
77
96
  turnId: string;
97
+ /** Kept for re-delivery on reconnect, like PendingApproval.request. */
98
+ requestId: string;
99
+ questions: AskUserQuestion[];
78
100
  resolve: (result: AskUserResult) => void;
79
101
  reject: (err: Error) => void;
80
102
  }
@@ -160,6 +182,20 @@ export class TurnCoordinator {
160
182
  p.reject(new Error(reason));
161
183
  this.pendingAskUser.delete(id);
162
184
  }
185
+ this.drainClientBoundForTurn(turn, reason);
186
+ }
187
+
188
+ /**
189
+ * Reject only the requests that NEED a live client at this instant —
190
+ * location and mask, whose sibling handlers already fail fast with no
191
+ * client attached. Approvals and ask-user cards deliberately survive a
192
+ * disconnect: on a phone the socket drops at every screen lock, and before
193
+ * this split a pending approval was silently denied the moment the screen
194
+ * went dark ("Client disconnected") while one raised DURING the dark parked
195
+ * until the turn timeout. Both now hold, bounded by the turn timeout, and
196
+ * re-deliver on reconnect.
197
+ */
198
+ drainClientBoundForTurn(turn: RunningTurn, reason: string): void {
163
199
  for (const [id, p] of this.pendingLocation) {
164
200
  if (p.turn !== turn) continue;
165
201
  p.reject(new Error(reason));