@unifedev/thread-pages 0.3.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +77 -129
  2. package/dist/server.js +11779 -11801
  3. package/dist/server.meta.json +2 -2
  4. package/docs/B1-OWN-FILES.md +133 -0
  5. package/docs/FOR-PAGE-AUTHORS-1.1.md +167 -0
  6. package/docs/UPGRADING.md +53 -0
  7. package/package.json +26 -18
  8. package/server.ts +3 -2175
  9. package/src/agent/cli.ts +193 -0
  10. package/src/agent/guide.ts +450 -0
  11. package/src/agent/instruction.ts +59 -0
  12. package/src/agent/seed/seed.ts +73 -0
  13. package/{theme.ts → src/agent/seed/theme-css.ts} +9 -11
  14. package/src/agent/starter-hub.ts +217 -0
  15. package/src/bb/activity.ts +59 -0
  16. package/src/bb/bb-host.ts +280 -0
  17. package/src/bb/public-origin.ts +45 -0
  18. package/src/config/settings.ts +82 -0
  19. package/src/domain/capabilities/contract.ts +48 -0
  20. package/src/domain/capabilities/index.ts +10 -0
  21. package/src/domain/capabilities/protocol.ts +113 -0
  22. package/src/domain/capabilities/registry.ts +48 -0
  23. package/src/domain/capabilities/renamed.ts +34 -0
  24. package/src/domain/capabilities/schema.ts +198 -0
  25. package/src/domain/capabilities/specs.ts +479 -0
  26. package/src/domain/eligibility.ts +43 -0
  27. package/src/domain/errors.ts +116 -0
  28. package/src/domain/html/document.ts +109 -0
  29. package/src/domain/html/escape.ts +16 -0
  30. package/src/domain/ids.ts +37 -0
  31. package/src/domain/json/canonical.ts +19 -0
  32. package/src/domain/json/strict-json.ts +139 -0
  33. package/src/domain/limits.ts +98 -0
  34. package/src/domain/rate-limit.ts +64 -0
  35. package/src/domain/revision.ts +27 -0
  36. package/src/domain/submissions/idempotency.ts +59 -0
  37. package/src/domain/submissions/message.ts +42 -0
  38. package/src/domain/submissions/parse.ts +105 -0
  39. package/src/domain/tokens/action-token.ts +52 -0
  40. package/src/domain/tokens/confirmation.ts +99 -0
  41. package/src/domain/tokens/mac.ts +50 -0
  42. package/src/generated/kernel-runtime.ts +3 -0
  43. package/src/generated/shell-runtime.ts +3 -0
  44. package/src/host/contract.ts +65 -0
  45. package/src/host/types.ts +89 -0
  46. package/src/pages/inline.ts +277 -0
  47. package/src/pages/layout.ts +65 -0
  48. package/src/pages/page-store.ts +170 -0
  49. package/src/pages/site.ts +36 -0
  50. package/src/plugin.ts +81 -0
  51. package/src/runtime/kernel/anchors.ts +45 -0
  52. package/src/runtime/kernel/api.ts +15 -0
  53. package/src/runtime/kernel/bridge-client.ts +148 -0
  54. package/src/runtime/kernel/dirty.ts +51 -0
  55. package/src/runtime/kernel/forms.ts +114 -0
  56. package/src/runtime/kernel/install.ts +156 -0
  57. package/src/runtime/kernel/labels.ts +98 -0
  58. package/src/runtime/kernel/main.ts +6 -0
  59. package/src/runtime/kernel/readonly.ts +75 -0
  60. package/src/runtime/shared/protocol.ts +125 -0
  61. package/src/runtime/shell/confirm.ts +70 -0
  62. package/src/runtime/shell/install.ts +79 -0
  63. package/src/runtime/shell/main.ts +12 -0
  64. package/src/runtime/shell/navigate.ts +64 -0
  65. package/src/runtime/shell/poll.ts +125 -0
  66. package/src/runtime/shell/relay.ts +185 -0
  67. package/src/serving/action-request.ts +32 -0
  68. package/src/serving/bridge/dispatcher.ts +112 -0
  69. package/src/serving/bridge/handler.ts +37 -0
  70. package/src/serving/bridge/handlers/index.ts +26 -0
  71. package/src/serving/bridge/handlers/navigation.ts +43 -0
  72. package/src/serving/bridge/handlers/reads.ts +186 -0
  73. package/src/serving/bridge/handlers/writes.ts +175 -0
  74. package/src/serving/bridge/selection-store.ts +58 -0
  75. package/src/serving/bridge-route.ts +23 -0
  76. package/src/serving/context.ts +34 -0
  77. package/src/serving/document-route.ts +37 -0
  78. package/src/serving/home-route.ts +23 -0
  79. package/src/serving/responses.ts +81 -0
  80. package/src/serving/routes.ts +26 -0
  81. package/src/serving/session-access.ts +22 -0
  82. package/src/serving/shell-html.ts +77 -0
  83. package/src/serving/shell-route.ts +51 -0
  84. package/src/serving/signing-key.ts +25 -0
  85. package/src/serving/submit-route.ts +47 -0
  86. package/src/serving/upload-route.ts +46 -0
  87. package/tsconfig.json +10 -6
  88. package/ARCHITECTURE.md +0 -230
  89. package/PLUGIN_OVERVIEW.md +0 -83
  90. package/authoring.ts +0 -368
  91. package/bridge.ts +0 -1721
  92. package/docs/MODEL.md +0 -211
  93. package/docs/ROADMAP.md +0 -96
  94. package/home.ts +0 -419
  95. package/page.ts +0 -782
package/server.ts CHANGED
@@ -1,2179 +1,7 @@
1
- import { randomBytes } from "node:crypto";
2
-
3
1
  import type { BbPluginApi } from "@get-bb/plugin-sdk";
2
+ import { createPlugin } from "./src/plugin.ts";
4
3
 
