@schlessera/brain-ui-server 0.34.1 → 0.35.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 (169) hide show
  1. package/README.md +28 -0
  2. package/dist/activity/notify.d.ts.map +1 -1
  3. package/dist/activity/notify.js +18 -8
  4. package/dist/activity/notify.js.map +1 -1
  5. package/dist/activity/push-sender.d.ts +7 -3
  6. package/dist/activity/push-sender.d.ts.map +1 -1
  7. package/dist/activity/push-sender.js +45 -11
  8. package/dist/activity/push-sender.js.map +1 -1
  9. package/dist/activity/query.d.ts.map +1 -1
  10. package/dist/activity/query.js +8 -0
  11. package/dist/activity/query.js.map +1 -1
  12. package/dist/activity/recorder.d.ts +11 -1
  13. package/dist/activity/recorder.d.ts.map +1 -1
  14. package/dist/activity/recorder.js +52 -2
  15. package/dist/activity/recorder.js.map +1 -1
  16. package/dist/activity/runtime.d.ts.map +1 -1
  17. package/dist/activity/runtime.js +2 -5
  18. package/dist/activity/runtime.js.map +1 -1
  19. package/dist/activity/sql.d.ts +3 -3
  20. package/dist/activity/sql.d.ts.map +1 -1
  21. package/dist/activity/sql.js +7 -5
  22. package/dist/activity/sql.js.map +1 -1
  23. package/dist/activity/store.d.ts +7 -0
  24. package/dist/activity/store.d.ts.map +1 -1
  25. package/dist/activity/store.js +15 -3
  26. package/dist/activity/store.js.map +1 -1
  27. package/dist/activity/stream.d.ts +3 -1
  28. package/dist/activity/stream.d.ts.map +1 -1
  29. package/dist/activity/stream.js +32 -17
  30. package/dist/activity/stream.js.map +1 -1
  31. package/dist/app-env.d.ts +8 -0
  32. package/dist/app-env.d.ts.map +1 -0
  33. package/dist/app-env.js +2 -0
  34. package/dist/app-env.js.map +1 -0
  35. package/dist/app.d.ts.map +1 -1
  36. package/dist/app.js +28 -3
  37. package/dist/app.js.map +1 -1
  38. package/dist/brain/client.d.ts +4 -2
  39. package/dist/brain/client.d.ts.map +1 -1
  40. package/dist/brain/client.js +60 -20
  41. package/dist/brain/client.js.map +1 -1
  42. package/dist/brain/types.d.ts +9 -0
  43. package/dist/brain/types.d.ts.map +1 -1
  44. package/dist/cron/run-job.d.ts.map +1 -1
  45. package/dist/cron/run-job.js +60 -10
  46. package/dist/cron/run-job.js.map +1 -1
  47. package/dist/db/principals.d.ts +72 -0
  48. package/dist/db/principals.d.ts.map +1 -0
  49. package/dist/db/principals.js +294 -0
  50. package/dist/db/principals.js.map +1 -0
  51. package/dist/files/walker.d.ts.map +1 -1
  52. package/dist/files/walker.js +6 -1
  53. package/dist/files/walker.js.map +1 -1
  54. package/dist/index.d.ts +4 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/middleware/auth.d.ts +37 -9
  59. package/dist/middleware/auth.d.ts.map +1 -1
  60. package/dist/middleware/auth.js +148 -74
  61. package/dist/middleware/auth.js.map +1 -1
  62. package/dist/middleware/passkeys.d.ts +9 -7
  63. package/dist/middleware/passkeys.d.ts.map +1 -1
  64. package/dist/middleware/passkeys.js +25 -7
  65. package/dist/middleware/passkeys.js.map +1 -1
  66. package/dist/middleware/principals.d.ts +18 -0
  67. package/dist/middleware/principals.d.ts.map +1 -0
  68. package/dist/middleware/principals.js +138 -0
  69. package/dist/middleware/principals.js.map +1 -0
  70. package/dist/middleware/require-owner.d.ts +5 -0
  71. package/dist/middleware/require-owner.d.ts.map +1 -0
  72. package/dist/middleware/require-owner.js +10 -0
  73. package/dist/middleware/require-owner.js.map +1 -0
  74. package/dist/observability/loggers.js +1 -1
  75. package/dist/observability/loggers.js.map +1 -1
  76. package/dist/routes/activity.d.ts.map +1 -1
  77. package/dist/routes/activity.js +3 -0
  78. package/dist/routes/activity.js.map +1 -1
  79. package/dist/routes/brain.d.ts.map +1 -1
  80. package/dist/routes/brain.js +102 -63
  81. package/dist/routes/brain.js.map +1 -1
  82. package/dist/routes/files.d.ts.map +1 -1
  83. package/dist/routes/files.js +7 -2
  84. package/dist/routes/files.js.map +1 -1
  85. package/dist/routes/push.d.ts +61 -2
  86. package/dist/routes/push.d.ts.map +1 -1
  87. package/dist/routes/push.js +8 -3
  88. package/dist/routes/push.js.map +1 -1
  89. package/dist/routes/sessions.d.ts.map +1 -1
  90. package/dist/routes/sessions.js +30 -4
  91. package/dist/routes/sessions.js.map +1 -1
  92. package/dist/share/staging.d.ts.map +1 -1
  93. package/dist/share/staging.js +11 -4
  94. package/dist/share/staging.js.map +1 -1
  95. package/dist/ws/bridge.d.ts.map +1 -1
  96. package/dist/ws/bridge.js +6 -2
  97. package/dist/ws/bridge.js.map +1 -1
  98. package/dist/ws/clients.d.ts +9 -2
  99. package/dist/ws/clients.d.ts.map +1 -1
  100. package/dist/ws/clients.js +44 -6
  101. package/dist/ws/clients.js.map +1 -1
  102. package/dist/ws/connection.d.ts +7 -4
  103. package/dist/ws/connection.d.ts.map +1 -1
  104. package/dist/ws/connection.js +83 -12
  105. package/dist/ws/connection.js.map +1 -1
  106. package/dist/ws/dispatch.d.ts +12 -1
  107. package/dist/ws/dispatch.d.ts.map +1 -1
  108. package/dist/ws/dispatch.js +46 -6
  109. package/dist/ws/dispatch.js.map +1 -1
  110. package/dist/ws/history.d.ts.map +1 -1
  111. package/dist/ws/history.js +7 -5
  112. package/dist/ws/history.js.map +1 -1
  113. package/dist/ws/host.d.ts +22 -0
  114. package/dist/ws/host.d.ts.map +1 -1
  115. package/dist/ws/host.js +39 -1
  116. package/dist/ws/host.js.map +1 -1
  117. package/dist/ws/run-session.d.ts +7 -7
  118. package/dist/ws/run-session.d.ts.map +1 -1
  119. package/dist/ws/run-session.js +208 -52
  120. package/dist/ws/run-session.js.map +1 -1
  121. package/dist/ws/shrink.d.ts +3 -1
  122. package/dist/ws/shrink.d.ts.map +1 -1
  123. package/dist/ws/shrink.js +46 -16
  124. package/dist/ws/shrink.js.map +1 -1
  125. package/dist/ws/turns.d.ts +58 -3
  126. package/dist/ws/turns.d.ts.map +1 -1
  127. package/dist/ws/turns.js +101 -2
  128. package/dist/ws/turns.js.map +1 -1
  129. package/migrations/011_principals.sql +13 -0
  130. package/migrations/012_activity_principals.sql +12 -0
  131. package/migrations/013_push_subscription_principals.sql +7 -0
  132. package/migrations/014_invalidate_legacy_sessions.sql +14 -0
  133. package/package.json +3 -3
  134. package/src/activity/notify.ts +16 -7
  135. package/src/activity/push-sender.ts +79 -16
  136. package/src/activity/query.ts +8 -0
  137. package/src/activity/recorder.ts +68 -3
  138. package/src/activity/runtime.ts +2 -5
  139. package/src/activity/sql.ts +7 -5
  140. package/src/activity/store.ts +32 -1
  141. package/src/activity/stream.ts +37 -17
  142. package/src/app-env.ts +8 -0
  143. package/src/app.ts +33 -4
  144. package/src/brain/client.ts +61 -20
  145. package/src/brain/types.ts +10 -0
  146. package/src/cron/run-job.ts +41 -17
  147. package/src/db/principals.ts +452 -0
  148. package/src/files/walker.ts +5 -1
  149. package/src/index.ts +6 -1
  150. package/src/middleware/auth.ts +241 -81
  151. package/src/middleware/passkeys.ts +35 -14
  152. package/src/middleware/principals.ts +189 -0
  153. package/src/middleware/require-owner.ts +13 -0
  154. package/src/observability/loggers.ts +1 -1
  155. package/src/routes/activity.ts +3 -0
  156. package/src/routes/brain.ts +107 -70
  157. package/src/routes/files.ts +10 -5
  158. package/src/routes/push.ts +11 -5
  159. package/src/routes/sessions.ts +45 -20
  160. package/src/share/staging.ts +11 -4
  161. package/src/ws/bridge.ts +14 -2
  162. package/src/ws/clients.ts +52 -7
  163. package/src/ws/connection.ts +98 -20
  164. package/src/ws/dispatch.ts +66 -6
  165. package/src/ws/history.ts +7 -5
  166. package/src/ws/host.ts +59 -1
  167. package/src/ws/run-session.ts +247 -74
  168. package/src/ws/shrink.ts +48 -14
  169. package/src/ws/turns.ts +136 -5
