@schlessera/brain-ui-server 0.34.0 → 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 +34 -0
  20. package/dist/activity/sql.d.ts.map +1 -0
  21. package/dist/activity/sql.js +114 -0
  22. package/dist/activity/sql.js.map +1 -0
  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 +48 -113
  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 +122 -0
  140. package/src/activity/store.ts +73 -155
  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
@@ -0,0 +1,189 @@
1
+ import type { Logger } from "@opentelemetry/api-logs";
2
+ import type { Database } from "bun:sqlite";
3
+ import { Hono } from "hono";
4
+ import type { Context } from "hono";
5
+ import { generateSignedCookie } from "hono/cookie";
6
+
7
+ import type { AppEnv } from "../app-env.js";
8
+ import {
9
+ createPrincipal,
10
+ listLivePrincipals,
11
+ PrincipalCreatorNotUsableError,
12
+ PrincipalLimitError,
13
+ revokePrincipal,
14
+ } from "../db/principals.js";
15
+ import { readJsonBody } from "./body-limit.js";
16
+ import {
17
+ applyPrincipalRevocation,
18
+ SESSION_TTL_SECONDS,
19
+ type AuthMode,
20
+ type AuthRuntime,
21
+ } from "./auth.js";
22
+ import { requireJson } from "./origin.js";
23
+ import type { WsHost } from "../ws/host.js";
24
+ import { requireOwner } from "./require-owner.js";
25
+
26
+ const COOKIE_NAME = "brain_ui_session";
27
+ const SECONDS_PER_DAY = 24 * 60 * 60;
28
+ const DEFAULT_AGENT_TTL_DAYS = 7;
29
+ const MAX_AGENT_TTL_DAYS = SESSION_TTL_SECONDS / SECONDS_PER_DAY;
30
+ const MAX_LABEL_LENGTH = 64;
31
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
32
+
33
+ export interface PrincipalManagementContext {
34
+ db: Database;
35
+ revoker: Pick<WsHost, "revokePrincipals">;
36
+ log?: Logger;
37
+ }
38
+
39
+ function notEnabled(c: Context) {
40
+ return c.json({ error: "Principal management is not enabled" }, 400);
41
+ }
42
+
43
+ function sanitizeLabel(value: unknown): string {
44
+ return typeof value === "string"
45
+ ? value
46
+ .replace(CONTROL_CHARACTERS, "")
47
+ .trim()
48
+ .slice(0, MAX_LABEL_LENGTH)
49
+ .trim()
50
+ : "";
51
+ }
52
+
53
+ function cookieValue(serialized: string): string {
54
+ const valueStart = serialized.indexOf("=") + 1;
55
+ const valueEnd = serialized.indexOf(";", valueStart);
56
+ return decodeURIComponent(
57
+ serialized.slice(valueStart, valueEnd === -1 ? undefined : valueEnd)
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Owner-only delegated-principal routes. Mount AFTER the auth guard: the
63
+ * family middleware below authorizes the resolved principal, it does not
64
+ * authenticate the request itself.
65
+ */
66
+ export function principalManagementRoutes(
67
+ mode: AuthMode,
68
+ auth: AuthRuntime,
69
+ ctx: PrincipalManagementContext
70
+ ): Hono<AppEnv> {
71
+ const app = new Hono<AppEnv>();
72
+
73
+ app.use("/auth/principals/*", async (c, next) => {
74
+ if (mode !== "password") return notEnabled(c);
75
+ await next();
76
+ });
77
+ app.use("/auth/principals/*", requireOwner());
78
+
79
+ app.get("/auth/principals", (c) => {
80
+ const caller = c.get("principal")!;
81
+ const principals = listLivePrincipals(ctx.db, Date.now()).map((principal) => ({
82
+ id: principal.id,
83
+ kind: principal.kind,
84
+ auth_method: principal.authMethod,
85
+ label: principal.label,
86
+ created_at: principal.createdAt,
87
+ expires_at: principal.expiresAt,
88
+ last_seen_at: principal.lastSeenAt,
89
+ is_own: principal.id === caller.id,
90
+ }));
91
+ return c.json({ principals });
92
+ });
93
+
94
+ app.post("/auth/principals", requireJson(), async (c) => {
95
+ let body: { label?: unknown; ttlDays?: unknown };
96
+ try {
97
+ const result = await readJsonBody<{ label?: unknown; ttlDays?: unknown }>(c);
98
+ if (result instanceof Response) return result;
99
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
100
+ return c.json({ error: "Invalid request body" }, 400);
101
+ }
102
+ body = result;
103
+ } catch {
104
+ return c.json({ error: "Invalid request body" }, 400);
105
+ }
106
+
107
+ const label = sanitizeLabel(body.label);
108
+ if (!label) return c.json({ error: "label must be a non-empty string" }, 400);
109
+
110
+ const ttlDays =
111
+ body.ttlDays === undefined ? DEFAULT_AGENT_TTL_DAYS : body.ttlDays;
112
+ if (
113
+ typeof ttlDays !== "number" ||
114
+ !Number.isInteger(ttlDays) ||
115
+ ttlDays < 1 ||
116
+ ttlDays > MAX_AGENT_TTL_DAYS
117
+ ) {
118
+ return c.json(
119
+ { error: `ttlDays must be an integer between 1 and ${MAX_AGENT_TTL_DAYS}` },
120
+ 400
121
+ );
122
+ }
123
+
124
+ const caller = c.get("principal")!;
125
+ let principal;
126
+ try {
127
+ principal = createPrincipal(ctx.db, {
128
+ authMethod: "delegated",
129
+ label,
130
+ createdBy: caller.id,
131
+ ttlSeconds: ttlDays * SECONDS_PER_DAY,
132
+ });
133
+ } catch (error) {
134
+ if (error instanceof PrincipalCreatorNotUsableError) {
135
+ return c.json({ error: "Owner access required" }, 403);
136
+ }
137
+ if (!(error instanceof PrincipalLimitError)) throw error;
138
+ return c.json(
139
+ { error: "Principal capacity reached. Revoke another principal and try again." },
140
+ 503
141
+ );
142
+ }
143
+
144
+ const serialized = await generateSignedCookie(
145
+ COOKIE_NAME,
146
+ principal.id,
147
+ auth.cookieSecret ?? ""
148
+ );
149
+ const cookie = cookieValue(serialized);
150
+
151
+ ctx.log?.emit({
152
+ severityText: "INFO",
153
+ body: "delegated principal minted",
154
+ attributes: {
155
+ "auth.principal.id": caller.id,
156
+ "auth.target_principal.id": principal.id,
157
+ },
158
+ });
159
+ c.header("Cache-Control", "no-store");
160
+ return c.json({
161
+ id: principal.id,
162
+ label: principal.label,
163
+ expiresAt: principal.expiresAt,
164
+ cookie,
165
+ });
166
+ });
167
+
168
+ app.delete("/auth/principals/:id", (c) => {
169
+ const caller = c.get("principal")!;
170
+ const targetId = c.req.param("id");
171
+ const revokedIds = revokePrincipal(ctx.db, targetId, Date.now());
172
+ if (revokedIds.length === 0) {
173
+ return c.json({ error: "Unknown principal" }, 404);
174
+ }
175
+
176
+ applyPrincipalRevocation(ctx.revoker, revokedIds);
177
+ ctx.log?.emit({
178
+ severityText: "INFO",
179
+ body: "principal revoked",
180
+ attributes: {
181
+ "auth.principal.id": caller.id,
182
+ "auth.target_principal.id": targetId,
183
+ },
184
+ });
185
+ return c.json({ ok: true });
186
+ });
187
+
188
+ return app;
189
+ }
@@ -0,0 +1,13 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+
3
+ import type { AppEnv } from "../app-env.js";
4
+
5
+ /** Require the authenticated request principal to be the owner. */
6
+ export function requireOwner(): MiddlewareHandler<AppEnv> {
7
+ return async (c, next) => {
8
+ if (c.get("principal")?.kind !== "owner") {
9
+ return c.json({ error: "Owner access required" }, 403);
10
+ }
11
+ await next();
12
+ };
13
+ }
@@ -54,7 +54,7 @@ export interface ConsoleLoggerProviderOptions {
54
54
  function formatLine(record: CapturedLog): string {
55
55
  const attrs = Object.entries(record.attributes)
56
56
  .filter(([, v]) => v !== undefined)
57
- .map(([k, v]) => `${k}=${String(v)}`);
57
+ .map(([k, v]) => `${k}=${typeof v === "string" ? JSON.stringify(v) : String(v)}`);
58
58
  return `[${record.scope}] ${record.body}${attrs.length ? ` ${attrs.join(" ")}` : ""}`;
59
59
  }
60
60
 
@@ -137,6 +137,9 @@ export function createActivityRoutes(deps: {
137
137
  name: s.name,
138
138
  sessionId: s.sessionId,
139
139
  jobName: s.jobName,
140
+ principalId: s.principalId,
141
+ principalLabel: null,
142
+ principalKind: null,
140
143
  startedAt: s.startedAt,
141
144
  endedAt: null,
142
145
  outcome: null,
@@ -7,11 +7,14 @@ import {
7
7
  writeCache,
8
8
  type KeytermSettings,
9
9
  } from "../voice/keyterm-builder.js";
10
- import { existsSync } from "fs";
10
+ import { existsSync, realpathSync } from "fs";
11
11
  import { join } from "path";
12
12
  import { readJsonBody } from "../middleware/body-limit.js";
13
13
  import { requireJson } from "../middleware/origin.js";
14
14
 
15
+ // Shared across route instances, keyed by the canonical brain root.
16
+ const syncingRoots = new Set<string>();
17
+
15
18
  export interface BrainRoutesDeps {
16
19
  brain: BrainClient;
17
20
  brainPath: string;
@@ -39,6 +42,7 @@ function findWhatsupScript(brainPath: string): string | null {
39
42
 
40
43
  export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
41
44
  const { brain, brainPath, keyterms } = deps;
45
+ let indexing: Promise<void> | null = null;
42
46
 
43
47
  return new Hono()
44
48
  .get("/brain/search", async (c) => {
@@ -52,12 +56,13 @@ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
52
56
  tag: c.req.query("tag"),
53
57
  limit: c.req.query("limit") ? Number(c.req.query("limit")) : undefined,
54
58
  mode: c.req.query("mode"),
59
+ signal: c.req.raw.signal,
55
60
  });
56
61
  return c.json({ results, warnings });
57
62
  } catch (err) {
58
63
  return c.json(
59
64
  { error: err instanceof Error ? err.message : "Search failed" },
60
- 500
65
+ err instanceof Error && err.name === "TimeoutError" ? 504 : 500
61
66
  );
62
67
  }
63
68
  })
@@ -205,91 +210,123 @@ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
205
210
  // Stream sync output via SSE so the client sees live progress.
206
211
  // brain sync spawns a nested Claude Code process that can take minutes.
207
212
 
213
+ const syncRoot = realpathSync(brainPath);
214
+ if (syncingRoots.has(syncRoot)) {
215
+ return c.json({ error: "A sync is already running. Wait for it to finish before retrying." }, 409);
216
+ }
217
+ syncingRoots.add(syncRoot);
208
218
  return streamSSE(c, async (stream) => {
219
+ let keepalive: ReturnType<typeof setInterval> | undefined;
220
+ let disconnected = false;
221
+ stream.onAbort(() => { disconnected = true; });
209
222
  const send = (data: {
210
223
  type: string;
211
224
  text?: string;
212
225
  success?: boolean;
213
- }) => stream.writeSSE({ data: JSON.stringify(data) });
226
+ }) => disconnected ? Promise.resolve() : stream.writeSSE({ data: JSON.stringify(data) })
227
+ .catch(() => { disconnected = true; });
214
228
 
215
- await send({ type: "start", text: "Starting brain sync..." });
229
+ try {
230
+ await send({ type: "start", text: "Starting brain sync..." });
216
231
 
217
- // The nested Claude process can be silent for minutes; without traffic
218
- // the socket gets cut by idle timeouts (Bun's own, or a proxy's). SSE
219
- // comment lines are ignored by EventSource and data-line parsers.
220
- const keepalive = setInterval(() => {
221
- stream.write(": keepalive\n\n").catch(() => {});
222
- }, 15_000);
232
+ // The nested Claude process can be silent for minutes; without traffic
233
+ // the socket gets cut by idle timeouts (Bun's own, or a proxy's). SSE
234
+ // comment lines are ignored by EventSource and data-line parsers.
235
+ keepalive = setInterval(() => {
236
+ stream.write(": keepalive\n\n").catch(() => {});
237
+ }, 15_000);
223
238
 
224
- // Merge stderr into stdout — brain sync spawns a nested Claude Code
225
- // process whose output goes to stderr. The command comes from
226
- // brain.cliCommand() (packaged bin or legacy vendored script), passed to
227
- // bash as positional args rather than interpolated into the script, so a
228
- // BRAIN_PATH containing spaces or shell metacharacters stays inert.
229
- const [cliBin, ...cliArgs] = brain.cliCommand();
230
- const proc = Bun.spawn(
231
- ["bash", "-c", '"$0" "$@" 2>&1', cliBin!, ...cliArgs, "sync"],
232
- {
233
- cwd: brainPath,
234
- stdout: "pipe",
235
- stderr: "pipe",
236
- env: subprocessEnv("brainCli", { NO_COLOR: "1" }),
237
- }
238
- );
239
+ // Merge stderr into stdout — brain sync spawns a nested Claude Code
240
+ // process whose output goes to stderr. The command comes from
241
+ // brain.cliCommand() (packaged bin or legacy vendored script), passed to
242
+ // bash as positional args rather than interpolated into the script, so a
243
+ // BRAIN_PATH containing spaces or shell metacharacters stays inert.
244
+ const [cliBin, ...cliArgs] = brain.cliCommand();
245
+ const proc = Bun.spawn(
246
+ ["bash", "-c", '"$0" "$@" 2>&1', cliBin!, ...cliArgs, "sync"],
247
+ {
248
+ cwd: brainPath,
249
+ stdout: "pipe",
250
+ stderr: "pipe",
251
+ env: subprocessEnv("brainCli", { NO_COLOR: "1" }),
252
+ }
253
+ );
239
254
 
240
- const reader = proc.stdout.getReader();
241
- const decoder = new TextDecoder();
242
- let buffer = "";
243
- try {
244
- while (true) {
245
- const { done, value } = await reader.read();
246
- if (done) break;
247
- buffer += decoder.decode(value, { stream: true });
248
- const lines = buffer.split("\n");
249
- buffer = lines.pop() || "";
250
- for (const line of lines) {
251
- if (line.trim()) {
252
- await send({ type: "progress", text: line });
255
+ // Keep draining even when the SSE reader disconnects. The repository
256
+ // stays reserved until the actual sync process has exited.
257
+ const stderrDone = new Response(proc.stderr).text().catch(() => "");
258
+ const reader = proc.stdout.getReader();
259
+ const decoder = new TextDecoder();
260
+ let buffer = "";
261
+ try {
262
+ while (true) {
263
+ const { done, value } = await reader.read();
264
+ if (done) break;
265
+ buffer += decoder.decode(value, { stream: true });
266
+ const lines = buffer.split("\n");
267
+ buffer = lines.pop() || "";
268
+ for (const line of lines) {
269
+ if (line.trim()) {
270
+ await send({ type: "progress", text: line });
271
+ }
253
272
  }
254
273
  }
274
+ if (buffer.trim()) {
275
+ await send({ type: "progress", text: buffer });
276
+ }
277
+ } catch {} finally {
278
+ clearInterval(keepalive);
255
279
  }
256
- if (buffer.trim()) {
257
- await send({ type: "progress", text: buffer });
258
- }
259
- } catch {} finally {
260
- clearInterval(keepalive);
261
- }
262
280
 
263
- const exitCode = await proc.exited;
281
+ const exitCode = await proc.exited;
282
+ await stderrDone;
264
283
 
265
- // Rebuild voice keyterms cache after a successful sync.
266
- if (exitCode === 0) {
267
- try {
268
- const cache = buildKeyterms(keyterms);
269
- writeCache(keyterms, cache);
270
- await send({
271
- type: "progress",
272
- text: `[voice] Rebuilt keyterms cache (${cache.count} terms)`,
273
- });
274
- } catch (err) {
275
- await send({
276
- type: "progress",
277
- text: `[voice] Keyterm rebuild failed: ${
278
- err instanceof Error ? err.message : String(err)
279
- }`,
280
- });
284
+ // Rebuild voice keyterms cache after a successful sync.
285
+ if (exitCode === 0) {
286
+ try {
287
+ const cache = buildKeyterms(keyterms);
288
+ writeCache(keyterms, cache);
289
+ await send({
290
+ type: "progress",
291
+ text: `[voice] Rebuilt keyterms cache (${cache.count} terms)`,
292
+ });
293
+ } catch (err) {
294
+ await send({
295
+ type: "progress",
296
+ text: `[voice] Keyterm rebuild failed: ${
297
+ err instanceof Error ? err.message : String(err)
298
+ }`,
299
+ });
300
+ }
281
301
  }
282
- }
283
302
 
284
- await send({
285
- type: "done",
286
- success: exitCode === 0,
287
- text:
288
- exitCode === 0 ? "Sync completed" : `Sync failed (exit ${exitCode})`,
289
- });
303
+ await send({
304
+ type: "done",
305
+ success: exitCode === 0,
306
+ text:
307
+ exitCode === 0 ? "Sync completed" : `Sync failed (exit ${exitCode})`,
308
+ });
309
+ } catch (error) {
310
+ await send({ type: "done", success: false, text: error instanceof Error ? error.message : "Sync failed" });
311
+ } finally {
312
+ clearInterval(keepalive);
313
+ syncingRoots.delete(syncRoot);
314
+ }
290
315
  });
291
316
  })
292
317
 
318
+ // Recovery after a successful capture whose indexing failed. Coalesce
319
+ // concurrent retries and never submit the captured content a second time.
320
+ .post("/brain/index", requireJson(), async (c) => {
321
+ try {
322
+ if (!indexing) indexing = brain.index().finally(() => { indexing = null; });
323
+ await indexing;
324
+ return c.json({ success: true });
325
+ } catch (err) {
326
+ return c.json({ error: err instanceof Error ? err.message : "Indexing failed" }, 500);
327
+ }
328
+ })
329
+
293
330
  .post("/brain/add", requireJson(), async (c) => {
294
331
  const result = await readJsonBody<{
295
332
  content: string;
@@ -303,12 +340,12 @@ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
303
340
  return c.json({ error: "Field 'content' is required" }, 400);
304
341
  }
305
342
  try {
306
- await brain.add(body.content, {
343
+ const outcome = await brain.add(body.content, {
307
344
  type: body.type,
308
345
  title: body.title,
309
346
  tags: body.tags,
310
347
  });
311
- return c.json({ success: true });
348
+ return c.json({ success: true, ...outcome });
312
349
  } catch (err) {
313
350
  return c.json(
314
351
  { error: err instanceof Error ? err.message : "Add failed" },
@@ -29,6 +29,7 @@ function errorResponse(err: unknown, log?: Logger): { body: { error: string; siz
29
29
 
30
30
  export function createFilesRoutes(deps: { brainRoot: string; log?: Logger }): Hono {
31
31
  const { brainRoot, log } = deps;
32
+ let wikilinkBuild: Promise<void> | null = null;
32
33
  let wikilinkCache: { generatedAt: number; map: Record<string, string> } | null = null;
33
34
  return new Hono()
34
35
  .get("/files/tree", async (c) => {
@@ -87,13 +88,17 @@ export function createFilesRoutes(deps: { brainRoot: string; log?: Logger }): Ho
87
88
  const now = Date.now();
88
89
  try {
89
90
  if (refresh || !wikilinkCache || now - wikilinkCache.generatedAt > WIKILINK_TTL_MS) {
90
- const map = await buildWikilinkMap(brainRoot);
91
- wikilinkCache = { generatedAt: now, map };
91
+ if (!wikilinkBuild) {
92
+ wikilinkBuild = buildWikilinkMap(brainRoot).then((map) => {
93
+ wikilinkCache = { generatedAt: Date.now(), map };
94
+ }).finally(() => { wikilinkBuild = null; });
95
+ }
96
+ await wikilinkBuild;
92
97
  }
93
98
  return c.json({
94
- generatedAt: wikilinkCache.generatedAt,
95
- count: Object.keys(wikilinkCache.map).length,
96
- slugs: wikilinkCache.map,
99
+ generatedAt: wikilinkCache!.generatedAt,
100
+ count: Object.keys(wikilinkCache!.map).length,
101
+ slugs: wikilinkCache!.map,
97
102
  });
98
103
  } catch (err) {
99
104
  const { body, status } = errorResponse(err, log);
@@ -2,6 +2,7 @@ import { Hono } from "hono";
2
2
  import { z } from "zod";
3
3
 
4
4
  import type { PushSender } from "../activity/push-sender.js";
5
+ import type { AppEnv } from "../app-env.js";
5
6
  import { readJsonBody } from "../middleware/body-limit.js";
6
7
  import { requireJson } from "../middleware/origin.js";
7
8
 
@@ -26,10 +27,10 @@ const unsubscribeSchema = z.object({
26
27
  endpoint: z.url().max(2048),
27
28
  });
28
29
 
29
- export function createPushRoutes(deps: { sender: PushSender }): Hono {
30
+ export function createPushRoutes(deps: { sender: PushSender }) {
30
31
  const { sender } = deps;
31
32
 
32
- return new Hono()
33
+ return new Hono<AppEnv>()
33
34
  .get("/push/public-key", (c) => {
34
35
  try {
35
36
  return c.json({ publicKey: sender.publicKey() });
@@ -45,7 +46,7 @@ export function createPushRoutes(deps: { sender: PushSender }): Hono {
45
46
  try {
46
47
  // Endpoints are capability URLs — list only metadata.
47
48
  return c.json({
48
- subscriptions: sender.subscriptions().map((s) => ({
49
+ subscriptions: sender.subscriptions(c.get("principal")!.id).map((s) => ({
49
50
  label: s.label,
50
51
  createdAt: s.createdAt,
51
52
  lastUsedAt: s.lastUsedAt,
@@ -66,7 +67,10 @@ export function createPushRoutes(deps: { sender: PushSender }): Hono {
66
67
  const result = await readJsonBody(c);
67
68
  if (result instanceof Response) return result;
68
69
  const body = subscribeSchema.parse(result);
69
- sender.subscribe(body.subscription, body.label);
70
+ const principal = c.get("principal")!;
71
+ if (!sender.subscribe(body.subscription, principal.id, body.label)) {
72
+ return c.json({ error: "Authentication required" }, 401);
73
+ }
70
74
  return c.json({ ok: true });
71
75
  } catch (err) {
72
76
  return c.json(
@@ -81,7 +85,9 @@ export function createPushRoutes(deps: { sender: PushSender }): Hono {
81
85
  const result = await readJsonBody(c);
82
86
  if (result instanceof Response) return result;
83
87
  const body = unsubscribeSchema.parse(result);
84
- return c.json({ removed: sender.unsubscribe(body.endpoint) });
88
+ return c.json({
89
+ removed: sender.unsubscribe(body.endpoint, c.get("principal")!.id),
90
+ });
85
91
  } catch (err) {
86
92
  return c.json(
87
93
  { error: err instanceof Error ? err.message : "Bad request" },
@@ -1,5 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Database } from "bun:sqlite";
3
+ import type { AgentBackend } from "@schlessera/brain-ui-sdk/server";
3
4
  import type { BackendRegistry } from "../agent/backend.js";
4
5
 
5
6
  export function createSessionRoutes(deps: {
@@ -7,6 +8,30 @@ export function createSessionRoutes(deps: {
7
8
  db: Database;
8
9
  }): Hono {
9
10
  const { registry, db } = deps;
11
+ const pending = new Map<AgentBackend, ReturnType<AgentBackend["listSessions"]>>();
12
+
13
+ async function listSessions(backend: AgentBackend) {
14
+ let work = pending.get(backend);
15
+ if (!work) {
16
+ work = Promise.resolve().then(() => backend.listSessions());
17
+ pending.set(backend, work);
18
+ const clear = () => { if (pending.get(backend) === work) pending.delete(backend); };
19
+ void work.then(clear, clear);
20
+ }
21
+ // Backends have no cancellation API. Reuse unfinished work so retries
22
+ // cannot pile up scans behind a stalled backend.
23
+ let timer: ReturnType<typeof setTimeout> | undefined;
24
+ try {
25
+ return await Promise.race([
26
+ work,
27
+ new Promise<never>((_, reject) => {
28
+ timer = setTimeout(() => reject(new Error("Session listing timed out")), 3_000);
29
+ }),
30
+ ]);
31
+ } finally {
32
+ clearTimeout(timer);
33
+ }
34
+ }
10
35
 
11
36
  function getStoredBackendId(sessionId: string): string | null {
12
37
  const row = db
@@ -30,28 +55,28 @@ export function createSessionRoutes(deps: {
30
55
  .all() as Array<{ id: string; cost: number | null; turns: number | null }>
31
56
  ).map((r) => [r.id, r])
32
57
  );
33
- const sessions = (
34
- await Promise.all(
35
- backends.map(async (backend) =>
36
- (await backend.listSessions()).map((session) => {
37
- const stored = accounting.get(session.id);
38
- return {
39
- ...session,
40
- ...(stored?.cost && session.totalCostUsd === 0
41
- ? { totalCostUsd: stored.cost }
42
- : {}),
43
- ...(stored?.turns && session.numTurns === 0
44
- ? { numTurns: stored.turns }
45
- : {}),
46
- backendId: backend.id,
47
- };
48
- })
49
- )
58
+ const results = await Promise.allSettled(
59
+ backends.map(async (backend) =>
60
+ (await listSessions(backend)).map((session) => {
61
+ const stored = accounting.get(session.id);
62
+ return {
63
+ ...session,
64
+ ...(stored?.cost && session.totalCostUsd === 0
65
+ ? { totalCostUsd: stored.cost }
66
+ : {}),
67
+ ...(stored?.turns && session.numTurns === 0
68
+ ? { numTurns: stored.turns }
69
+ : {}),
70
+ backendId: backend.id,
71
+ };
72
+ })
50
73
  )
51
- )
52
- .flat()
74
+ );
75
+ const sessions = results.flatMap((result) => result.status === "fulfilled" ? result.value : [])
53
76
  .sort((a, b) => b.lastActiveAt - a.lastActiveAt);
54
- return c.json({ sessions });
77
+ const unavailableBackends = results.flatMap((result, index) =>
78
+ result.status === "rejected" ? [backends[index]!.id] : []);
79
+ return c.json({ sessions, ...(unavailableBackends.length ? { unavailableBackends } : {}) });
55
80
  } catch (err) {
56
81
  return c.json(
57
82
  { error: err instanceof Error ? err.message : "Failed to list sessions" },
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from "@opentelemetry/api-logs";
2
- import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, readdir, rename, rm, writeFile, realpath, lstat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import {
5
5
  SHARE_MAX_FILES,
@@ -406,9 +406,15 @@ export async function pruneShareStaging(
406
406
  now = Date.now(),
407
407
  log?: Logger
408
408
  ): Promise<number> {
409
- const root = shareStagingRoot(brainPath);
409
+ let root: string;
410
410
  let entries;
411
411
  try {
412
+ root = await safeResolve(SHARE_STAGING_DIR, brainPath);
413
+ // Even an internal symlink could point at unrelated content. Only prune
414
+ // the actual staging directory; the brain root itself may be a symlink.
415
+ if (await realpath(root) !== join(await realpath(brainPath), SHARE_STAGING_DIR)) {
416
+ throw new Error("Share staging directory must not traverse a symlink");
417
+ }
412
418
  entries = await readdir(root, { withFileTypes: true });
413
419
  } catch (err) {
414
420
  // Nothing staged yet is the ordinary case. Anything else — a permission
@@ -430,8 +436,9 @@ export async function pruneShareStaging(
430
436
  const path = join(root, entry.name);
431
437
  const ttl = entry.name.startsWith(".") ? PARTIAL_TTL_MS : SHARE_STAGING_TTL_MS;
432
438
  try {
433
- const info = await stat(path);
434
- if (now - info.mtimeMs <= ttl) continue;
439
+ await safeResolve(`${SHARE_STAGING_DIR}/${entry.name}`, brainPath);
440
+ const info = await lstat(path);
441
+ if (!info.isDirectory() || now - info.mtimeMs <= ttl) continue;
435
442
  await rm(path, { recursive: true, force: true });
436
443
  removed += 1;
437
444
  } catch {