5
- import {
6
- AUTHORING_GUIDE,
7
- DEFAULT_AGENT_INSTRUCTION,
8
- DEFAULT_PAGE_SEED,
9
- renderHomeSeed,
10
- renderPageSeed,
11
- } from "./authoring.js";
12
- import {
13
- BRIDGE_MAX_SERIALIZED_BYTES,
14
- authorizeBridgeInvocation,
15
- capabilityDescriptors,
16
- completeBridgeInvocation,
17
- createCapabilityRegistry,
18
- createTrustedOuterConfirmation,
19
- makeBridgeFailureResponse,
20
- resolveBridgeInvocation,
21
- strictParityCapabilityRegistry,
22
- type BridgeErrorCode,
23
- type JsonValue,
24
- type ThreadActivityParams,
25
- type ThreadReplyParams,
26
- type ThreadsContinueParams,
27
- type ThreadsSnapshotParams,
28
- type ThreadsSpawnParams,
29
- type ThreadTargetParams,
30
- } from "./bridge.js";
31
- import {
32
- CONFIRMATION_TTL_MS,
33
- MAX_PAGE_BYTES,
34
- MAX_UPLOAD_BYTES,
35
- PAGE_FILENAME,
36
- UPLOAD_DIRNAME,
37
- VIEWER_TOKEN_TTL_MS,
38
- escapeHtml,
39
- etagForHash,
40
- formatSubmissionMessage,
41
- formatThreadReplyMessage,
42
- parseSubmission,
43
- renderDocument,
44
- safeUploadName,
45
- signConfirmationChallenge,
46
- verifyConfirmationChallenge,
47
- renderOuterPage,
48
- sha256Text,
49
- signPageToken,
50
- verifyPageToken,
51
- type PageTokenPayload,
52
- } from "./page.js";
53
-
54
- const MAX_SUBMISSION_BYTES = 64 * 1024;
55
- const ASSET_DIRNAME = "thread-page-assets";
56
- const ASSET_PREVIEW_TTL_MS = 10 * 60 * 1000;
57
- const MAX_CACHE_VALUE_BYTES = 240 * 1024;
58
- const MAX_MEMORY_CACHE_BYTES = 8 * 1024 * 1024;
59
- const MAX_MEMORY_CACHE_ENTRIES = 32;
60
- const SUBMISSION_TTL_MS = 5 * 60 * 1000;
61
- const MAX_RECENT_SUBMISSIONS = 512;
62
- const MAX_BRIDGE_BODY_BYTES = BRIDGE_MAX_SERIALIZED_BYTES + 8 * 1024;
63
- const SIGNING_KEY_KV_KEY = "page-signing-key:v2";
64
- export const ENABLED_BRIDGE_METHODS = new Set([
65
- "context.get",
66
- "thread.activity",
67
- "thread.reply",
68
- "threads.snapshot",
69
- "projects.list",
70
- "providers.list",
71
- "threads.continue",
72
- "threads.spawn",
73
- "threads.archive",
74
- "threads.stop",
75
- "threads.openPage",
76
- "threads.openBb",
77
- "navigation.openExternal",
78
- "storage.get",
79
- "storage.set",
80
- "projects.browse",
81
- "projects.create",
82
- ]);
83
-
84
- const SELECTION_TTL_MS = 10 * 60 * 1000;
85
- const enabledBridgeRegistry = createCapabilityRegistry(
86
- strictParityCapabilityRegistry
87
- .list()
88
- .filter((capability) => ENABLED_BRIDGE_METHODS.has(capability.method)),
89
- );
90
-
91
- interface CachedPage {
92
- fragment: string;
93
- hash: string;
94
- updatedAt: number;
95
- }
96
-
97
- interface LoadedPage extends CachedPage {
98
- stale: boolean;
99
- }
100
-
101
- class PageNotFoundError extends Error {
102
- constructor() {
103
- super("Thread page has not been initialized");
104
- this.name = "PageNotFoundError";
105
- }
106
- }
107
-
108
- class PageTooLargeError extends Error {
109
- constructor() {
110
- super(`Thread page exceeds ${MAX_PAGE_BYTES} bytes`);
111
- this.name = "PageTooLargeError";
112
- }
113
- }
114
-
115
- class PageUnavailableError extends Error {
116
- constructor(options?: ErrorOptions) {
117
- super("Thread page source is unavailable", options);
118
- this.name = "PageUnavailableError";
119
- }
120
- }
121
-
122
- function isValidThreadId(value: string | null): value is string {
123
- return value !== null && /^[A-Za-z0-9_-]{3,128}$/.test(value);
124
- }
125
-
126
- function errorText(error: unknown): string {
127
- return error instanceof Error ? error.message : String(error);
128
- }
129
-
130
- function recordValue(value: unknown): Record<string, unknown> | null {
131
- return value && typeof value === "object" && !Array.isArray(value)
132
- ? (value as Record<string, unknown>)
133
- : null;
134
- }
135
-
136
- const ACTIVITY_LABELS: Record<string, readonly [string, string]> = {
137
- agentMessage: ["Writing", "Wrote"],
138
- reasoning: ["Thinking", "Thought"],
139
- };
140
-
141
- function activityState(thread: Record<string, unknown>) {
142
- const runtime = recordValue(thread.runtime);
143
- const display =
144
- typeof runtime?.displayStatus === "string"
145
- ? runtime.displayStatus
146
- : typeof thread.status === "string"
147
- ? thread.status
148
- : "idle";
149
- if (["active", "starting", "provisioning", "stopping"].includes(display)) {
150
- return "working" as const;
151
- }
152
- if (display === "error") return "failed" as const;
153
- if (thread.hasPendingInteraction === true) return "waiting" as const;
154
- return "idle" as const;
155
- }
156
-
157
- function activityItems(events: readonly unknown[], limit: number) {
158
- const out: Array<{
159
- kind: string;
160
- done: boolean;
161
- atMs: number;
162
- label: string;
163
- text: string;
164
- }> = [];
165
- for (const rawEvent of events) {
166
- const event = recordValue(rawEvent);
167
- if (!event) continue;
168
- const type = event.type;
169
- if (type !== "item/started" && type !== "item/completed") continue;
170
- const done = type === "item/completed";
171
- const data = recordValue(event.data);
172
- const item = recordValue(data?.item) ?? data;
173
- if (!item || typeof item.type !== "string") continue;
174
- const presentation = recordValue(item.presentation);
175
- const labels = recordValue(presentation?.label);
176
- const presented = labels?.[done ? "completed" : "pending"];
177
- const fallback = ACTIVITY_LABELS[item.type];
178
- const label =
179
- typeof presented === "string"
180
- ? presented
181
- : fallback
182
- ? fallback[done ? 1 : 0]
183
- : null;
184
- if (!label) continue;
185
- const detail =
186
- presentation?.title ?? item.command ?? item.text ?? item.name ?? "";
187
- const atMs =
188
- typeof event.createdAt === "number" && Number.isFinite(event.createdAt)
189
- ? Math.max(0, Math.trunc(event.createdAt))
190
- : 0;
191
- out.push({
192
- kind: item.type.slice(0, 80),
193
- done,
194
- atMs,
195
- label: label.trim().slice(0, 80) || (done ? "Completed" : "Working"),
196
- text: String(detail).replace(/\s+/g, " ").trim().slice(0, 200),
197
- });
198
- }
199
- out.reverse();
200
- return out.slice(-limit);
201
- }
202
-
203
- /** The snapshot's own status vocabulary, not bb's internal one. */
204
- function snapshotStatus(thread: Record<string, unknown>) {
205
- const state = activityState(thread);
206
- if (state === "working") return "active" as const;
207
- if (state === "failed") return "failed" as const;
208
- if (state === "waiting") return "waiting" as const;
209
- return "idle" as const;
210
- }
211
-
212
- function isMissingFileError(error: unknown): boolean {
213
- if (error && typeof error === "object") {
214
- const record = error as Record<string, unknown>;
215
- if (record.code === "ENOENT" || record.status === 404) return true;
216
- }
217
- return /\b(enoent|not found|does not exist|no such file)\b/i.test(
218
- errorText(error),
219
- );
220
- }
221
-
222
- function storageRoot(storageRootPath: string): string {
223
- return storageRootPath.replace(/[\\/]+$/, "");
224
- }
225
-
226
- function pagePath(storageRootPath: string): string {
227
- return `${storageRoot(storageRootPath)}/${PAGE_FILENAME}`;
228
- }
229
-
230
- function assetDirPath(storageRootPath: string): string {
231
- return `${storageRoot(storageRootPath)}/${ASSET_DIRNAME}`;
232
- }
233
-
234
- function uploadDirPath(storageRootPath: string): string {
235
- return `${storageRoot(storageRootPath)}/${UPLOAD_DIRNAME}`;
236
- }
237
-
238
- function cacheKey(threadId: string): string {
239
- return `cache:${threadId}`;
240
- }
241
-
242
- function isEligibleThread(thread: {
243
- archivedAt: number | null;
244
- deletedAt: number | null;
245
- parentThreadId: string | null;
246
- sourceThreadId: string | null;
247
- visibility: "hidden" | "visible";
248
- }): boolean {
249
- return (
250
- thread.visibility === "visible" &&
251
- thread.parentThreadId === null &&
252
- thread.sourceThreadId === null &&
253
- thread.archivedAt === null &&
254
- thread.deletedAt === null
255
- );
256
- }
257
-
258
- function isCachedPage(value: unknown): value is CachedPage {
259
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
260
- const entry = value as Record<string, unknown>;
261
- return (
262
- typeof entry.fragment === "string" &&
263
- Buffer.byteLength(entry.fragment, "utf8") <= MAX_PAGE_BYTES &&
264
- typeof entry.hash === "string" &&
265
- /^[a-f0-9]{64}$/.test(entry.hash) &&
266
- sha256Text(entry.fragment) === entry.hash &&
267
- typeof entry.updatedAt === "number" &&
268
- Number.isFinite(entry.updatedAt)
269
- );
270
- }
271
-
272
- function createPageStore(bb: BbPluginApi) {
273
- const memory = new Map<string, CachedPage>();
274
- let memoryBytes = 0;
275
-
276
- function cacheBytes(page: CachedPage): number {
277
- return Buffer.byteLength(page.fragment, "utf8") + 128;
278
- }
279
-
280
- function retain(threadId: string, page: CachedPage): void {
281
- const existing = memory.get(threadId);
282
- if (existing) {
283
- memoryBytes -= cacheBytes(existing);
284
- memory.delete(threadId);
285
- }
286
- memory.set(threadId, page);
287
- memoryBytes += cacheBytes(page);
288
- while (
289
- memory.size > MAX_MEMORY_CACHE_ENTRIES ||
290
- memoryBytes > MAX_MEMORY_CACHE_BYTES
291
- ) {
292
- const oldest = memory.keys().next().value as string | undefined;
293
- if (!oldest) break;
294
- const removed = memory.get(oldest);
295
- memory.delete(oldest);
296
- if (removed) memoryBytes -= cacheBytes(removed);
297
- }
298
- }
299
-
300
- async function remember(threadId: string, page: CachedPage): Promise<void> {
301
- const previous = memory.get(threadId);
302
- retain(threadId, page);
303
- if (previous?.hash === page.hash) return;
304
- if (Buffer.byteLength(JSON.stringify(page), "utf8") > MAX_CACHE_VALUE_BYTES) {
305
- bb.log.debug(
306
- `Thread Page ${threadId} is too large for the durable offline cache`,
307
- );
308
- try {
309
- await bb.storage.kv.delete(cacheKey(threadId));
310
- } catch (error) {
311
- bb.log.warn(
312
- `Could not clear obsolete offline cache for ${threadId}: ${errorText(error)}`,
313
- );
314
- }
315
- return;
316
- }
317
- try {
318
- await bb.storage.kv.set(cacheKey(threadId), page);
319
- } catch (error) {
320
- bb.log.warn(
321
- `Could not update offline cache for ${threadId}: ${errorText(error)}`,
322
- );
323
- }
324
- }
325
-
326
- async function cached(threadId: string): Promise<CachedPage | null> {
327
- const resident = memory.get(threadId);
328
- if (resident) {
329
- retain(threadId, resident);
330
- return resident;
331
- }
332
- try {
333
- const stored = await bb.storage.kv.get<unknown>(cacheKey(threadId));
334
- if (!isCachedPage(stored)) return null;
335
- retain(threadId, stored);
336
- return stored;
337
- } catch (error) {
338
- bb.log.warn(
339
- `Could not read offline cache for ${threadId}: ${errorText(error)}`,
340
- );
341
- return null;
342
- }
343
- }
344
-
345
- async function load(
346
- threadId: string,
347
- signal?: AbortSignal,
348
- ): Promise<LoadedPage> {
349
- try {
350
- const location = await bb.sdk.threads.storageLocation({
351
- threadId,
352
- signal,
353
- });
354
- const file = await bb.sdk.files.read({
355
- hostId: location.hostId,
356
- path: pagePath(location.storageRootPath),
357
- rootPath: location.storageRootPath,
358
- signal,
359
- });
360
-
361
- if (file.contentEncoding !== "utf8") {
362
- throw new Error("Thread page is not UTF-8 text");
363
- }
364
- if (
365
- file.sizeBytes > MAX_PAGE_BYTES ||
366
- Buffer.byteLength(file.content, "utf8") > MAX_PAGE_BYTES
367
- ) {
368
- throw new PageTooLargeError();
369
- }
370
-
371
- const hash = /^[a-f0-9]{64}$/i.test(file.sha256)
372
- ? file.sha256.toLowerCase()
373
- : sha256Text(file.content);
374
- const page = {
375
- fragment: file.content,
376
- hash,
377
- updatedAt: file.modifiedAtMs ?? Date.now(),
378
- };
379
- await remember(threadId, page);
380
- return { ...page, stale: false };
381
- } catch (error) {
382
- if (error instanceof PageTooLargeError) throw error;
383
- if (isMissingFileError(error)) throw new PageNotFoundError();
384
- const fallback = await cached(threadId);
385
- if (fallback) return { ...fallback, stale: true };
386
- throw new PageUnavailableError({ cause: error });
387
- }
388
- }
389
-
390
- return { load, remember };
391
- }
392
-
393
- async function getSigningKey(bb: BbPluginApi): Promise<Uint8Array> {
394
- try {
395
- const stored = await bb.storage.kv.get<unknown>(SIGNING_KEY_KV_KEY);
396
- if (typeof stored === "string" && /^[A-Za-z0-9_-]{43}$/.test(stored)) {
397
- const decoded = Buffer.from(stored, "base64url");
398
- if (decoded.byteLength === 32) return decoded;
399
- }
400
- } catch (error) {
401
- bb.log.warn(`Could not read viewer signing key: ${errorText(error)}`);
402
- }
403
-
404
- const generated = randomBytes(32);
405
- try {
406
- await bb.storage.kv.set(SIGNING_KEY_KV_KEY, generated.toString("base64url"));
407
- } catch (error) {
408
- bb.log.warn(
409
- `Viewer sessions will reset on plugin reload: ${errorText(error)}`,
410
- );
411
- }
412
- return generated;
413
- }
414
-
415
- function commonHeaders(): Headers {
416
- return new Headers({
417
- "cache-control": "no-store, max-age=0",
418
- "content-type": "text/html; charset=utf-8",
419
- "permissions-policy":
420
- "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
421
- "referrer-policy": "no-referrer",
422
- "x-content-type-options": "nosniff",
423
- });
424
- }
425
-
426
- function outerHeaders(nonce: string): Headers {
427
- const headers = commonHeaders();
428
- headers.set(
429
- "content-security-policy",
430
- [
431
- "default-src 'none'",
432
- "base-uri 'none'",
433
- "connect-src 'self'",
434
- "form-action 'none'",
435
- "frame-ancestors 'self'",
436
- "frame-src 'self'",
437
- `script-src 'nonce-${nonce}'`,
438
- `style-src 'nonce-${nonce}'`,
439
- ].join("; "),
440
- );
441
- return headers;
442
- }
443
-
444
- /**
445
- * A CSP source expression for the confined asset preview.
446
- *
447
- * The preview base is origin-relative, but a CSP source list needs a scheme and
448
- * host; a bare path is invalid and is dropped, which would silently block every
449
- * asset. The trailing-slash path is preserved, so the source still matches only
450
- * URLs inside this thread's preview.
451
- */
452
- function assetCspSource(
453
- assetBase: string | null | undefined,
454
- requestUrl: string,
455
- ): string | null {
456
- if (!assetBase) return null;
457
- try {
458
- return new URL(assetBase, requestUrl).href;
459
- } catch {
460
- return null;
461
- }
462
- }
463
-
464
- function documentHeaders(
465
- _nonce: string,
466
- page?: LoadedPage,
467
- assetBase?: string | null,
468
- activity?: "working" | "idle" | "waiting" | "failed" | "stopped",
469
- ): Headers {
470
- const headers = commonHeaders();
471
- // Assets are confined to one temporary, path-shaped preview of this thread's
472
- // asset directory. Everything else stays denied.
473
- const assetSource = assetBase ? ` ${assetBase}` : "";
474
- headers.set(
475
- "content-security-policy",
476
- [
477
- "default-src 'none'",
478
- // The injected <base> must be allowed, but only for the confined preview.
479
- assetBase ? `base-uri ${assetBase}` : "base-uri 'none'",
480
- "connect-src 'none'",
481
- "form-action 'none'",
482
- "frame-ancestors 'self'",
483
- "frame-src 'none'",
484
- "object-src 'none'",
485
- `img-src data: blob:${assetSource}`,
486
- `media-src data: blob:${assetSource}`,
487
- `font-src data:${assetSource}`,
488
- "sandbox allow-scripts allow-forms",
489
- `script-src 'unsafe-inline'${assetSource}`,
490
- "script-src-attr 'unsafe-inline'",
491
- `style-src 'unsafe-inline'${assetSource}`,
492
- "style-src-attr 'unsafe-inline'",
493
- ].join("; "),
494
- );
495
- if (page) {
496
- headers.set("etag", etagForHash(page.hash));
497
- headers.set("x-thread-page-stale", String(page.stale));
498
- // The shell already polls this route for revision changes, so the working
499
- // state rides along on that response: no extra request, and nothing for a
500
- // page or an agent to implement.
501
- if (activity) headers.set("x-thread-page-activity", activity);
502
- headers.set("x-thread-page-updated-at", String(page.updatedAt));
503
- }
504
- return headers;
505
- }
506
-
507
- function jsonResponse(
508
- value: unknown,
509
- status = 200,
510
- ): Response {
511
- return new Response(JSON.stringify(value), {
512
- status,
513
- headers: {
514
- "cache-control": "no-store, max-age=0",
515
- "content-type": "application/json; charset=utf-8",
516
- "x-content-type-options": "nosniff",
517
- },
518
- });
519
- }
520
-
521
- interface BridgeEnvelope {
522
- actionToken: string;
523
- request: unknown;
524
- confirmation: string | null;
525
- }
526
-
527
- function parseBridgeEnvelope(value: unknown): BridgeEnvelope | null {
528
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
529
- const input = value as Record<string, unknown>;
530
- const keys = Object.keys(input);
531
- const allowed = new Set(["actionToken", "request", "confirmation"]);
532
- if (
533
- keys.length < 2 ||
534
- keys.length > 3 ||
535
- !keys.includes("actionToken") ||
536
- !keys.includes("request") ||
537
- keys.some((key) => !allowed.has(key)) ||
538
- typeof input.actionToken !== "string" ||
539
- input.actionToken.length > 4_096
540
- ) {
541
- return null;
542
- }
543
- const confirmation = input.confirmation;
544
- if (
545
- confirmation !== undefined &&
546
- confirmation !== null &&
547
- (typeof confirmation !== "string" || confirmation.length > 4_096)
548
- ) {
549
- return null;
550
- }
551
- return {
552
- actionToken: input.actionToken,
553
- request: input.request,
554
- confirmation: typeof confirmation === "string" ? confirmation : null,
555
- };
556
- }
557
-
558
- function requestIdFrom(value: unknown): unknown {
559
- return value && typeof value === "object" && !Array.isArray(value)
560
- ? (value as Record<string, unknown>).id
561
- : undefined;
562
- }
563
-
564
- function bridgeFailureStatus(code: BridgeErrorCode): number {
565
- if (code === "unknown_method") return 404;
566
- if (code === "stale_page" || code === "conflict") return 409;
567
- if (code === "unavailable") return 503;
568
- if (code === "handler_error" || code === "invalid_result") return 500;
569
- return 400;
570
- }
571
-
572
- function stableJsonStringify(value: JsonValue): string {
573
- if (value === null || typeof value !== "object") return JSON.stringify(value);
574
- if (Array.isArray(value)) {
575
- return `[${value.map((item) => stableJsonStringify(item)).join(",")}]`;
576
- }
577
- return `{${Object.keys(value)
578
- .sort()
579
- .map(
580
- (key) =>
581
- `${JSON.stringify(key)}:${stableJsonStringify(value[key] as JsonValue)}`,
582
- )
583
- .join(",")}}`;
584
- }
585
-
586
- function errorPage(message: string, status: number): Response {
587
- const nonce = randomBytes(18).toString("base64url");
588
- const headers = documentHeaders(nonce);
589
- return new Response(
590
- `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Thread Page</title><style nonce="${nonce}">body{max-width:42rem;margin:4rem auto;padding:0 1rem;font:16px/1.5 system-ui;color:CanvasText;background:Canvas}h1{font-size:1.4rem}</style></head><body><main><h1>Thread Page</h1><p>${escapeHtml(message)}</p></main></body></html>`,
591
- { status, headers },
592
- );
593
- }
594
-
595
- function errorStatus(error: unknown): number {
596
- if (error instanceof PageNotFoundError) return 404;
597
- if (error instanceof PageTooLargeError) return 413;
598
- return 503;
599
- }
600
-
601
- function publicMessage(error: unknown): string {
602
- if (error instanceof PageNotFoundError) {
603
- return "This thread has no page yet. Run `bb thread-page init` in the thread first.";
604
- }
605
- if (error instanceof PageTooLargeError) {
606
- return `The thread page is larger than ${MAX_PAGE_BYTES / 1024} KiB.`;
607
- }
608
- return "The thread page is unavailable. Reconnect its source host and try again.";
609
- }
610
-
611
- function parseIfNoneMatch(value: string | undefined): string[] {
612
- if (!value) return [];
613
- return value.split(",").map((item) => item.trim());
614
- }
615
-
4
+ /** Thread Pages: every session gets one page it writes itself. */
616
5
  export default async function threadPagesPlugin(bb: BbPluginApi): Promise<void> {
617
- const settings = bb.settings.define({
618
- agentInstructions: {
619
- type: "boolean",
620
- label: "Agent initialization hint",
621
- description:
622
- "Tell each new agent session to initialize and directly edit its Thread Page.",
623
- default: false,
624
- },
625
- workingLabel: {
626
- type: "string",
627
- label: "Working indicator text",
628
- description:
629
- "Shown in the page header while the thread is mid-turn, so a reader knows a new version is coming. Blank hides the indicator.",
630
- default: "Working — this is the last saved version",
631
- },
632
- homeThreadId: {
633
- type: "string",
634
- label: "Home page thread",
635
- description:
636
- "Thread whose page is the home page every other page links back to. Set it with `bb thread-page home`.",
637
- default: "",
638
- },
639
- pageSeedHtml: {
640
- type: "string",
641
- label: "New-page HTML seed",
642
- description:
643
- "Full HTML used only when bb thread-page init creates a missing page. {{TITLE}} is escaped and replaced.",
644
- experimental_multiline: true,
645
- default: DEFAULT_PAGE_SEED,
646
- },
647
- agentInstructionText: {
648
- type: "string",
649
- label: "Agent instruction",
650
- description:
651
- "Short instruction injected into eligible new sessions when Agent initialization hint is enabled.",
652
- experimental_multiline: true,
653
- default: DEFAULT_AGENT_INSTRUCTION,
654
- },
655
- });
656
- let currentSettings = await settings.get();
657
- settings.onChange((next) => {
658
- currentSettings = next;
659
- });
660
-
661
- bb.agents.configure((context) => {
662
- const base = { tools: [], skills: [] };
663
- const isRootOwnerThread =
664
- context.thread.parentThreadId === null &&
665
- context.thread.sourceThreadId === null &&
666
- context.origin.kind === null;
667
- return currentSettings.agentInstructions && isRootOwnerThread
668
- ? {
669
- ...base,
670
- instructions: currentSettings.agentInstructionText,
671
- }
672
- : base;
673
- });
674
-
675
- const signingKey = await getSigningKey(bb);
676
- const pages = createPageStore(bb);
677
- const baseRoute = `/api/v1/plugins/${bb.pluginId}/http`;
678
-
679
- /**
680
- * The public origin this bb is reachable at, or null when it is local-only.
681
- *
682
- * Thread Pages needs no port share of its own: its routes are part of the bb
683
- * server, so whatever origin reaches bb reaches them, already behind the same
684
- * owner login. This asks the Connect plugin for that origin so the link we
685
- * hand back is one the user can actually open on a phone. Connect being
686
- * absent, disabled, or unpaired is a normal local-only answer, not an error.
687
- */
688
- // Only a positive answer is cached. Caching "local-only" would keep handing
689
- // back a useless link for half a minute after Connect comes up, which is
690
- // exactly when the user is waiting for a link they can open on a phone.
691
- /**
692
- * Folder-picker selections, held here so the page never sees a real path.
693
- * Bounded and short-lived: a token is single use, expires in ten minutes,
694
- * and is only valid for the thread whose page requested it.
695
- */
696
- const folderSelections = new Map<
697
- string,
698
- { expiresAt: number; threadId: string; hostId: string; path: string }
699
- >();
700
- function pruneSelections(now: number): void {
701
- for (const [token, selection] of folderSelections) {
702
- if (selection.expiresAt <= now) folderSelections.delete(token);
703
- }
704
- while (folderSelections.size > 32) {
705
- const oldest = folderSelections.keys().next().value;
706
- if (oldest === undefined) break;
707
- folderSelections.delete(oldest);
708
- }
709
- }
710
-
711
- let cachedOrigin: { at: number; origin: string } | null = null;
712
- async function publicOrigin(): Promise<string | null> {
713
- const now = Date.now();
714
- if (cachedOrigin && now - cachedOrigin.at < 30_000) {
715
- return cachedOrigin.origin;
716
- }
717
- let origin: string | null = null;
718
- try {
719
- const status = (await bb.sdk.plugins.callRpc({
720
- pluginId: "connect",
721
- method: "status",
722
- input: null,
723
- // The Connect contract validates its own output; we only read two
724
- // fields, so an identity schema keeps zod out of this plugin.
725
- outputSchema: {
726
- parse: (value: unknown) => value,
727
- } as never,
728
- })) as { state?: unknown; url?: unknown } | null;
729
- if (
730
- status &&
731
- status.state === "connected" &&
732
- typeof status.url === "string"
733
- ) {
734
- origin = new URL(status.url).origin;
735
- }
736
- } catch {
737
- origin = null;
738
- }
739
- cachedOrigin = origin === null ? null : { at: now, origin };
740
- return origin;
741
- }
742
-
743
- /**
744
- * Whether another thread has a page, and its revision. A hub needs to know
745
- * which rows are worth linking; it never gets the page itself, its path, or
746
- * its host. A thread with no page is a normal answer, not an error.
747
- */
748
- async function pageAvailability(
749
- threadId: string,
750
- ): Promise<{ available: boolean; revision: string | null }> {
751
- try {
752
- const page = await pages.load(threadId);
753
- return { available: true, revision: page.hash };
754
- } catch {
755
- return { available: false, revision: null };
756
- }
757
- }
758
-
759
- const assetPreviews = new Map<
760
- string,
761
- { baseUrl: string; expiresAtMs: number }
762
- >();
763
-
764
- /**
765
- * A confined, path-shaped preview of this thread's asset directory, or null.
766
- * Missing directories and hosts without preview support are a normal, silent
767
- * absence: the page simply has no relative asset base.
768
- */
769
- async function assetBaseUrl(
770
- threadId: string,
771
- signal?: AbortSignal,
772
- ): Promise<string | null> {
773
- const now = Date.now();
774
- const cached = assetPreviews.get(threadId);
775
- if (cached && cached.expiresAtMs - 30_000 > now) return cached.baseUrl;
776
- try {
777
- const location = await bb.sdk.threads.storageLocation({
778
- threadId,
779
- signal,
780
- });
781
- const rootPath = assetDirPath(location.storageRootPath);
782
- const listed = await bb.sdk.files.list({
783
- hostId: location.hostId,
784
- path: rootPath,
785
- limit: 1,
786
- signal,
787
- });
788
- if (!listed) return null;
789
- const preview = await bb.sdk.files.createPreview({
790
- hostId: location.hostId,
791
- rootPath,
792
- ttlMs: ASSET_PREVIEW_TTL_MS,
793
- signal,
794
- });
795
- const baseUrl = preview.baseUrl.endsWith("/")
796
- ? preview.baseUrl
797
- : `${preview.baseUrl}/`;
798
- assetPreviews.set(threadId, {
799
- baseUrl,
800
- expiresAtMs: preview.expiresAtMs,
801
- });
802
- if (assetPreviews.size > 64) {
803
- for (const [key, value] of assetPreviews) {
804
- if (value.expiresAtMs <= now) assetPreviews.delete(key);
805
- }
806
- }
807
- return baseUrl;
808
- } catch {
809
- // No asset directory, no preview support, or an offline host.
810
- return null;
811
- }
812
- }
813
- type SubmissionOutcome = {
814
- body: Record<string, unknown>;
815
- status: number;
816
- };
817
- const recentSubmissions = new Map<
818
- string,
819
- {
820
- expiresAt: number;
821
- fingerprint: string;
822
- outcome: Promise<SubmissionOutcome>;
823
- }
824
- >();
825
- const recentReplies = new Map<
826
- string,
827
- {
828
- expiresAt: number;
829
- fingerprint: string;
830
- outcome: Promise<{
831
- delivery: "started" | "queued" | "steered";
832
- duplicate: false;
833
- }>;
834
- }
835
- >();
836
- const viewerRates = new Map<
837
- string,
838
- { windowStartedAt: number; accepted: number; inFlight: number; touchedAt: number }
839
- >();
840
-
841
- function acquireViewerRequest(
842
- threadId: string,
843
- actionToken: string,
844
- now: number,
845
- ): (() => void) | null {
846
- for (const [key, entry] of viewerRates) {
847
- if (entry.inFlight === 0 && now - entry.touchedAt > VIEWER_TOKEN_TTL_MS) {
848
- viewerRates.delete(key);
849
- }
850
- }
851
- const key = `${threadId}:${sha256Text(actionToken)}`;
852
- const current = viewerRates.get(key);
853
- const entry =
854
- current ??
855
- { windowStartedAt: now, accepted: 0, inFlight: 0, touchedAt: now };
856
- if (now - entry.windowStartedAt >= 60_000) {
857
- entry.windowStartedAt = now;
858
- entry.accepted = 0;
859
- }
860
- if (entry.inFlight >= 4 || entry.accepted >= 30) return null;
861
- entry.accepted += 1;
862
- entry.inFlight += 1;
863
- entry.touchedAt = now;
864
- viewerRates.set(key, entry);
865
- let released = false;
866
- return () => {
867
- if (released) return;
868
- released = true;
869
- entry.inFlight = Math.max(0, entry.inFlight - 1);
870
- entry.touchedAt = Date.now();
871
- };
872
- }
873
-
874
- function pruneSubmissions(now: number): void {
875
- for (const [key, entry] of recentSubmissions) {
876
- if (entry.expiresAt <= now) recentSubmissions.delete(key);
877
- }
878
- while (recentSubmissions.size >= MAX_RECENT_SUBMISSIONS) {
879
- const oldest = recentSubmissions.keys().next().value as string | undefined;
880
- if (!oldest) break;
881
- recentSubmissions.delete(oldest);
882
- }
883
- }
884
-
885
- function pruneReplies(now: number): void {
886
- for (const [key, entry] of recentReplies) {
887
- if (entry.expiresAt <= now) recentReplies.delete(key);
888
- }
889
- while (recentReplies.size >= MAX_RECENT_SUBMISSIONS) {
890
- const oldest = recentReplies.keys().next().value as string | undefined;
891
- if (!oldest) break;
892
- recentReplies.delete(oldest);
893
- }
894
- }
895
-
896
- bb.cli.register({
897
- name: "thread-page",
898
- summary: "Initialize the directly editable HTML page for the current thread",
899
- commands: [
900
- {
901
- name: "init",
902
- summary: "Create the current thread's page if it does not exist",
903
- usage: "bb thread-page init",
904
- },
905
- {
906
- name: "guide",
907
- summary: "Print the optional authoring and bridge guide",
908
- usage: "bb thread-page guide",
909
- },
910
- {
911
- name: "home",
912
- summary: "Make this thread's page the home page every page links to",
913
- usage: "bb thread-page home [--clear]",
914
- },
915
- ],
916
- async run(argv, context) {
917
- if (argv.length === 1 && argv[0] === "guide") {
918
- return { exitCode: 0, stdout: `${AUTHORING_GUIDE}\n` };
919
- }
920
- if (argv[0] === "home") {
921
- if (argv.length === 2 && argv[1] === "--clear") {
922
- await settings.experimental_set({ homeThreadId: null });
923
- return {
924
- exitCode: 0,
925
- stdout: "home: cleared — pages no longer show a Sessions link\n",
926
- };
927
- }
928
- if (argv.length !== 1) {
929
- return {
930
- exitCode: 2,
931
- stderr: "Usage: bb thread-page home [--clear]\n",
932
- };
933
- }
934
- if (!context.threadId) {
935
- return {
936
- exitCode: 2,
937
- stderr: "Run `bb thread-page home` from the thread that should be home.\n",
938
- };
939
- }
940
- await settings.experimental_set({ homeThreadId: context.threadId });
941
-
942
- // Write the session hub, but only when this thread has no page yet.
943
- // A page the user already has is never replaced.
944
- let wrote = false;
945
- try {
946
- const location = await bb.sdk.threads.storageLocation({
947
- threadId: context.threadId,
948
- signal: context.signal,
949
- });
950
- const home = renderHomeSeed(currentSettings.pageSeedHtml);
951
- const write = await bb.sdk.files.write({
952
- hostId: location.hostId,
953
- path: pagePath(location.storageRootPath),
954
- rootPath: location.storageRootPath,
955
- content: home,
956
- createParents: true,
957
- expectedSha256: null,
958
- mode: 0o600,
959
- });
960
- if (write.outcome === "written") {
961
- wrote = true;
962
- await pages.remember(context.threadId, {
963
- fragment: home,
964
- hash: /^[a-f0-9]{64}$/i.test(write.sha256)
965
- ? write.sha256.toLowerCase()
966
- : sha256Text(home),
967
- updatedAt: Date.now(),
968
- });
969
- }
970
- } catch (error) {
971
- bb.log.warn(
972
- `Could not seed the home page for ${context.threadId}: ${errorText(error)}`,
973
- );
974
- }
975
-
976
- const origin = await publicOrigin();
977
- const homeRoute = `${baseRoute}/home`;
978
- return {
979
- exitCode: 0,
980
- stdout: [
981
- `home: ${context.threadId}`,
982
- `link: [Sessions](${origin ? `${origin}${homeRoute}` : homeRoute})`,
983
- "Every other page now shows a Sessions link back to this one.",
984
- wrote
985
- ? "state: NEW — a session hub grouped by project was written for you. Adjust it like any page."
986
- : "state: EXISTING — this thread already had a page; it was left alone. It should list threads with threads.snapshot.",
987
- "",
988
- ].join("\n"),
989
- };
990
- }
991
- if (argv.length !== 1 || argv[0] !== "init") {
992
- return {
993
- exitCode: 2,
994
- stderr: "Usage: bb thread-page <init|guide|home>\n",
995
- };
996
- }
997
- if (!context.threadId) {
998
- return {
999
- exitCode: 0,
1000
- stdout:
1001
- "state: SKIP — no current root thread; answer normally without creating a page.\n",
1002
- };
1003
- }
1004
-
1005
- try {
1006
- const thread = await bb.sdk.threads.get({
1007
- threadId: context.threadId,
1008
- signal: context.signal,
1009
- });
1010
- if (!isEligibleThread(thread)) {
1011
- return {
1012
- exitCode: 0,
1013
- stdout:
1014
- "state: SKIP — not a current root owner thread; answer normally without creating a page.\n",
1015
- };
1016
- }
1017
-
1018
- const location = await bb.sdk.threads.storageLocation({
1019
- threadId: context.threadId,
1020
- signal: context.signal,
1021
- });
1022
- const absolutePath = pagePath(location.storageRootPath);
1023
- const title = thread.title ?? thread.titleFallback ?? "Thread Page";
1024
- const fragment = renderPageSeed(currentSettings.pageSeedHtml, title);
1025
- if (Buffer.byteLength(fragment, "utf8") > MAX_PAGE_BYTES) {
1026
- throw new PageTooLargeError();
1027
- }
1028
- const write = await bb.sdk.files.write({
1029
- hostId: location.hostId,
1030
- path: absolutePath,
1031
- rootPath: location.storageRootPath,
1032
- content: fragment,
1033
- createParents: true,
1034
- expectedSha256: null,
1035
- mode: 0o600,
1036
- });
1037
-
1038
- let state: "created" | "existing";
1039
- if (write.outcome === "written") {
1040
- state = "created";
1041
- await pages.remember(context.threadId, {
1042
- fragment,
1043
- hash: /^[a-f0-9]{64}$/i.test(write.sha256)
1044
- ? write.sha256.toLowerCase()
1045
- : sha256Text(fragment),
1046
- updatedAt: Date.now(),
1047
- });
1048
- } else {
1049
- state = "existing";
1050
- await pages.load(context.threadId, context.signal);
1051
- }
1052
-
1053
- const route = `${baseRoute}/page?threadId=${encodeURIComponent(context.threadId)}`;
1054
- // Prefer the remote origin, because a relative or loopback link is
1055
- // useless on the phone the user is most likely holding.
1056
- const origin = await publicOrigin();
1057
- const link = origin ? `${origin}${route}` : route;
1058
- return {
1059
- exitCode: 0,
1060
- stdout: [
1061
- `page: ${absolutePath}`,
1062
- `link: [Open the Thread Page](${link})`,
1063
- state === "created"
1064
- ? "state: NEW — seeded; make this HTML app fit the task, keep a response path, then reply in chat only with the link."
1065
- : "state: EXISTING — read before editing; update the HTML app every turn, keep a response path, then reply in chat only with the link.",
1066
- "guide: bb thread-page guide (only when the page needs custom UI, files, activity, or bridge methods)",
1067
- "",
1068
- ].join("\n"),
1069
- };
1070
- } catch (error) {
1071
- return {
1072
- exitCode: 1,
1073
- stderr: `Could not initialize Thread Page: ${errorText(error)}\n`,
1074
- };
1075
- }
1076
- },
1077
- });
1078
-
1079
- bb.http.route(
1080
- "GET",
1081
- "/page",
1082
- async (context) => {
1083
- const threadId = new URL(context.req.url).searchParams.get("threadId");
1084
- if (!isValidThreadId(threadId)) {
1085
- return errorPage("A valid threadId query parameter is required.", 400);
1086
- }
1087
-
1088
- try {
1089
- const thread = await bb.sdk.threads.get({ threadId });
1090
- if (!isEligibleThread(thread)) {
1091
- return errorPage("Thread Pages are available only for current root threads.", 404);
1092
- }
1093
- const page = await pages.load(threadId);
1094
- const now = Date.now();
1095
- const renderPayload: PageTokenPayload = {
1096
- v: 2,
1097
- scope: "render",
1098
- threadId,
1099
- pageHash: page.hash,
1100
- iat: now,
1101
- exp: now + VIEWER_TOKEN_TTL_MS,
1102
- };
1103
- const actionPayload: PageTokenPayload = {
1104
- ...renderPayload,
1105
- scope: "action",
1106
- };
1107
- const renderToken = signPageToken(renderPayload, signingKey);
1108
- const actionToken = signPageToken(actionPayload, signingKey);
1109
- const documentUrl = `${baseRoute}/document?render=${encodeURIComponent(renderToken)}`;
1110
- const nonce = randomBytes(18).toString("base64url");
1111
- const title = thread.title ?? thread.titleFallback ?? "Thread Page";
1112
- const html = renderOuterPage({
1113
- nonce,
1114
- title,
1115
- actionToken,
1116
- pageHash: page.hash,
1117
- expiresAt: renderPayload.exp,
1118
- documentUrl,
1119
- submitUrl: `${baseRoute}/submit`,
1120
- uploadUrl: `${baseRoute}/upload`,
1121
- bridgeUrl: `${baseRoute}/bridge`,
1122
- pageUrlTemplate: `${baseRoute}/page?threadId=__THREAD__`,
1123
- bbThreadUrlTemplate: `/threads/__THREAD__`,
1124
- // The home link is chrome, so every page gets it without the agent
1125
- // writing one. Home itself gets no link back to itself.
1126
- homeUrl:
1127
- isValidThreadId(currentSettings.homeThreadId.trim()) &&
1128
- currentSettings.homeThreadId.trim() !== threadId
1129
- ? `${baseRoute}/home`
1130
- : null,
1131
- working:
1132
- activityState(thread as unknown as Record<string, unknown>) ===
1133
- "working",
1134
- workingLabel: currentSettings.workingLabel,
1135
- stale: page.stale,
1136
- });
1137
- return new Response(html, { status: 200, headers: outerHeaders(nonce) });
1138
- } catch (error) {
1139
- return errorPage(publicMessage(error), errorStatus(error));
1140
- }
1141
- },
1142
- { auth: "local" },
1143
- );
1144
-
1145
- /**
1146
- * The home page: a redirect to whichever thread's page has been designated
1147
- * home. It is deliberately not a dashboard of its own — the home page is an
1148
- * ordinary agent-authored Thread Page, so it can be redesigned like any
1149
- * other and can render the session list however it likes.
1150
- */
1151
- bb.http.route(
1152
- "GET",
1153
- "/home",
1154
- async () => {
1155
- const homeThreadId = currentSettings.homeThreadId.trim();
1156
- if (!isValidThreadId(homeThreadId)) {
1157
- return errorPage(
1158
- "No home page is set yet. Run `bb thread-page home` in the thread whose page should be home.",
1159
- 404,
1160
- );
1161
- }
1162
- return new Response(null, {
1163
- status: 302,
1164
- headers: {
1165
- location: `${baseRoute}/page?threadId=${encodeURIComponent(homeThreadId)}`,
1166
- "cache-control": "no-store, max-age=0",
1167
- },
1168
- });
1169
- },
1170
- { auth: "local" },
1171
- );
1172
-
1173
- const serveDocument = async (context: Parameters<Parameters<typeof bb.http.route>[2]>[0]) => {
1174
- const renderToken = new URL(context.req.url).searchParams.get("render");
1175
- const payload = renderToken
1176
- ? verifyPageToken(renderToken, signingKey, "render")
1177
- : null;
1178
- if (!payload) return errorPage("This page session is invalid or expired.", 401);
1179
-
1180
- try {
1181
- const thread = await bb.sdk.threads.get({ threadId: payload.threadId });
1182
- if (!isEligibleThread(thread)) {
1183
- return errorPage("This thread no longer has an active Thread Page.", 404);
1184
- }
1185
- const page = await pages.load(payload.threadId);
1186
- const nonce = randomBytes(18).toString("base64url");
1187
- const assetBase = await assetBaseUrl(payload.threadId);
1188
- const headers = documentHeaders(
1189
- nonce,
1190
- page,
1191
- assetCspSource(assetBase, context.req.url),
1192
- activityState(thread as unknown as Record<string, unknown>),
1193
- );
1194
- const etag = etagForHash(page.hash);
1195
- const ifNoneMatch = context.req.header("if-none-match");
1196
- const matches = parseIfNoneMatch(ifNoneMatch).some(
1197
- (candidate) => candidate === etag || candidate === "*",
1198
- );
1199
- if (matches) return new Response(null, { status: 304, headers });
1200
- // A conditional GET is the outer shell's bodyless revision probe. A
1201
- // changed ETag must reach it before the old viewer-token hash is refused.
1202
- if (ifNoneMatch) return new Response(null, { status: 200, headers });
1203
- if (page.hash !== payload.pageHash) {
1204
- return errorPage("This page changed. Reload the outer Thread Page.", 409);
1205
- }
1206
- // The document keeps the origin-relative base so it works through
1207
- // loopback, Connect, and Tailscale alike; only the CSP needs absolute.
1208
- const html = renderDocument({
1209
- fragment: page.fragment,
1210
- nonce,
1211
- pageHash: page.hash,
1212
- stale: page.stale,
1213
- assetBase,
1214
- });
1215
- return new Response(html, { status: 200, headers });
1216
- } catch (error) {
1217
- return errorPage(publicMessage(error), errorStatus(error));
1218
- }
1219
- };
1220
-
1221
- bb.http.route("GET", "/document", serveDocument, { auth: "local" });
1222
-
1223
- /**
1224
- * A file the page attached to a form.
1225
- *
1226
- * bb's "local" auth requires an application/json body on non-GET requests
1227
- * (that is what forces the CORS preflight), so the bytes arrive base64-encoded
1228
- * in a JSON envelope. Authority comes from the action token, never from the
1229
- * body, and the file lands in this thread's confined upload directory under a
1230
- * plugin-generated name.
1231
- */
1232
- bb.http.route(
1233
- "POST",
1234
- "/upload",
1235
- async (context) => {
1236
- // base64 costs 4 bytes per 3, plus the small JSON envelope.
1237
- const maxBodyBytes = Math.ceil((MAX_UPLOAD_BYTES * 4) / 3) + 4096;
1238
- const declared = Number(context.req.header("content-length") ?? "0");
1239
- if (Number.isFinite(declared) && declared > maxBodyBytes) {
1240
- return jsonResponse(
1241
- { ok: false, error: "Attachments must be smaller than 24 MiB" },
1242
- 413,
1243
- );
1244
- }
1245
-
1246
- let envelope: Record<string, unknown>;
1247
- try {
1248
- const decoded = (await context.req.json()) as unknown;
1249
- if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
1250
- throw new Error("not an object");
1251
- }
1252
- envelope = decoded as Record<string, unknown>;
1253
- } catch {
1254
- return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400);
1255
- }
1256
-
1257
- const actionToken =
1258
- typeof envelope.actionToken === "string" ? envelope.actionToken : "";
1259
- const action =
1260
- actionToken.length > 0 && actionToken.length <= 4096
1261
- ? verifyPageToken(actionToken, signingKey, "action")
1262
- : null;
1263
- if (!action) {
1264
- return jsonResponse(
1265
- { ok: false, error: "Page session is invalid or expired" },
1266
- 401,
1267
- );
1268
- }
1269
- if (
1270
- typeof envelope.content !== "string" ||
1271
- !/^[A-Za-z0-9+/]*={0,2}$/.test(envelope.content) ||
1272
- envelope.content.length > maxBodyBytes
1273
- ) {
1274
- return jsonResponse(
1275
- { ok: false, error: "Attachment content must be base64" },
1276
- 400,
1277
- );
1278
- }
1279
-
1280
- const now = Date.now();
1281
- const releaseRequest = acquireViewerRequest(
1282
- action.threadId,
1283
- actionToken,
1284
- now,
1285
- );
1286
- if (!releaseRequest) {
1287
- return jsonResponse(
1288
- { ok: false, error: "Too many Thread Page requests; try again shortly" },
1289
- 429,
1290
- );
1291
- }
1292
-
1293
- try {
1294
- const thread = await bb.sdk.threads.get({ threadId: action.threadId });
1295
- if (!isEligibleThread(thread)) {
1296
- return jsonResponse(
1297
- { ok: false, error: "This thread no longer accepts attachments" },
1298
- 409,
1299
- );
1300
- }
1301
-
1302
- const body = Buffer.from(envelope.content as string, "base64");
1303
- if (body.byteLength === 0) {
1304
- return jsonResponse({ ok: false, error: "The file is empty" }, 400);
1305
- }
1306
- if (body.byteLength > MAX_UPLOAD_BYTES) {
1307
- return jsonResponse(
1308
- { ok: false, error: "Attachments must be smaller than 24 MiB" },
1309
- 413,
1310
- );
1311
- }
1312
-
1313
- const name = safeUploadName(
1314
- typeof envelope.name === "string" ? envelope.name : "upload",
1315
- );
1316
- const stamp = new Date(now)
1317
- .toISOString()
1318
- .replace(/[-:]/g, "")
1319
- .replace(/\..+$/, "")
1320
- .replace("T", "-");
1321
- const unique = randomBytes(3).toString("hex");
1322
- const filename = `${stamp}-${unique}-${name}`;
1323
- const location = await bb.sdk.threads.storageLocation({
1324
- threadId: action.threadId,
1325
- });
1326
- const directory = uploadDirPath(location.storageRootPath);
1327
-
1328
- await bb.sdk.files.write({
1329
- hostId: location.hostId,
1330
- path: `${directory}/${filename}`,
1331
- rootPath: location.storageRootPath,
1332
- content: body.toString("base64"),
1333
- contentEncoding: "base64",
1334
- createParents: true,
1335
- expectedSha256: null,
1336
- mode: 0o600,
1337
- });
1338
-
1339
- return jsonResponse({
1340
- ok: true,
1341
- name: filename,
1342
- path: `${UPLOAD_DIRNAME}/${filename}`,
1343
- sizeBytes: body.byteLength,
1344
- });
1345
- } catch (error) {
1346
- bb.log.warn(
1347
- `Could not store a Thread Page upload for ${action.threadId}: ${errorText(error)}`,
1348
- );
1349
- return jsonResponse(
1350
- { ok: false, error: "The attachment could not be stored" },
1351
- 503,
1352
- );
1353
- } finally {
1354
- releaseRequest();
1355
- }
1356
- },
1357
- { auth: "local" },
1358
- );
1359
-
1360
- bb.http.route(
1361
- "POST",
1362
- "/submit",
1363
- async (context) => {
1364
- const contentLength = Number(context.req.header("content-length") ?? "0");
1365
- if (Number.isFinite(contentLength) && contentLength > MAX_SUBMISSION_BYTES) {
1366
- return jsonResponse({ ok: false, error: "Submission is too large" }, 413);
1367
- }
1368
-
1369
- let raw: string;
1370
- let decoded: unknown;
1371
- try {
1372
- raw = await context.req.text();
1373
- if (Buffer.byteLength(raw, "utf8") > MAX_SUBMISSION_BYTES) {
1374
- return jsonResponse({ ok: false, error: "Submission is too large" }, 413);
1375
- }
1376
- decoded = JSON.parse(raw) as unknown;
1377
- } catch {
1378
- return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400);
1379
- }
1380
-
1381
- const submission = parseSubmission(decoded);
1382
- if (!submission) {
1383
- return jsonResponse({ ok: false, error: "Invalid submission" }, 400);
1384
- }
1385
- const action = verifyPageToken(
1386
- submission.actionToken,
1387
- signingKey,
1388
- "action",
1389
- );
1390
- if (!action) {
1391
- return jsonResponse(
1392
- { ok: false, error: "Page session is invalid or expired" },
1393
- 401,
1394
- );
1395
- }
1396
- if (submission.pageHash !== action.pageHash) {
1397
- return jsonResponse(
1398
- { ok: false, error: "This form belongs to an older page revision" },
1399
- 409,
1400
- );
1401
- }
1402
-
1403
- const now = Date.now();
1404
- const releaseRequest = acquireViewerRequest(
1405
- action.threadId,
1406
- submission.actionToken,
1407
- now,
1408
- );
1409
- if (!releaseRequest) {
1410
- return jsonResponse(
1411
- { ok: false, error: "Too many Thread Page requests; try again shortly" },
1412
- 429,
1413
- );
1414
- }
1415
- try {
1416
- pruneSubmissions(now);
1417
- const dedupeKey = `${action.threadId}:${submission.submissionId}`;
1418
- const fingerprint = sha256Text(
1419
- JSON.stringify({
1420
- pageHash: submission.pageHash,
1421
- title: submission.title,
1422
- answers: submission.answers,
1423
- files: submission.files,
1424
- }),
1425
- );
1426
- const existing = recentSubmissions.get(dedupeKey);
1427
- if (existing) {
1428
- if (existing.fingerprint !== fingerprint) {
1429
- return jsonResponse(
1430
- { ok: false, error: "Submission ID was reused with different answers" },
1431
- 409,
1432
- );
1433
- }
1434
- const repeated = await existing.outcome;
1435
- return jsonResponse(repeated.body, repeated.status);
1436
- }
1437
-
1438
- const outcome = (async (): Promise<SubmissionOutcome> => {
1439
- try {
1440
- const thread = await bb.sdk.threads.get({ threadId: action.threadId });
1441
- if (!isEligibleThread(thread)) {
1442
- return {
1443
- body: {
1444
- ok: false,
1445
- error: "This thread no longer accepts Thread Page responses",
1446
- },
1447
- status: 409,
1448
- };
1449
- }
1450
- const page = await pages.load(action.threadId);
1451
- if (page.stale) {
1452
- return {
1453
- body: {
1454
- ok: false,
1455
- error:
1456
- "The source host is offline; this cached page is read-only",
1457
- },
1458
- status: 503,
1459
- };
1460
- }
1461
- if (page.hash !== action.pageHash) {
1462
- return {
1463
- body: {
1464
- ok: false,
1465
- error: "This page changed; reload it before responding",
1466
- },
1467
- status: 409,
1468
- };
1469
- }
1470
-
1471
- const sent = await bb.sdk.threads.send({
1472
- threadId: action.threadId,
1473
- mode: "queue-if-active",
1474
- input: [
1475
- {
1476
- type: "text",
1477
- text: formatSubmissionMessage(submission),
1478
- mentions: [],
1479
- },
1480
- ],
1481
- });
1482
- return {
1483
- body: { ok: true, delivery: sent.delivery },
1484
- status: 200,
1485
- };
1486
- } catch (error) {
1487
- bb.log.warn(
1488
- `Could not deliver Thread Page submission to ${action.threadId}: ${errorText(error)}`,
1489
- );
1490
- return {
1491
- body: {
1492
- ok: false,
1493
- error: "Could not deliver the response to this thread",
1494
- },
1495
- status: 503,
1496
- };
1497
- }
1498
- })();
1499
- recentSubmissions.set(dedupeKey, {
1500
- expiresAt: now + SUBMISSION_TTL_MS,
1501
- fingerprint,
1502
- outcome,
1503
- });
1504
- const delivered = await outcome;
1505
- return jsonResponse(delivered.body, delivered.status);
1506
- } finally {
1507
- releaseRequest();
1508
- }
1509
- },
1510
- { auth: "local" },
1511
- );
1512
-
1513
- bb.http.route(
1514
- "POST",
1515
- "/bridge",
1516
- async (context) => {
1517
- const contentLength = Number(context.req.header("content-length") ?? "0");
1518
- if (
1519
- Number.isFinite(contentLength) &&
1520
- contentLength > MAX_BRIDGE_BODY_BYTES
1521
- ) {
1522
- return jsonResponse(
1523
- makeBridgeFailureResponse(
1524
- undefined,
1525
- "request_too_large",
1526
- "Bridge request is too large",
1527
- ),
1528
- 413,
1529
- );
1530
- }
1531
-
1532
- let decoded: unknown;
1533
- try {
1534
- const raw = await context.req.text();
1535
- if (Buffer.byteLength(raw, "utf8") > MAX_BRIDGE_BODY_BYTES) {
1536
- return jsonResponse(
1537
- makeBridgeFailureResponse(
1538
- undefined,
1539
- "request_too_large",
1540
- "Bridge request is too large",
1541
- ),
1542
- 413,
1543
- );
1544
- }
1545
- decoded = JSON.parse(raw) as unknown;
1546
- } catch {
1547
- return jsonResponse(
1548
- makeBridgeFailureResponse(
1549
- undefined,
1550
- "invalid_json",
1551
- "Invalid JSON body",
1552
- ),
1553
- 400,
1554
- );
1555
- }
1556
-
1557
- const envelope = parseBridgeEnvelope(decoded);
1558
- if (!envelope) {
1559
- return jsonResponse(
1560
- makeBridgeFailureResponse(
1561
- undefined,
1562
- "invalid_request",
1563
- "Invalid bridge envelope",
1564
- ),
1565
- 400,
1566
- );
1567
- }
1568
- const requestId = requestIdFrom(envelope.request);
1569
- const action = verifyPageToken(
1570
- envelope.actionToken,
1571
- signingKey,
1572
- "action",
1573
- );
1574
- if (!action) {
1575
- return jsonResponse(
1576
- makeBridgeFailureResponse(
1577
- requestId,
1578
- "invalid_request",
1579
- "Page action session is invalid or expired",
1580
- ),
1581
- 401,
1582
- );
1583
- }
1584
-
1585
- const releaseRequest = acquireViewerRequest(
1586
- action.threadId,
1587
- envelope.actionToken,
1588
- Date.now(),
1589
- );
1590
- if (!releaseRequest) {
1591
- return jsonResponse(
1592
- makeBridgeFailureResponse(
1593
- requestId,
1594
- "rate_limited",
1595
- "Too many Thread Page requests; try again shortly",
1596
- ),
1597
- 429,
1598
- );
1599
- }
1600
-
1601
- try {
1602
- const resolved = resolveBridgeInvocation(
1603
- envelope.request,
1604
- enabledBridgeRegistry,
1605
- action.pageHash,
1606
- );
1607
- if (!resolved.ok) {
1608
- return jsonResponse(
1609
- makeBridgeFailureResponse(
1610
- requestId,
1611
- resolved.error.code,
1612
- resolved.error.message,
1613
- ),
1614
- bridgeFailureStatus(resolved.error.code),
1615
- );
1616
- }
1617
- // A confirmed method is answered once with a signed challenge and the
1618
- // server's own summary. Trusted outer chrome shows that summary and
1619
- // returns the challenge; the untrusted page never authors either.
1620
- const nowForAuth = Date.now();
1621
- let confirmation: unknown = null;
1622
- if (resolved.value.capability.confirmation === "trusted-outer") {
1623
- const paramsHash = sha256Text(
1624
- stableJsonStringify(resolved.value.request.params as JsonValue),
1625
- );
1626
- if (envelope.confirmation === null) {
1627
- const summary = (
1628
- resolved.value.capability.summarize?.(
1629
- resolved.value.params as never,
1630
- ) ?? resolved.value.capability.description
1631
- ).slice(0, 512);
1632
- const challenge = signConfirmationChallenge(
1633
- {
1634
- v: 2,
1635
- scope: "confirm",
1636
- threadId: action.threadId,
1637
- pageHash: action.pageHash,
1638
- requestId: resolved.value.request.id,
1639
- method: resolved.value.request.method,
1640
- paramsHash,
1641
- summary,
1642
- iat: nowForAuth,
1643
- exp: nowForAuth + CONFIRMATION_TTL_MS,
1644
- },
1645
- signingKey,
1646
- );
1647
- return jsonResponse(
1648
- {
1649
- confirm: {
1650
- requestId: resolved.value.request.id,
1651
- summary,
1652
- challenge,
1653
- },
1654
- },
1655
- 401,
1656
- );
1657
- }
1658
- const verified = verifyConfirmationChallenge(
1659
- envelope.confirmation,
1660
- signingKey,
1661
- nowForAuth,
1662
- );
1663
- if (
1664
- !verified ||
1665
- verified.threadId !== action.threadId ||
1666
- verified.pageHash !== action.pageHash ||
1667
- verified.requestId !== resolved.value.request.id ||
1668
- verified.method !== resolved.value.request.method ||
1669
- verified.paramsHash !== paramsHash
1670
- ) {
1671
- return jsonResponse(
1672
- makeBridgeFailureResponse(
1673
- requestId,
1674
- "confirmation_invalid",
1675
- "Confirmation is expired or does not match this request",
1676
- ),
1677
- bridgeFailureStatus("confirmation_invalid"),
1678
- );
1679
- }
1680
- confirmation = createTrustedOuterConfirmation(resolved.value, {
1681
- confirmedAtMs: verified.iat,
1682
- expiresAtMs: verified.exp,
1683
- humanSummary: verified.summary,
1684
- });
1685
- }
1686
-
1687
- const authorized = authorizeBridgeInvocation(
1688
- resolved.value,
1689
- confirmation,
1690
- nowForAuth,
1691
- );
1692
- if (!authorized.ok) {
1693
- return jsonResponse(
1694
- makeBridgeFailureResponse(
1695
- requestId,
1696
- authorized.error.code,
1697
- authorized.error.message,
1698
- ),
1699
- bridgeFailureStatus(authorized.error.code),
1700
- );
1701
- }
1702
-
1703
- const invocation = authorized.value;
1704
- try {
1705
- const thread = await bb.sdk.threads.get({ threadId: action.threadId });
1706
- if (!isEligibleThread(thread)) {
1707
- return jsonResponse(
1708
- makeBridgeFailureResponse(
1709
- invocation.request.id,
1710
- "conflict",
1711
- "This thread no longer accepts Thread Page actions",
1712
- ),
1713
- 409,
1714
- );
1715
- }
1716
- const page = await pages.load(action.threadId);
1717
- if (page.hash !== action.pageHash) {
1718
- return jsonResponse(
1719
- makeBridgeFailureResponse(
1720
- invocation.request.id,
1721
- "stale_page",
1722
- "The Thread Page revision has changed",
1723
- ),
1724
- 409,
1725
- );
1726
- }
1727
-
1728
- if (invocation.request.method === "context.get") {
1729
- const response = completeBridgeInvocation(invocation, {
1730
- protocolVersion: 1,
1731
- thread: {
1732
- id: thread.id,
1733
- title: (thread.title ?? thread.titleFallback ?? "Thread Page").slice(
1734
- 0,
1735
- 240,
1736
- ),
1737
- projectId: thread.projectId ?? null,
1738
- },
1739
- page: { revision: page.hash, readOnly: page.stale },
1740
- capabilities: capabilityDescriptors(enabledBridgeRegistry),
1741
- });
1742
- return jsonResponse(response, response.ok ? 200 : 500);
1743
- }
1744
-
1745
- if (invocation.request.method === "thread.activity") {
1746
- const params = invocation.params as ThreadActivityParams;
1747
- const events = await bb.sdk.threads.events.list({
1748
- threadId: action.threadId,
1749
- order: "desc",
1750
- limit: "80",
1751
- types: ["item/started", "item/completed"],
1752
- });
1753
- const response = completeBridgeInvocation(invocation, {
1754
- state: activityState(thread as unknown as Record<string, unknown>),
1755
- updatedAtMs: Math.max(0, Math.trunc(thread.updatedAt)),
1756
- items: activityItems(events, params.limit),
1757
- });
1758
- return jsonResponse(response, response.ok ? 200 : 500);
1759
- }
1760
-
1761
- if (invocation.request.method === "projects.list") {
1762
- const projects = await bb.sdk.projects.list({
1763
- includePersonal: true,
1764
- });
1765
- const response = completeBridgeInvocation(invocation, {
1766
- projects: projects.slice(0, 64).map((project) => ({
1767
- id: project.id,
1768
- name: project.name,
1769
- kind: project.kind === "personal" ? "personal" : "standard",
1770
- })),
1771
- });
1772
- return jsonResponse(response, response.ok ? 200 : 500);
1773
- }
1774
-
1775
- if (invocation.request.method === "threads.snapshot") {
1776
- const params = invocation.params as ThreadsSnapshotParams;
1777
- // bb's `archived` flag selects archived threads rather than adding
1778
- // them, so "include archived" is two lists merged, not one flag.
1779
- const query = {
1780
- ...(params.projectId ? { projectId: params.projectId } : {}),
1781
- limit: params.limit,
1782
- };
1783
- const live = await bb.sdk.threads.list(query);
1784
- const archived = params.includeArchived
1785
- ? await bb.sdk.threads
1786
- .list({ ...query, archived: true })
1787
- .catch(() => [])
1788
- : [];
1789
- const seen = new Set<string>();
1790
- const listed = [...live, ...archived]
1791
- .filter((item) => {
1792
- if (seen.has(item.id)) return false;
1793
- seen.add(item.id);
1794
- return true;
1795
- })
1796
- .slice(0, params.limit);
1797
- // A projection, not the thread record: no prompts, paths, host
1798
- // ids, provider ids, or section membership reach the page.
1799
- const threads = await Promise.all(
1800
- listed.map(async (item) => ({
1801
- id: item.id,
1802
- title: item.title ?? item.titleFallback ?? "Untitled",
1803
- projectId: item.projectId ?? null,
1804
- parentThreadId: item.parentThreadId ?? null,
1805
- status: snapshotStatus(
1806
- item as unknown as Record<string, unknown>,
1807
- ),
1808
- archived: item.archivedAt !== null,
1809
- page: await pageAvailability(item.id),
1810
- updatedAtMs: Math.max(0, Math.trunc(item.updatedAt)),
1811
- })),
1812
- );
1813
- const response = completeBridgeInvocation(invocation, {
1814
- threads,
1815
- nextCursor: null,
1816
- generatedAtMs: Date.now(),
1817
- });
1818
- return jsonResponse(response, response.ok ? 200 : 500);
1819
- }
1820
-
1821
- if (invocation.request.method === "threads.continue") {
1822
- const params = invocation.params as ThreadsContinueParams;
1823
- if (params.threadId === action.threadId) {
1824
- return jsonResponse(
1825
- makeBridgeFailureResponse(
1826
- invocation.request.id,
1827
- "invalid_params",
1828
- "Use thread.reply for this page's own thread",
1829
- ),
1830
- 400,
1831
- );
1832
- }
1833
- const target = await bb.sdk.threads
1834
- .get({ threadId: params.threadId })
1835
- .catch(() => null);
1836
- if (!target || target.deletedAt !== null) {
1837
- return jsonResponse(
1838
- makeBridgeFailureResponse(
1839
- invocation.request.id,
1840
- "not_found",
1841
- "That thread is not available",
1842
- ),
1843
- 404,
1844
- );
1845
- }
1846
- const wasActive =
1847
- target.status === "active" || target.status === "starting";
1848
- const sent = await bb.sdk.threads.send({
1849
- threadId: params.threadId,
1850
- mode:
1851
- params.mode === "steer" ? "steer-if-active" : "queue-if-active",
1852
- input: [{ type: "text", text: params.prompt, mentions: [] }],
1853
- });
1854
- const response = completeBridgeInvocation(invocation, {
1855
- threadId: params.threadId,
1856
- delivery:
1857
- sent.delivery === "queued"
1858
- ? "queued"
1859
- : params.mode === "steer" && wasActive
1860
- ? "steered"
1861
- : "started",
1862
- duplicate: false,
1863
- });
1864
- return jsonResponse(response, response.ok ? 200 : 500);
1865
- }
1866
-
1867
- if (invocation.request.method === "projects.browse") {
1868
- // The picker opens on the host, and the chosen path is kept here.
1869
- // The page receives an opaque token and a display string, never a
1870
- // filesystem path it could reuse or exfiltrate.
1871
- const location = await bb.sdk.threads.storageLocation({
1872
- threadId: action.threadId,
1873
- });
1874
- const picked = await bb.sdk.hosts.pickFolder({
1875
- hostId: location.hostId,
1876
- clientHostId: location.hostId,
1877
- });
1878
- if (!picked.path) {
1879
- const response = completeBridgeInvocation(invocation, {
1880
- selection: null,
1881
- });
1882
- return jsonResponse(response, response.ok ? 200 : 500);
1883
- }
1884
- const host = await bb.sdk.hosts
1885
- .get({ hostId: location.hostId })
1886
- .catch(() => null);
1887
- const token = `sel.${randomBytes(18).toString("base64url")}`;
1888
- pruneSelections(Date.now());
1889
- folderSelections.set(token, {
1890
- expiresAt: Date.now() + SELECTION_TTL_MS,
1891
- threadId: action.threadId,
1892
- hostId: location.hostId,
1893
- path: picked.path,
1894
- });
1895
- const response = completeBridgeInvocation(invocation, {
1896
- selection: {
1897
- token,
1898
- displayPath: picked.path.replace(/^\/Users\/[^/]+/, "~"),
1899
- hostName: host?.name ?? "this device",
1900
- },
1901
- });
1902
- return jsonResponse(response, response.ok ? 200 : 500);
1903
- }
1904
-
1905
- if (invocation.request.method === "projects.create") {
1906
- const params = invocation.params as {
1907
- selectionToken: string;
1908
- name?: string;
1909
- };
1910
- pruneSelections(Date.now());
1911
- const selection = folderSelections.get(params.selectionToken);
1912
- // A selection belongs to the page that made it, and is single use.
1913
- if (!selection || selection.threadId !== action.threadId) {
1914
- return jsonResponse(
1915
- makeBridgeFailureResponse(
1916
- invocation.request.id,
1917
- "not_found",
1918
- "That folder selection has expired; choose the folder again",
1919
- ),
1920
- 404,
1921
- );
1922
- }
1923
- folderSelections.delete(params.selectionToken);
1924
- const created = await bb.sdk.projects.create({
1925
- name:
1926
- params.name ?? selection.path.split("/").pop() ?? "New project",
1927
- hostId: selection.hostId,
1928
- path: selection.path,
1929
- } as never);
1930
- const response = completeBridgeInvocation(invocation, {
1931
- project: {
1932
- id: created.id,
1933
- name: created.name,
1934
- kind: created.kind === "personal" ? "personal" : "standard",
1935
- },
1936
- });
1937
- return jsonResponse(response, response.ok ? 200 : 500);
1938
- }
1939
-
1940
- if (
1941
- invocation.request.method === "storage.get" ||
1942
- invocation.request.method === "storage.set"
1943
- ) {
1944
- // Namespaced by thread, so one page can never read another's
1945
- // state even though they share bb's key-value table.
1946
- const params = invocation.params as {
1947
- key: string;
1948
- value?: JsonValue;
1949
- };
1950
- const key = `state:${action.threadId}:${params.key}`;
1951
- if (invocation.request.method === "storage.get") {
1952
- const stored = await bb.storage.kv.get<JsonValue>(key);
1953
- const response = completeBridgeInvocation(
1954
- invocation,
1955
- stored === undefined
1956
- ? { found: false }
1957
- : { found: true, value: stored },
1958
- );
1959
- return jsonResponse(response, response.ok ? 200 : 500);
1960
- }
1961
- await bb.storage.kv.set(key, params.value ?? null);
1962
- const response = completeBridgeInvocation(invocation, {
1963
- stored: true,
1964
- });
1965
- return jsonResponse(response, response.ok ? 200 : 500);
1966
- }
1967
-
1968
- if (invocation.request.method === "providers.list") {
1969
- const providers = await bb.sdk.providers.list();
1970
- const models = await bb.sdk.providers.models().catch(() => []);
1971
- const byProvider = new Map<
1972
- string,
1973
- Array<{ id: string; displayName: string }>
1974
- >();
1975
- for (const model of models as Array<Record<string, unknown>>) {
1976
- const providerId =
1977
- typeof model.providerId === "string" ? model.providerId : null;
1978
- const id = typeof model.id === "string" ? model.id : null;
1979
- if (!providerId || !id) continue;
1980
- const list = byProvider.get(providerId) ?? [];
1981
- if (list.length < 32) {
1982
- list.push({
1983
- id,
1984
- displayName:
1985
- typeof model.displayName === "string"
1986
- ? model.displayName
1987
- : id,
1988
- });
1989
- }
1990
- byProvider.set(providerId, list);
1991
- }
1992
- const response = completeBridgeInvocation(invocation, {
1993
- providers: (providers as Array<Record<string, unknown>>)
1994
- .slice(0, 64)
1995
- .map((provider) => {
1996
- const id = String(provider.id ?? "");
1997
- return {
1998
- id,
1999
- displayName: String(
2000
- provider.displayName ?? provider.name ?? id,
2001
- ),
2002
- available: provider.available !== false,
2003
- models: byProvider.get(id) ?? [],
2004
- };
2005
- }),
2006
- });
2007
- return jsonResponse(response, response.ok ? 200 : 500);
2008
- }
2009
-
2010
- if (invocation.request.method === "threads.spawn") {
2011
- const params = invocation.params as ThreadsSpawnParams;
2012
- const spawned = await bb.sdk.threads.spawn({
2013
- projectId: params.projectId,
2014
- prompt: params.prompt,
2015
- ...(params.title ? { title: params.title } : {}),
2016
- ...(params.providerId ? { providerId: params.providerId } : {}),
2017
- ...(params.model ? { model: params.model } : {}),
2018
- ...(params.reasoningLevel
2019
- ? { reasoningLevel: params.reasoningLevel as never }
2020
- : {}),
2021
- // A thread the user asked a page to start is theirs, so it is a
2022
- // visible root rather than a hidden helper of this thread.
2023
- visibility: "visible",
2024
- } as never);
2025
- const response = completeBridgeInvocation(invocation, {
2026
- threadId: spawned.id,
2027
- });
2028
- return jsonResponse(response, response.ok ? 200 : 500);
2029
- }
2030
-
2031
- if (
2032
- invocation.request.method === "threads.archive" ||
2033
- invocation.request.method === "threads.stop"
2034
- ) {
2035
- const params = invocation.params as ThreadTargetParams;
2036
- const destructive = invocation.request.method === "threads.stop";
2037
- if (destructive && params.threadId === action.threadId) {
2038
- // Stopping your own thread would kill the turn that is about to
2039
- // read the answer, so it fails visibly instead.
2040
- return jsonResponse(
2041
- makeBridgeFailureResponse(
2042
- invocation.request.id,
2043
- "invalid_params",
2044
- "A page cannot stop its own thread",
2045
- ),
2046
- 400,
2047
- );
2048
- }
2049
- const target = await bb.sdk.threads
2050
- .get({ threadId: params.threadId })
2051
- .catch(() => null);
2052
- if (!target || target.deletedAt !== null) {
2053
- return jsonResponse(
2054
- makeBridgeFailureResponse(
2055
- invocation.request.id,
2056
- "not_found",
2057
- "That thread is not available",
2058
- ),
2059
- 404,
2060
- );
2061
- }
2062
- if (destructive) {
2063
- await bb.sdk.threads.stop({ threadId: params.threadId });
2064
- const response = completeBridgeInvocation(invocation, {
2065
- stopped: true,
2066
- });
2067
- return jsonResponse(response, response.ok ? 200 : 500);
2068
- }
2069
- await bb.sdk.threads.archive({ threadId: params.threadId });
2070
- const response = completeBridgeInvocation(invocation, {
2071
- archived: true,
2072
- });
2073
- return jsonResponse(response, response.ok ? 200 : 500);
2074
- }
2075
-
2076
- if (page.stale) {
2077
- return jsonResponse(
2078
- makeBridgeFailureResponse(
2079
- invocation.request.id,
2080
- "unavailable",
2081
- "The source host is offline; this cached page is read-only",
2082
- ),
2083
- 503,
2084
- );
2085
- }
2086
-
2087
- const params = invocation.params as ThreadReplyParams;
2088
- const dedupeKey = `${action.threadId}:${params.idempotencyKey ?? invocation.request.id}`;
2089
- const fingerprint = sha256Text(
2090
- stableJsonStringify({
2091
- pageRevision: page.hash,
2092
- result: params.result,
2093
- mode: params.mode,
2094
- ...(params.title === undefined ? {} : { title: params.title }),
2095
- }),
2096
- );
2097
- const now = Date.now();
2098
- pruneReplies(now);
2099
- const existing = recentReplies.get(dedupeKey);
2100
- if (existing) {
2101
- if (existing.fingerprint !== fingerprint) {
2102
- return jsonResponse(
2103
- makeBridgeFailureResponse(
2104
- invocation.request.id,
2105
- "conflict",
2106
- "Idempotency key was reused with a different reply",
2107
- ),
2108
- 409,
2109
- );
2110
- }
2111
- const repeated = await existing.outcome;
2112
- const response = completeBridgeInvocation(invocation, {
2113
- ...repeated,
2114
- duplicate: true,
2115
- });
2116
- return jsonResponse(response, response.ok ? 200 : 500);
2117
- }
2118
-
2119
- const wasActive =
2120
- thread.status === "active" || thread.status === "starting";
2121
- const outcome = (async () => {
2122
- const sent = await bb.sdk.threads.send({
2123
- threadId: action.threadId,
2124
- mode:
2125
- params.mode === "steer"
2126
- ? "steer-if-active"
2127
- : "queue-if-active",
2128
- input: [
2129
- {
2130
- type: "text",
2131
- text: formatThreadReplyMessage(params.title, params.result),
2132
- mentions: [],
2133
- },
2134
- ],
2135
- });
2136
- const delivery: "started" | "queued" | "steered" =
2137
- sent.delivery === "queued"
2138
- ? "queued"
2139
- : params.mode === "steer" && wasActive
2140
- ? "steered"
2141
- : "started";
2142
- return { delivery, duplicate: false as const };
2143
- })();
2144
- recentReplies.set(dedupeKey, {
2145
- expiresAt: now + SUBMISSION_TTL_MS,
2146
- fingerprint,
2147
- outcome,
2148
- });
2149
- let delivered: Awaited<typeof outcome>;
2150
- try {
2151
- delivered = await outcome;
2152
- } catch (error) {
2153
- if (recentReplies.get(dedupeKey)?.outcome === outcome) {
2154
- recentReplies.delete(dedupeKey);
2155
- }
2156
- throw error;
2157
- }
2158
- const response = completeBridgeInvocation(invocation, delivered);
2159
- return jsonResponse(response, response.ok ? 200 : 500);
2160
- } catch (error) {
2161
- bb.log.warn(
2162
- `Could not execute Thread Page bridge request for ${action.threadId}: ${errorText(error)}`,
2163
- );
2164
- return jsonResponse(
2165
- makeBridgeFailureResponse(
2166
- invocation.request.id,
2167
- "handler_error",
2168
- "Could not execute the Thread Page action",
2169
- ),
2170
- 503,
2171
- );
2172
- }
2173
- } finally {
2174
- releaseRequest();
2175
- }
2176
- },
2177
- { auth: "local" },
2178
- );
6
+ await createPlugin(bb);
2179
7
  }