@@ -46,8 +46,21 @@ export interface TurnRecorder {
46
46
  readonly runId: string;
47
47
  observeFrame(msg: ServerMessage): void;
48
48
  observeActivity(event: BackendActivityEvent): void;
49
+ /** Record that this turn's principal was revoked while work kept running. */
50
+ recordPrincipalRevocation(principalId: string): void;
51
+ /** Record a live follow-up that joined this already-running turn. */
52
+ recordFollowUp(principalId: string): void;
53
+ /** Record who cancelled this turn or an interactive prompt within it. */
54
+ recordCancellation(principalId: string, kind?: "turn" | "ask_user"): void;
55
+ /** Record who answered an ask-user interaction. */
56
+ recordAskUserResponse(principalId: string): void;
49
57
  /** The user's approval decision arrived for a gated tool call. */
50
- onApprovalDecision(toolUseId: string, allowed: boolean): void;
58
+ onApprovalDecision(
59
+ toolUseId: string,
60
+ decision: "allow" | "always_allow" | "deny",
61
+ requestKind: "tool" | "command",
62
+ principalId?: string
63
+ ): void;
51
64
  /**
52
65
  * Close the turn: merge the buffered result enrichment into the root's
53
66
  * single terminal write, cascade-cancel anything left open, roll up.
@@ -64,6 +77,8 @@ export function createTurnRecorder(
64
77
  profileId?: string;
65
78
  /** Billing classification of that profile, resolved at run start (U3). */
66
79
  billingMode?: BillingMode;
80
+ /** Principal that initiated this turn; absent means unattributed. */
81
+ principalId?: string;
67
82
  }
68
83
  ): TurnRecorder {
69
84
  const { store, onWrite, log } = deps;
@@ -76,6 +91,7 @@ export function createTurnRecorder(
76
91
  let resultOutcome: SpanOutcome | null = null;
77
92
  let resultUsage: SpanUsage | undefined;
78
93
  let resultAttrs: Record<string, unknown> | undefined;
94
+ let principalRevocationRecorded = false;
79
95
 
80
96
  const guard = (fn: () => void) => {
81
97
  try {
@@ -99,6 +115,7 @@ export function createTurnRecorder(
99
115
  kind: "turn",
100
116
  origin: "session",
101
117
  sessionId,
118
+ principalId: turn.principalId,
102
119
  // Profile + billing ride the ROOT span so the rollup can price the run
103
120
  // without any registry or env lookup of its own (a root missing the
104
121
  // billing attr falls back to env classification at rollup time).
@@ -253,9 +270,57 @@ export function createTurnRecorder(
253
270
  });
254
271
  },
255
272
 
256
- onApprovalDecision(toolUseId, allowed) {
273
+ recordPrincipalRevocation(principalId) {
257
274
  guard(() => {
258
- if (allowed) {
275
+ if (finished || principalRevocationRecorded) return;
276
+ ensureRoot();
277
+ store.appendEvent(rootSpanId, "principal_revoked", { principalId });
278
+ principalRevocationRecorded = true;
279
+ onWrite?.();
280
+ });
281
+ },
282
+
283
+ recordFollowUp(principalId) {
284
+ guard(() => {
285
+ if (finished) return;
286
+ ensureRoot();
287
+ store.appendEvent(rootSpanId, "follow_up", { principalId });
288
+ onWrite?.();
289
+ });
290
+ },
291
+
292
+ recordCancellation(principalId, kind = "turn") {
293
+ guard(() => {
294
+ if (finished) return;
295
+ ensureRoot();
296
+ store.appendEvent(rootSpanId, `${kind}_cancelled`, { principalId });
297
+ onWrite?.();
298
+ });
299
+ },
300
+
301
+ recordAskUserResponse(principalId) {
302
+ guard(() => {
303
+ if (finished) return;
304
+ ensureRoot();
305
+ store.appendEvent(rootSpanId, "ask_user_response", { principalId });
306
+ onWrite?.();
307
+ });
308
+ },
309
+
310
+ onApprovalDecision(toolUseId, decision, requestKind, principalId) {
311
+ guard(() => {
312
+ if (principalId) {
313
+ // One tool can raise several approval requests. The span-level actor
314
+ // remains the latest responder for compact views, while this
315
+ // append-only event preserves every decision and actor in order.
316
+ store.appendEvent(toolUseId, "approval_decision", {
317
+ principalId,
318
+ decision,
319
+ requestKind,
320
+ });
321
+ store.patchSpan(toolUseId, { principalId });
322
+ }
323
+ if (decision !== "deny") {
259
324
  // Everything before this moment was approval wait, not execution.
260
325
  store.patchSpan(toolUseId, { waitUntil: Date.now() });
261
326
  } else {
@@ -58,11 +58,8 @@ export function createActivityRuntime(
58
58
  const store = createActivityStore(db, deps.pricing ? { pricing: deps.pricing } : {});
59
59
  const stream = createActivityStream(store, log);
60
60
 
61
- // The notifier is created BEFORE the boot sweep: its change cursor starts
62
- // at the current head, so the terminal changes the sweep writes are above
63
- // it and the first tick turns restart-interrupted runs into failure
64
- // intents. Created after, the sweep's changes would sit below the cursor
65
- // and a turn killed by a restart would never be notified.
61
+ // The notifier resumes its persisted cursor, including terminal changes
62
+ // committed while this server was down and interruptions from the boot sweep.
66
63
  const notifier = createActivityNotifier({
67
64
  db,
68
65
  store,
@@ -17,11 +17,12 @@ export const ACTIVITY_SQL = {
17
17
  "SELECT span_id FROM activity_spans WHERE run_id = ? AND outcome IS NULL ORDER BY started_at DESC",
18
18
  spansByRun: "SELECT * FROM activity_spans WHERE run_id = ? ORDER BY started_at",
19
19
  upsertRollup: `INSERT INTO activity_run_rollups
20
- (run_id, origin, name, session_id, job_name, started_at, ended_at, outcome,
20
+ (run_id, origin, name, session_id, job_name, principal_id, principal_label,
21
+ principal_kind, started_at, ended_at, outcome,
21
22
  duration_ms, span_count, input_tokens, output_tokens, cache_read_tokens,
22
23
  cache_creation_tokens, cost_usd, effective_cost_usd, billing_mode,
23
24
  pricing_estimate, failure_reason, detail_pruned)
24
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
25
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
25
26
  ON CONFLICT(run_id) DO UPDATE SET
26
27
  ended_at = excluded.ended_at, outcome = excluded.outcome,
27
28
  duration_ms = excluded.duration_ms, span_count = excluded.span_count,
@@ -48,10 +49,11 @@ export const ACTIVITY_SQL = {
48
49
  END,
49
50
  failure_reason = excluded.failure_reason`,
50
51
  insertSpan: `INSERT INTO activity_spans
51
- (span_id, run_id, parent_span_id, name, kind, origin, session_id, job_name,
52
+ (span_id, run_id, parent_span_id, name, kind, origin, session_id, job_name, principal_id,
52
53
  attrs, started_at, writer, last_heartbeat_at)
53
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
54
- patchSpan: `UPDATE activity_spans SET attrs = ?, wait_until = COALESCE(?, wait_until),
54
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
55
+ patchSpan: `UPDATE activity_spans SET principal_id = COALESCE(?, principal_id),
56
+ attrs = ?, wait_until = COALESCE(?, wait_until),
55
57
  input_tokens = COALESCE(?, input_tokens),
56
58
  output_tokens = COALESCE(?, output_tokens),
57
59
  cache_read_tokens = COALESCE(?, cache_read_tokens),
@@ -38,6 +38,7 @@ import {
38
38
  resolveStandalonePricingConfig,
39
39
  } from "../config/env.js";
40
40
  import { createModelPricing, type PricingRates } from "../pricing/model-pricing.js";
41
+ import type { PrincipalKind } from "../db/principals.js";
41
42
  import { ACTIVITY_SQL } from "./sql.js";
42
43
 
43
44
  export const SPAN_OUTCOMES = [
@@ -53,6 +54,12 @@ export type SpanOutcome = (typeof SPAN_OUTCOMES)[number];
53
54
  export type SpanKind = "turn" | "tool" | "subagent" | "cron";
54
55
  export type SpanOrigin = "session" | "cron";
55
56
 
57
+ function isPrincipalKind(value: unknown): value is PrincipalKind {
58
+ return (
59
+ value === "owner" || value === "agent" || value === "ambient" || value === "system"
60
+ );
61
+ }
62
+
56
63
  /** Token usage as spans carry it — OTel GenAI attribute semantics. */
57
64
  export interface SpanUsage {
58
65
  inputTokens?: number;
@@ -72,6 +79,7 @@ export interface SpanRow {
72
79
  origin: SpanOrigin;
73
80
  sessionId: string | null;
74
81
  jobName: string | null;
82
+ principalId: string | null;
75
83
  attrs: Record<string, unknown>;
76
84
  startedAt: number;
77
85
  waitUntil: number | null;
@@ -99,6 +107,9 @@ export interface RunRollupRow {
99
107
  name: string;
100
108
  sessionId: string | null;
101
109
  jobName: string | null;
110
+ principalId: string | null;
111
+ principalLabel: string | null;
112
+ principalKind: PrincipalKind | null;
102
113
  startedAt: number;
103
114
  endedAt: number | null;
104
115
  outcome: SpanOutcome | null;
@@ -131,6 +142,9 @@ export function rowToRunRollup(r: any): RunRollupRow {
131
142
  name: r.name,
132
143
  sessionId: r.session_id,
133
144
  jobName: r.job_name,
145
+ principalId: r.principal_id ?? null,
146
+ principalLabel: r.principal_label ?? null,
147
+ principalKind: isPrincipalKind(r.principal_kind) ? r.principal_kind : null,
134
148
  startedAt: r.started_at,
135
149
  endedAt: r.ended_at,
136
150
  outcome: r.outcome,
@@ -183,6 +197,7 @@ export interface StartSpanInput {
183
197
  origin: SpanOrigin;
184
198
  sessionId?: string;
185
199
  jobName?: string;
200
+ principalId?: string;
186
201
  attrs?: Record<string, unknown>;
187
202
  startedAt?: number;
188
203
  }
@@ -237,7 +252,12 @@ export interface ActivityStore {
237
252
  /** Non-terminal field updates (usage enrichment, wait boundary, attrs). */
238
253
  patchSpan(
239
254
  spanId: string,
240
- patch: { attrs?: Record<string, unknown>; usage?: SpanUsage; waitUntil?: number }
255
+ patch: {
256
+ principalId?: string;
257
+ attrs?: Record<string, unknown>;
258
+ usage?: SpanUsage;
259
+ waitUntil?: number;
260
+ }
241
261
  ): boolean;
242
262
  /**
243
263
  * Append one event; null (nothing written) for an unknown span. `cap`
@@ -347,6 +367,7 @@ export function createActivityStore(
347
367
  origin: r.origin,
348
368
  sessionId: r.session_id,
349
369
  jobName: r.job_name,
370
+ principalId: r.principal_id ?? null,
350
371
  attrs: safeParse(r.attrs) ?? {},
351
372
  startedAt: r.started_at,
352
373
  waitUntil: r.wait_until,
@@ -492,6 +513,11 @@ export function createActivityStore(
492
513
  ? (priced.estimate ? 1 : 0)
493
514
  : null;
494
515
  const costUsd = root.usage.costUsd ?? priced?.costUsd ?? null;
516
+ const principal = root.principalId
517
+ ? (db
518
+ .query("SELECT label, kind FROM principals WHERE id = ?")
519
+ .get(root.principalId) as { label: string; kind: PrincipalKind } | null)
520
+ : null;
495
521
 
496
522
  db.query(
497
523
  ACTIVITY_SQL.upsertRollup
@@ -511,6 +537,9 @@ export function createActivityStore(
511
537
  root.name,
512
538
  root.sessionId,
513
539
  root.jobName,
540
+ root.principalId,
541
+ principal?.label ?? null,
542
+ principal?.kind ?? null,
514
543
  root.startedAt,
515
544
  root.endedAt,
516
545
  root.outcome,
@@ -543,6 +572,7 @@ export function createActivityStore(
543
572
  input.origin,
544
573
  input.sessionId ?? null,
545
574
  input.jobName ?? null,
575
+ input.principalId ?? null,
546
576
  input.attrs ? JSON.stringify(input.attrs) : null,
547
577
  startedAt,
548
578
  writer,
@@ -568,6 +598,7 @@ export function createActivityStore(
568
598
  : existing.attrs;
569
599
  const u = patch.usage ?? {};
570
600
  db.query(ACTIVITY_SQL.patchSpan).run(
601
+ patch.principalId ?? null,
571
602
  attrs,
572
603
  patch.waitUntil ?? null,
573
604
  u.inputTokens ?? null,
@@ -77,6 +77,7 @@ export function toWireSpan(span: SpanRow): ActivitySpan {
77
77
  origin: span.origin,
78
78
  sessionId: span.sessionId ?? undefined,
79
79
  jobName: span.jobName ?? undefined,
80
+ principalId: span.principalId ?? undefined,
80
81
  startedAt: span.startedAt,
81
82
  waitUntil: span.waitUntil ?? undefined,
82
83
  endedAt: span.endedAt ?? undefined,
@@ -122,6 +123,9 @@ export function toWireRollup(r: RunRollupRow): ActivityRunRollup {
122
123
  name: r.name,
123
124
  sessionId: r.sessionId,
124
125
  jobName: r.jobName,
126
+ principalId: r.principalId,
127
+ principalLabel: r.principalLabel,
128
+ principalKind: r.principalKind,
125
129
  startedAt: r.startedAt,
126
130
  endedAt: r.endedAt,
127
131
  outcome: r.outcome,
@@ -136,10 +140,12 @@ export function toWireRollup(r: RunRollupRow): ActivityRunRollup {
136
140
  }
137
141
 
138
142
  export interface ActivityStream {
139
- handleSubscribe(ws: WSContext, msg: ClientActivitySubscribe): void;
143
+ handleSubscribe(ws: WSContext, msg: ClientActivitySubscribe, principalId: string): void;
140
144
  handleUnsubscribe(ws: WSContext, msg: ClientActivityUnsubscribe): void;
141
145
  /** Forget a closed socket. */
142
146
  dropConnection(ws: WSContext): void;
147
+ /** Forget every subscription attached by one revoked principal. */
148
+ dropFor(principalId: string): void;
143
149
  /** Drain new committed changes to subscribers. Reentrancy-safe. */
144
150
  pump(): void;
145
151
  /** Whether any connection currently watches the given session's activity. */
@@ -150,7 +156,10 @@ export interface ActivityStream {
150
156
  }
151
157
 
152
158
  export function createActivityStream(store: ActivityStore, log?: Logger): ActivityStream {
153
- const subscriptions = new Map<WSContext, Subscription[]>();
159
+ const subscriptions = new Map<
160
+ unknown,
161
+ { ws: WSContext; principalId: string; subscriptions: Subscription[] }
162
+ >();
154
163
  let cursor = store.latestChangeCursor();
155
164
  let pumping = false;
156
165
  let pollHandle: ReturnType<typeof setInterval> | null = null;
@@ -222,9 +231,9 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
222
231
  const span = change.kind === "span" ? change.span : undefined;
223
232
  if (span) noteSpan(span);
224
233
  const frame = deltaFrame(change);
225
- for (const [ws, subs] of subscriptions) {
226
- if (subs.some((s) => matches(s, change.runId, span))) {
227
- sendTo(ws, frame);
234
+ for (const entry of subscriptions.values()) {
235
+ if (entry.subscriptions.some((s) => matches(s, change.runId, span))) {
236
+ sendTo(entry.ws, frame);
228
237
  }
229
238
  }
230
239
  }
@@ -310,7 +319,7 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
310
319
  }
311
320
 
312
321
  return {
313
- handleSubscribe(ws, msg) {
322
+ handleSubscribe(ws, msg, principalId) {
314
323
  // A scoped view without its scope id can never match anything and
315
324
  // would only leak an all-runs snapshot shape — ignore it up front.
316
325
  if ((msg.view === "session" && !msg.sessionId) || (msg.view === "run" && !msg.runId)) {
@@ -321,9 +330,11 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
321
330
  ...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
322
331
  ...(msg.runId ? { runId: msg.runId } : {}),
323
332
  };
324
- const list = subscriptions.get(ws) ?? [];
333
+ const identity = ws.raw ?? ws;
334
+ const existing = subscriptions.get(identity);
335
+ const list = existing?.subscriptions ?? [];
325
336
  if (!list.some((s) => subKey(s) === subKey(sub))) list.push(sub);
326
- subscriptions.set(ws, list);
337
+ subscriptions.set(identity, { ws, principalId, subscriptions: list });
327
338
  ensurePolling();
328
339
  try {
329
340
  sendSnapshot(ws, sub);
@@ -337,29 +348,38 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
337
348
  },
338
349
 
339
350
  handleUnsubscribe(ws, msg) {
340
- const list = subscriptions.get(ws);
341
- if (!list) return;
351
+ const identity = ws.raw ?? ws;
352
+ const entry = subscriptions.get(identity);
353
+ if (!entry) return;
342
354
  const key = subKey({
343
355
  view: msg.view,
344
356
  ...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
345
357
  ...(msg.runId ? { runId: msg.runId } : {}),
346
358
  });
347
- const remaining = list.filter((s) => subKey(s) !== key);
348
- if (remaining.length > 0) subscriptions.set(ws, remaining);
349
- else subscriptions.delete(ws);
359
+ const remaining = entry.subscriptions.filter((s) => subKey(s) !== key);
360
+ if (remaining.length > 0) {
361
+ subscriptions.set(identity, { ...entry, ws, subscriptions: remaining });
362
+ } else subscriptions.delete(identity);
350
363
  stopPollingIfIdle();
351
364
  },
352
365
 
353
366
  dropConnection(ws) {
354
- subscriptions.delete(ws);
367
+ subscriptions.delete(ws.raw ?? ws);
368
+ stopPollingIfIdle();
369
+ },
370
+
371
+ dropFor(principalId) {
372
+ for (const [identity, entry] of subscriptions) {
373
+ if (entry.principalId === principalId) subscriptions.delete(identity);
374
+ }
355
375
  stopPollingIfIdle();
356
376
  },
357
377
 
358
378
  pump,
359
379
 
360
380
  isWatched(scope) {
361
- for (const subs of subscriptions.values()) {
362
- for (const s of subs) {
381
+ for (const entry of subscriptions.values()) {
382
+ for (const s of entry.subscriptions) {
363
383
  if (s.view === "index") return true;
364
384
  if (scope.runId && s.view === "run" && s.runId === scope.runId) return true;
365
385
  if (scope.sessionId && s.view === "session" && s.sessionId === scope.sessionId)
@@ -371,7 +391,7 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
371
391
 
372
392
  subscriptionCount() {
373
393
  let n = 0;
374
- for (const subs of subscriptions.values()) n += subs.length;
394
+ for (const entry of subscriptions.values()) n += entry.subscriptions.length;
375
395
  return n;
376
396
  },
377
397
 
package/src/app-env.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { Principal } from "./db/principals.js";
2
+
3
+ /** Request-scoped values populated by the authentication boundary. */
4
+ export type AppEnv = {
5
+ Variables: {
6
+ principal?: Principal;
7
+ };
8
+ };
package/src/app.ts CHANGED
@@ -35,7 +35,9 @@ import {
35
35
  assertPasskeyConfig,
36
36
  type PasskeyContext,
37
37
  } from "./middleware/passkeys.js";
38
+ import { principalManagementRoutes } from "./middleware/principals.js";
38
39
  import { createUiDb } from "./db/client.js";
40
+ import { isUsablePrincipal, prunePrincipals, resolvePrincipal } from "./db/principals.js";
39
41
  import {
40
42
  getAutoAllowedTools,
41
43
  getBillingOverrides,
@@ -61,6 +63,7 @@ import { createSessionCatalog } from "./ws/session-catalog.js";
61
63
  import type { KeytermSettings } from "./voice/keyterm-builder.js";
62
64
  import { createObservability, type Observability } from "./observability/index.js";
63
65
  import { isSameOriginRequest, originPolicy } from "./middleware/origin.js";
66
+ import type { AppEnv } from "./app-env.js";
64
67
 
65
68
  export type { AppRenderer };
66
69
 
@@ -160,7 +163,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
160
163
  options.observability ?? createObservability({ minSeverity: config.logLevel });
161
164
  const auth: AuthRuntime = { ...config.auth, host: config.host };
162
165
 
163
- const app = new Hono();
166
+ const app = new Hono<AppEnv>();
164
167
  const authLog = observability.logger("auth");
165
168
  const authMode = resolveAuthMode(auth, authLog);
166
169
  // Validate inside the factory, not the bin entry: every consumer of the app
@@ -181,6 +184,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
181
184
  probeBrainCliVersion(config.brainPath, observability.logger("brain"));
182
185
  const dbLog = observability.logger("db");
183
186
  const db = createUiDb(config.dbPath, { log: dbLog });
187
+ prunePrincipals(db, Date.now());
184
188
  const brain = createBrainClient({ brainPath: config.brainPath });
185
189
  const cron = createCronScheduler({ db, brain, log: observability.logger("cron") });
186
190
 
@@ -225,6 +229,10 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
225
229
  maxConcurrentSessions: () => config.maxConcurrentSessions,
226
230
  wsRate: config.wsRate,
227
231
  wsMaxConnections: config.wsMaxConnections,
232
+ isPrincipalValid: (principal) => {
233
+ const current = resolvePrincipal(db, principal.id);
234
+ return current !== null && isUsablePrincipal(current, Date.now());
235
+ },
228
236
  toolPermissions: {
229
237
  isAutoAllowed: (toolName) => getAutoAllowedTools(db, dbLog).includes(toolName),
230
238
  add: (toolName) =>
@@ -233,6 +241,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
233
241
  activity: {
234
242
  store: activity.store,
235
243
  stream: activity.stream,
244
+ pushSender: activity.pushSender,
236
245
  query: activity.query,
237
246
  },
238
247
  });
@@ -244,7 +253,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
244
253
  });
245
254
  const passkeyCtx: PasskeyContext = {
246
255
  db,
247
- clients: host.clients,
256
+ revoker: host,
248
257
  webauthn: config.webauthn,
249
258
  auth,
250
259
  allowedOrigins: config.allowedOrigins,
@@ -268,6 +277,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
268
277
  if (c.req.path === "/api/health") return next();
269
278
  const startedAt = Date.now();
270
279
  await next();
280
+ const principal = c.get("principal");
271
281
  httpLog.emit({
272
282
  severityText: "INFO",
273
283
  body: "request",
@@ -276,6 +286,12 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
276
286
  path: c.req.path,
277
287
  status: c.res.status,
278
288
  "duration.ms": Date.now() - startedAt,
289
+ ...(principal
290
+ ? {
291
+ "auth.principal.id": principal.id,
292
+ "auth.principal.label": principal.label,
293
+ }
294
+ : {}),
279
295
  },
280
296
  });
281
297
  });
@@ -335,7 +351,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
335
351
  "/api",
336
352
  authRoutes(authMode, auth, {
337
353
  db,
338
- clients: host.clients,
354
+ revoker: host,
339
355
  passwordDisabled: (c) => passwordLoginDisabled(c, passkeyCtx),
340
356
  log: authLog,
341
357
  failures: authFailures,
@@ -351,6 +367,16 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
351
367
  // Passkey registration/management: after the guard, so a session is required
352
368
  // by mount position (the public assertion routes are registered above).
353
369
  app.route("/api", passkeyManagementRoutes(authMode, passkeyCtx));
370
+ // Principal management: also after the guard. Its router adds the narrower
371
+ // owner-only check after authentication has resolved the caller principal.
372
+ app.route(
373
+ "/api",
374
+ principalManagementRoutes(authMode, auth, {
375
+ db,
376
+ revoker: host,
377
+ log: authLog,
378
+ })
379
+ );
354
380
  app.route(
355
381
  "/api",
356
382
  createStatusRoutes({
@@ -414,9 +440,11 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
414
440
  if (!isSameOriginRequest(c, allowedOrigins, auth.trustProxy)) {
415
441
  return c.json({ error: "Cross-origin WebSocket rejected" }, 403);
416
442
  }
417
- if (!(await isWsAuthorized(c, authMode, auth, db))) {
443
+ const principal = await isWsAuthorized(c, authMode, auth, db);
444
+ if (!principal) {
418
445
  return c.json({ error: "Authentication required" }, 401);
419
446
  }
447
+ c.set("principal", principal);
420
448
  if (!host.clients.hasCapacity()) {
421
449
  host.reportRefusedConnection();
422
450
  return c.text("WebSocket connection limit reached", 503);
@@ -462,6 +490,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
462
490
  isTurnActive: () => host.coordinator.isTurnActive(),
463
491
  cancelActiveTurns: () => host.coordinator.cancelAll("Server shutting down"),
464
492
  close: () => {
493
+ host.close();
465
494
  activity.close();
466
495
  db.close();
467
496
  },
@@ -8,6 +8,7 @@ import type {
8
8
  BrainDocument,
9
9
  BrainStats,
10
10
  BrainSyncResult,
11
+ BrainAddResult,
11
12
  } from "./types.js";
12
13
 
13
14
  /**
@@ -20,7 +21,7 @@ export interface BrainClient {
20
21
  cliCommand(): string[];
21
22
  search(
22
23
  query: string,
23
- opts?: { type?: string; tag?: string; limit?: number; mode?: string }
24
+ opts?: { type?: string; tag?: string; limit?: number; mode?: string; signal?: AbortSignal }
24
25
  ): Promise<BrainSearchResponse>;
25
26
  briefing(): Promise<string>;
26
27
  stats(): Promise<BrainStats>;
@@ -36,7 +37,7 @@ export interface BrainClient {
36
37
  add(
37
38
  content: string,
38
39
  opts?: { type?: string; title?: string; tags?: string[] }
39
- ): Promise<void>;
40
+ ): Promise<BrainAddResult>;
40
41
  index(opts?: { force?: boolean }): Promise<void>;
41
42
  validate(): Promise<string>;
42
43
  /** Run `brain skills sync` — re-materialize every agent's skill links. */
@@ -157,25 +158,46 @@ export function probeBrainCliVersion(brainPath: string, log: Logger): void {
157
158
  }
158
159
  }
159
160
 
160
- export function createBrainClient(opts: { brainPath: string }): BrainClient {
161
+ /** Interactive search must finish or fail within a bounded time. */
162
+ const SEARCH_TIMEOUT_MS = 15_000;
163
+
164
+ export function createBrainClient(opts: { brainPath: string; searchTimeoutMs?: number }): BrainClient {
161
165
  const { brainPath } = opts;
166
+ const searchTimeoutMs = opts.searchTimeoutMs ?? SEARCH_TIMEOUT_MS;
167
+ if (!Number.isFinite(searchTimeoutMs) || searchTimeoutMs <= 0) {
168
+ throw new Error("searchTimeoutMs must be a positive finite number");
169
+ }
162
170
 
163
- async function execBrain(args: string[]): Promise<ExecResult> {
171
+ async function execBrain(args: string[], signal?: AbortSignal): Promise<ExecResult> {
172
+ signal?.throwIfAborted();
164
173
  const proc = Bun.spawn([...brainCliCommand(brainPath), ...args], {
165
174
  cwd: brainPath,
166
175
  stdout: "pipe",
167
176
  stderr: "pipe",
168
177
  // Force JSON output when not a TTY
169
178
  env: subprocessEnv("brainCli", { NO_COLOR: "1" }),
179
+ // Only read-only search passes a signal. Kill even a CLI that ignores
180
+ // SIGTERM; abandoning a request must not leave the local process alive.
181
+ ...(signal ? { signal, killSignal: "SIGKILL" } : {}),
170
182
  });
171
183
 
172
- const [stdout, stderr] = await Promise.all([
173
- new Response(proc.stdout).text(),
174
- new Response(proc.stderr).text(),
175
- ]);
176
- const exitCode = await proc.exited;
177
-
178
- return { stdout, stderr, exitCode };
184
+ try {
185
+ const [stdout, stderr, exitCode] = await Promise.all([
186
+ new Response(proc.stdout).text(),
187
+ new Response(proc.stderr).text(),
188
+ proc.exited,
189
+ ]);
190
+ signal?.throwIfAborted();
191
+ return { stdout, stderr, exitCode };
192
+ } catch (error) {
193
+ // A pipe-read failure also abandons search. Reap before its caller
194
+ // clears the deadline; otherwise that failure could orphan the child.
195
+ if (signal) {
196
+ proc.kill("SIGKILL");
197
+ await proc.exited;
198
+ }
199
+ throw error;
200
+ }
179
201
  }
180
202
 
181
203
  function parseJsonOutput<T>(result: ExecResult): T {
@@ -197,18 +219,29 @@ export function createBrainClient(opts: { brainPath: string }): BrainClient {
197
219
  return brainCliCommand(brainPath);
198
220
  },
199
221
 
200
- async search(query, opts) {
222
+ async search(query, searchOpts) {
201
223
  const args = ["search"];
202
- if (opts?.type) args.push("--type", opts.type);
203
- if (opts?.tag) args.push("--tag", opts.tag);
204
- if (opts?.limit) args.push("--limit", String(opts.limit));
205
- if (opts?.mode) args.push("--mode", opts.mode);
224
+ if (searchOpts?.type) args.push("--type", searchOpts.type);
225
+ if (searchOpts?.tag) args.push("--tag", searchOpts.tag);
226
+ if (searchOpts?.limit) args.push("--limit", String(searchOpts.limit));
227
+ if (searchOpts?.mode) args.push("--mode", searchOpts.mode);
206
228
  args.push("--", query);
207
229
 
208
- const result = await execBrain(args);
209
- const parsed = parseJsonOutput<BrainSearchResponse | BrainSearchResult[]>(result);
210
- // brain returns {results, warnings} since 22e82e1; tolerate the old bare array
211
- return Array.isArray(parsed) ? { results: parsed, warnings: [] } : parsed;
230
+ const deadline = new AbortController();
231
+ const timer = setTimeout(() => deadline.abort(
232
+ new DOMException("Search timed out. Please try again.", "TimeoutError")
233
+ ), searchTimeoutMs);
234
+ const signal = searchOpts?.signal
235
+ ? AbortSignal.any([searchOpts.signal, deadline.signal])
236
+ : deadline.signal;
237
+ try {
238
+ const result = await execBrain(args, signal);
239
+ const parsed = parseJsonOutput<BrainSearchResponse | BrainSearchResult[]>(result);
240
+ // brain returns {results, warnings} since 22e82e1; tolerate the old bare array
241
+ return Array.isArray(parsed) ? { results: parsed, warnings: [] } : parsed;
242
+ } finally {
243
+ clearTimeout(timer);
244
+ }
212
245
  },
213
246
 
214
247
  async briefing() {
@@ -279,6 +312,14 @@ export function createBrainClient(opts: { brainPath: string }): BrainClient {
279
312
  if (result.exitCode !== 0) {
280
313
  throw new Error(`brain add failed: ${result.stderr}`);
281
314
  }
315
+ const parsed = parseJsonOutput<BrainAddResult>(result);
316
+ if (!parsed || typeof parsed !== "object" || typeof parsed.path !== "string" ||
317
+ typeof parsed.indexed !== "boolean" || typeof parsed.title !== "string" ||
318
+ typeof parsed.type !== "string" || (parsed.indexError !== undefined && typeof parsed.indexError !== "string") ||
319
+ !["created", "appended"].includes(parsed.action)) {
320
+ throw new Error("Capture completed but its outcome could not be read. Check your files before adding it again.");
321
+ }
322
+ return parsed;
282
323
  },
283
324
 
284
325
  async index(opts) {