@unifedev/thread-pages 0.3.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +77 -129
  2. package/dist/server.js +11426 -11802
  3. package/dist/server.meta.json +2 -2
  4. package/package.json +25 -18
  5. package/server.ts +3 -2175
  6. package/src/agent/cli.ts +193 -0
  7. package/src/agent/guide.ts +355 -0
  8. package/src/agent/instruction.ts +59 -0
  9. package/src/agent/seed/seed.ts +69 -0
  10. package/{theme.ts → src/agent/seed/theme-css.ts} +4 -11
  11. package/src/agent/starter-hub.ts +217 -0
  12. package/src/bb/activity.ts +59 -0
  13. package/src/bb/bb-host.ts +280 -0
  14. package/src/bb/public-origin.ts +45 -0
  15. package/src/config/settings.ts +82 -0
  16. package/src/domain/capabilities/contract.ts +48 -0
  17. package/src/domain/capabilities/index.ts +10 -0
  18. package/src/domain/capabilities/protocol.ts +112 -0
  19. package/src/domain/capabilities/registry.ts +48 -0
  20. package/src/domain/capabilities/schema.ts +198 -0
  21. package/src/domain/capabilities/specs.ts +479 -0
  22. package/src/domain/eligibility.ts +43 -0
  23. package/src/domain/errors.ts +116 -0
  24. package/src/domain/html/document.ts +109 -0
  25. package/src/domain/html/escape.ts +16 -0
  26. package/src/domain/ids.ts +37 -0
  27. package/src/domain/json/canonical.ts +19 -0
  28. package/src/domain/json/strict-json.ts +139 -0
  29. package/src/domain/limits.ts +88 -0
  30. package/src/domain/rate-limit.ts +64 -0
  31. package/src/domain/revision.ts +27 -0
  32. package/src/domain/submissions/idempotency.ts +59 -0
  33. package/src/domain/submissions/message.ts +42 -0
  34. package/src/domain/submissions/parse.ts +105 -0
  35. package/src/domain/tokens/action-token.ts +52 -0
  36. package/src/domain/tokens/confirmation.ts +99 -0
  37. package/src/domain/tokens/mac.ts +50 -0
  38. package/src/generated/kernel-runtime.ts +3 -0
  39. package/src/generated/shell-runtime.ts +3 -0
  40. package/src/host/contract.ts +65 -0
  41. package/src/host/types.ts +89 -0
  42. package/src/pages/layout.ts +65 -0
  43. package/src/pages/page-store.ts +136 -0
  44. package/src/pages/site.ts +36 -0
  45. package/src/plugin.ts +71 -0
  46. package/src/runtime/kernel/anchors.ts +45 -0
  47. package/src/runtime/kernel/api.ts +15 -0
  48. package/src/runtime/kernel/bridge-client.ts +148 -0
  49. package/src/runtime/kernel/dirty.ts +51 -0
  50. package/src/runtime/kernel/forms.ts +114 -0
  51. package/src/runtime/kernel/install.ts +156 -0
  52. package/src/runtime/kernel/labels.ts +98 -0
  53. package/src/runtime/kernel/main.ts +6 -0
  54. package/src/runtime/kernel/readonly.ts +75 -0
  55. package/src/runtime/shared/protocol.ts +125 -0
  56. package/src/runtime/shell/confirm.ts +70 -0
  57. package/src/runtime/shell/install.ts +79 -0
  58. package/src/runtime/shell/main.ts +12 -0
  59. package/src/runtime/shell/navigate.ts +64 -0
  60. package/src/runtime/shell/poll.ts +125 -0
  61. package/src/runtime/shell/relay.ts +185 -0
  62. package/src/serving/action-request.ts +32 -0
  63. package/src/serving/bridge/dispatcher.ts +112 -0
  64. package/src/serving/bridge/handler.ts +37 -0
  65. package/src/serving/bridge/handlers/index.ts +26 -0
  66. package/src/serving/bridge/handlers/navigation.ts +43 -0
  67. package/src/serving/bridge/handlers/reads.ts +186 -0
  68. package/src/serving/bridge/handlers/writes.ts +175 -0
  69. package/src/serving/bridge/selection-store.ts +58 -0
  70. package/src/serving/bridge-route.ts +23 -0
  71. package/src/serving/context.ts +34 -0
  72. package/src/serving/document-route.ts +37 -0
  73. package/src/serving/home-route.ts +23 -0
  74. package/src/serving/responses.ts +81 -0
  75. package/src/serving/routes.ts +26 -0
  76. package/src/serving/session-access.ts +22 -0
  77. package/src/serving/shell-html.ts +77 -0
  78. package/src/serving/shell-route.ts +51 -0
  79. package/src/serving/signing-key.ts +25 -0
  80. package/src/serving/submit-route.ts +47 -0
  81. package/src/serving/upload-route.ts +46 -0
  82. package/tsconfig.json +10 -6
  83. package/ARCHITECTURE.md +0 -230
  84. package/PLUGIN_OVERVIEW.md +0 -83
  85. package/authoring.ts +0 -368
  86. package/bridge.ts +0 -1721
  87. package/docs/MODEL.md +0 -211
  88. package/docs/ROADMAP.md +0 -96
  89. package/home.ts +0 -419
  90. package/page.ts +0 -782
@@ -0,0 +1,479 @@
1
+ import { isEntityId, isOpaqueToken, isStorageKey } from "../ids.ts";
2
+ import type { JsonValue } from "../json/strict-json.ts";
3
+ import { LIMITS } from "../limits.ts";
4
+ import type { CapabilitySpec } from "./contract.ts";
5
+ import * as s from "./schema.ts";
6
+
7
+ /**
8
+ * Every capability, exactly as spec 05 defines it. One object each; the
9
+ * server-side handler for each lives in src/serving/bridge/handlers.
10
+ */
11
+
12
+ // --- shared pieces ---------------------------------------------------------
13
+
14
+ const ENTITY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
15
+ const entityId = (label: string) => s.string({ min: 1, max: 128, pattern: ENTITY_ID, label });
16
+ const title = (label = "Title") => s.string({ max: LIMITS.titleChars, label });
17
+ const prompt = s.string({ min: 1, max: LIMITS.promptChars, label: "Prompt" });
18
+ const safeName = (label: string, max = 160) => s.string({ min: 1, max, pattern: /^[^\u0000-\u001f\u007f]+$/, label });
19
+ const timestamp = s.integer(0, Number.MAX_SAFE_INTEGER, "Timestamp");
20
+
21
+ export const SESSION_STATES = ["working", "idle", "waiting", "failed", "stopped"] as const;
22
+ export type SessionState = (typeof SESSION_STATES)[number];
23
+ const sessionState = s.literal(SESSION_STATES, "State");
24
+
25
+ const deliveryResult = s.object({
26
+ delivery: s.literal(["started", "queued", "steered"], "Delivery"),
27
+ duplicate: s.boolean(),
28
+ });
29
+
30
+ const projectChoice = s.object({
31
+ id: entityId("Project id"),
32
+ name: title("Project name"),
33
+ kind: s.literal(["standard", "personal"], "Project kind"),
34
+ });
35
+
36
+ function spec<P, R>(definition: CapabilitySpec<P, R>): CapabilitySpec<P, R> {
37
+ return Object.freeze(definition);
38
+ }
39
+
40
+ function params<P>(schema: s.Schema<P>) {
41
+ return (value: JsonValue | undefined) => schema.parse(value, "$");
42
+ }
43
+
44
+ function result<R>(schema: s.Schema<R>) {
45
+ return (value: unknown) => schema.parse(value as JsonValue, "$");
46
+ }
47
+
48
+ // --- reads -----------------------------------------------------------------
49
+
50
+ export const contextGet = spec({
51
+ method: "context.get",
52
+ description: "Read this page's identity and the capability roster.",
53
+ effect: "read",
54
+ confirmed: false,
55
+ implemented: true,
56
+ validateParams: params(s.noParams()),
57
+ validateResult: result(
58
+ s.object({
59
+ protocolVersion: s.literal([1]),
60
+ session: s.object({ id: entityId("Session id"), title: title(), projectId: s.nullable(entityId("Project id")) }),
61
+ page: s.object({ revision: s.string({ min: 64, max: 64, pattern: /^[a-f0-9]{64}$/, label: "Revision" }), readOnly: s.boolean() }),
62
+ capabilities: s.array(
63
+ s.object({
64
+ method: s.string({ min: 3, max: LIMITS.methodNameChars, label: "Method" }),
65
+ effect: s.literal(["read", "own-session-write", "cross-session-write", "destructive", "navigation", "device", "reader-state"]),
66
+ confirmation: s.literal(["none", "required"]),
67
+ }),
68
+ 64,
69
+ ),
70
+ }),
71
+ ),
72
+ doc: {
73
+ params: "None.",
74
+ result: "`{ protocolVersion: 1, session: { id, title, projectId }, page: { revision, readOnly }, capabilities: [{ method, effect, confirmation }] }`.",
75
+ notes: "The roster lists what is actually enabled; check it rather than assume.",
76
+ },
77
+ });
78
+
79
+ export const sessionActivity = spec({
80
+ method: "session.activity",
81
+ description: "Read this session's state and recent activity.",
82
+ effect: "read",
83
+ confirmed: false,
84
+ implemented: true,
85
+ validateParams: params(s.object({ limit: s.withDefault(s.integer(1, LIMITS.activityMax, "Limit"), LIMITS.activityDefault) })),
86
+ validateResult: result(
87
+ s.object({
88
+ state: sessionState,
89
+ updatedAtMs: timestamp,
90
+ items: s.array(
91
+ s.object({
92
+ kind: s.string({ min: 1, max: 80, label: "Kind" }),
93
+ done: s.boolean(),
94
+ atMs: timestamp,
95
+ label: s.string({ min: 1, max: 80, label: "Label" }),
96
+ text: s.string({ max: 200, label: "Text" }),
97
+ }),
98
+ LIMITS.activityMax,
99
+ ),
100
+ }),
101
+ ),
102
+ doc: {
103
+ params: `\`{ limit? }\` — 1 to ${LIMITS.activityMax}, default ${LIMITS.activityDefault}. It never takes a session id: it is always this page's own session.`,
104
+ result: "`{ state, updatedAtMs, items: [{ kind, done, atMs, label, text }] }` where `state` is one of `working`, `idle`, `waiting`, `failed`, `stopped`.",
105
+ },
106
+ });
107
+
108
+ export type SnapshotParams = s.Infer<typeof snapshotParams>;
109
+ const snapshotParams = s.object({
110
+ projectId: s.optional(s.nullable(entityId("Project id"))),
111
+ includeArchived: s.withDefault(s.boolean(), false),
112
+ includeChildren: s.withDefault(s.boolean(), false),
113
+ limit: s.withDefault(s.integer(1, LIMITS.snapshotMax, "Limit"), LIMITS.snapshotDefault),
114
+ cursor: s.optional(s.nullable(s.string({ min: 1, max: 512, pattern: /^[A-Za-z0-9._~:-]+$/, label: "Cursor" }))),
115
+ });
116
+
117
+ export const sessionSummary = s.object({
118
+ id: entityId("Session id"),
119
+ title: title(),
120
+ projectId: s.nullable(entityId("Project id")),
121
+ parentSessionId: s.nullable(entityId("Session id")),
122
+ status: sessionState,
123
+ archived: s.boolean(),
124
+ page: s.object({ available: s.boolean(), revision: s.nullable(s.string({ min: 64, max: 64, pattern: /^[a-f0-9]{64}$/ })) }),
125
+ updatedAtMs: timestamp,
126
+ attentionAtMs: timestamp,
127
+ unread: s.boolean(),
128
+ });
129
+ export type SessionSummary = s.Infer<typeof sessionSummary>;
130
+
131
+ export const sessionsSnapshot = spec({
132
+ method: "sessions.snapshot",
133
+ description: "Read a bounded, projected list of sessions.",
134
+ effect: "read",
135
+ confirmed: false,
136
+ implemented: true,
137
+ validateParams: params(snapshotParams),
138
+ validateResult: result(
139
+ s.object({
140
+ sessions: s.array(sessionSummary, LIMITS.snapshotMax),
141
+ nextCursor: s.nullable(s.string({ min: 1, max: 512 })),
142
+ generatedAtMs: timestamp,
143
+ }),
144
+ ),
145
+ doc: {
146
+ params: `\`{ projectId?, includeArchived?, includeChildren?, limit?, cursor? }\` — \`limit\` 1 to ${LIMITS.snapshotMax}, default ${LIMITS.snapshotDefault}; pass the previous result's \`nextCursor\` to continue. By default only root sessions are listed, the way the host's own sidebar shows them; \`includeChildren: true\` adds sub-agent sessions (with \`parentSessionId\` set).`,
147
+ result: "`{ sessions: [{ id, title, projectId, parentSessionId, status, archived, unread, attentionAtMs, updatedAtMs, page: { available, revision } }], nextCursor, generatedAtMs }`. `unread` means the session asked for the reader's attention (a turn ended, a question) after they last looked at it — the same mark the host's sidebar shows; `attentionAtMs` is when. `page.revision` is known for pages this host has served recently and `null` otherwise.",
148
+ notes: "No message bodies or agent output are included.",
149
+ },
150
+ });
151
+
152
+ export const projectsList = spec({
153
+ method: "projects.list",
154
+ description: "Read project choices without paths or host details.",
155
+ effect: "read",
156
+ confirmed: false,
157
+ implemented: true,
158
+ validateParams: params(s.noParams()),
159
+ validateResult: result(s.object({ projects: s.array(projectChoice, LIMITS.projectsMax) })),
160
+ doc: { params: "None.", result: "`{ projects: [{ id, name, kind }] }` where `kind` is `standard` or `personal`." },
161
+ });
162
+
163
+ export const providersList = spec({
164
+ method: "providers.list",
165
+ description: "Read the provider and model choices a page may pass to sessions.start.",
166
+ effect: "read",
167
+ confirmed: false,
168
+ implemented: true,
169
+ validateParams: params(s.noParams()),
170
+ validateResult: result(
171
+ s.object({
172
+ providers: s.array(
173
+ s.object({
174
+ id: entityId("Provider id"),
175
+ displayName: title("Provider name"),
176
+ available: s.boolean(),
177
+ models: s.array(
178
+ s.object({
179
+ id: safeName("Model id"),
180
+ displayName: title("Model name"),
181
+ isDefault: s.boolean(),
182
+ reasoningLevels: s.array(safeName("Reasoning level", 32), 16),
183
+ }),
184
+ LIMITS.modelsPerProvider,
185
+ ),
186
+ }),
187
+ LIMITS.providersMax,
188
+ ),
189
+ }),
190
+ ),
191
+ doc: {
192
+ params: "None.",
193
+ result: "`{ providers: [{ id, displayName, available, models: [{ id, displayName, isDefault, reasoningLevels }] }] }`.",
194
+ notes: "Fails with `unavailable` when the host cannot enumerate providers; it never returns an empty list to mean that.",
195
+ },
196
+ });
197
+
198
+ const storageKey = s.string({ min: 1, max: LIMITS.storageKeyChars, pattern: /^[A-Za-z0-9][A-Za-z0-9._:-]*$/, label: "Storage key" });
199
+ const storageValue = s.json({ maxBytes: LIMITS.storageValueBytes, maxDepth: 12 }, "Stored value");
200
+
201
+ export const storageGet = spec({
202
+ method: "storage.get",
203
+ description: "Read a small JSON value stored for this page.",
204
+ effect: "read",
205
+ confirmed: false,
206
+ implemented: true,
207
+ validateParams: params(s.object({ key: storageKey })),
208
+ validateResult: result(
209
+ s.union(
210
+ s.object({ found: s.literal([false]) }),
211
+ s.object({ found: s.literal([true]), value: storageValue }),
212
+ "Storage result",
213
+ ),
214
+ ),
215
+ doc: { params: "`{ key }`.", result: "`{ found: false }` or `{ found: true, value }`." },
216
+ });
217
+
218
+ // --- writes to the page's own session --------------------------------------
219
+
220
+ export const sessionReply = spec({
221
+ method: "session.reply",
222
+ description: "Send a structured result to this page's owning session.",
223
+ effect: "own-session-write",
224
+ confirmed: false,
225
+ implemented: true,
226
+ validateParams: params(
227
+ s.object({
228
+ title: s.optional(title()),
229
+ mode: s.withDefault(s.literal(["queue", "steer"], "Mode"), "queue"),
230
+ result: s.json({ maxBytes: LIMITS.resultTextBytes }, "Result"),
231
+ idempotencyKey: s.optional(s.string({ min: 1, max: LIMITS.requestIdChars, pattern: /^[A-Za-z0-9][A-Za-z0-9._:-]*$/, label: "Idempotency key" })),
232
+ }),
233
+ ),
234
+ validateResult: result(deliveryResult),
235
+ doc: {
236
+ params: "`{ result, title?, mode?, idempotencyKey? }` — `mode` is `queue` (default: waits for the current turn) or `steer` (interrupts it).",
237
+ result: "`{ delivery: 'started' | 'queued' | 'steered', duplicate }`.",
238
+ notes: "With an `idempotencyKey`, a repeat with the same content delivers once and reports `duplicate: true`; a repeat with different content is a `conflict`.",
239
+ },
240
+ });
241
+
242
+ export const storageSet = spec({
243
+ method: "storage.set",
244
+ description: "Store a small JSON value for this page.",
245
+ effect: "own-session-write",
246
+ confirmed: false,
247
+ implemented: true,
248
+ validateParams: params(s.object({ key: storageKey, value: storageValue })),
249
+ validateResult: result(s.object({ stored: s.literal([true]) })),
250
+ doc: { params: `\`{ key, value }\` — the value serialised must be at most ${LIMITS.storageValueBytes / 1024} KiB.`, result: "`{ stored: true }`.", notes: "Namespaced per session: no page can read another page's keys." },
251
+ });
252
+
253
+ // --- starting and steering work --------------------------------------------
254
+
255
+ const sessionTarget = s.object({ sessionId: entityId("Session id") });
256
+
257
+ export type SessionsSendParams = s.Infer<typeof sessionsSendParams>;
258
+ const sessionsSendParams = s.object({
259
+ sessionId: entityId("Session id"),
260
+ prompt,
261
+ mode: s.withDefault(s.literal(["queue", "steer"], "Mode"), "queue"),
262
+ });
263
+
264
+ export const sessionsSend = spec({
265
+ method: "sessions.send",
266
+ description: "Send a prompt to another existing session.",
267
+ effect: "cross-session-write",
268
+ confirmed: true,
269
+ implemented: true,
270
+ validateParams: params(sessionsSendParams),
271
+ validateResult: result(s.object({ sessionId: entityId("Session id"), delivery: s.literal(["started", "queued", "steered"]), duplicate: s.boolean() })),
272
+ doc: {
273
+ params: "`{ sessionId, prompt, mode? }` — `mode` `queue` (default) or `steer`.",
274
+ result: "`{ sessionId, delivery, duplicate }`.",
275
+ notes: "Refuses this page's own session with `invalid_params`; use `session.reply` for that.",
276
+ },
277
+ });
278
+
279
+ export type SessionsStartParams = s.Infer<typeof sessionsStartParams>;
280
+ const sessionsStartParams = s.object({
281
+ projectId: entityId("Project id"),
282
+ prompt,
283
+ title: s.optional(title()),
284
+ providerId: s.optional(entityId("Provider id")),
285
+ model: s.optional(safeName("Model id")),
286
+ reasoningLevel: s.optional(s.literal(["none", "low", "medium", "high", "xhigh", "max", "ultra", "ultracode"], "Reasoning level")),
287
+ environment: s.withDefault(
288
+ s.union(s.literal(["project-default"]), s.object({ sameAs: entityId("Session id") }), "Environment"),
289
+ "project-default" as const,
290
+ ),
291
+ });
292
+
293
+ export const sessionsStart = spec({
294
+ method: "sessions.start",
295
+ description: "Start a new visible root session in a project.",
296
+ effect: "cross-session-write",
297
+ confirmed: true,
298
+ implemented: true,
299
+ validateParams: params(sessionsStartParams),
300
+ validateResult: result(s.object({ sessionId: entityId("Session id") })),
301
+ doc: {
302
+ params: "`{ projectId, prompt, title?, providerId?, model?, reasoningLevel?, environment? }`.",
303
+ result: "`{ sessionId }`.",
304
+ notes:
305
+ "Defaults when you say nothing: the project's default environment, the project's default provider, model and reasoning level. Say otherwise with `providerId`/`model`/`reasoningLevel` from `providers.list`, or `environment: { sameAs: sessionId }` to run in the same environment as another session. The started session is a visible root owned by the reader, never a child of this page's session.",
306
+ },
307
+ });
308
+
309
+ export const sessionsStop = spec({
310
+ method: "sessions.stop",
311
+ description: "Stop a session's running turn.",
312
+ effect: "destructive",
313
+ confirmed: true,
314
+ implemented: true,
315
+ validateParams: params(sessionTarget),
316
+ validateResult: result(s.object({ stopped: s.boolean() })),
317
+ doc: { params: "`{ sessionId }`.", result: "`{ stopped }`.", notes: "Refuses this page's own session outright, before any dialog." },
318
+ });
319
+
320
+ export const sessionsMarkRead = spec({
321
+ method: "sessions.markRead",
322
+ description: "Mark a session read or unread for the reader.",
323
+ effect: "reader-state",
324
+ confirmed: false,
325
+ implemented: true,
326
+ validateParams: params(s.object({ sessionId: entityId("Session id"), read: s.withDefault(s.boolean(), true) })),
327
+ validateResult: result(s.object({ sessionId: entityId("Session id"), unread: s.boolean() })),
328
+ doc: {
329
+ params: "`{ sessionId, read? }` — `read` defaults to true; `false` marks it unread again.",
330
+ result: "`{ sessionId, unread }`, the mark after the change.",
331
+ notes: "Changes only the reader's own attention mark, the one the host's sidebar shows; it never touches the session's work, so it is not confirmed.",
332
+ },
333
+ });
334
+
335
+ export const sessionsArchive = spec({
336
+ method: "sessions.archive",
337
+ description: "Archive a session.",
338
+ effect: "destructive",
339
+ confirmed: true,
340
+ implemented: true,
341
+ validateParams: params(sessionTarget),
342
+ validateResult: result(s.object({ archived: s.boolean() })),
343
+ doc: { params: "`{ sessionId }`.", result: "`{ archived }`." },
344
+ });
345
+
346
+ // --- navigation --------------------------------------------------------------
347
+
348
+ const openedResult = result(s.object({ opened: s.boolean() }));
349
+
350
+ export const pagesOpen = spec({
351
+ method: "pages.open",
352
+ description: "Open another session's page in place.",
353
+ effect: "navigation",
354
+ confirmed: false,
355
+ implemented: true,
356
+ validateParams: params(sessionTarget),
357
+ validateResult: openedResult,
358
+ doc: { params: "`{ sessionId }`.", result: "`{ opened: true }`, after which the reader's view navigates in place; the back button returns here." },
359
+ });
360
+
361
+ export const sessionsOpenHost = spec({
362
+ method: "sessions.openHost",
363
+ description: "Open a session in the host application.",
364
+ effect: "navigation",
365
+ confirmed: false,
366
+ implemented: true,
367
+ validateParams: params(sessionTarget),
368
+ validateResult: openedResult,
369
+ doc: { params: "`{ sessionId }`.", result: "`{ opened: true }`; navigates the reader's view in place to the session's conversation." },
370
+ });
371
+
372
+ export type OpenExternalParams = s.Infer<typeof openExternalParams>;
373
+ const openExternalParams = s.object({
374
+ url: s.refine(s.string({ min: 1, max: 2048, pattern: /^[^\u0000-\u0020\u007f]+$/, label: "URL" }), (value) => {
375
+ let parsed: URL;
376
+ try {
377
+ parsed = new URL(value);
378
+ } catch {
379
+ return "Expected an absolute http or https URL";
380
+ }
381
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || !parsed.hostname || parsed.username || parsed.password) {
382
+ return "Expected an absolute http or https URL without credentials";
383
+ }
384
+ return null;
385
+ }),
386
+ label: s.optional(s.string({ min: 1, max: 160, label: "Label" })),
387
+ });
388
+
389
+ export const navigationOpenExternal = spec({
390
+ method: "navigation.openExternal",
391
+ description: "Open an external http(s) URL through trusted chrome.",
392
+ effect: "navigation",
393
+ confirmed: true,
394
+ implemented: true,
395
+ validateParams: params(openExternalParams),
396
+ validateResult: openedResult,
397
+ doc: {
398
+ params: "`{ url, label? }` — http or https only.",
399
+ result: "`{ opened: true }`. The confirmation names the destination origin. An ordinary `<a href=\"https://…\">` in your page goes through this automatically.",
400
+ },
401
+ });
402
+
403
+ // --- device --------------------------------------------------------------------
404
+
405
+ export const projectsBrowse = spec({
406
+ method: "projects.browse",
407
+ description: "Open the host's folder picker and return an opaque selection token.",
408
+ effect: "device",
409
+ confirmed: true,
410
+ implemented: true,
411
+ validateParams: params(s.noParams()),
412
+ validateResult: result(
413
+ s.object({
414
+ selection: s.nullable(
415
+ s.object({
416
+ token: s.string({ min: 1, max: 512, pattern: /^[A-Za-z0-9][A-Za-z0-9._~:-]*$/, label: "Selection token" }),
417
+ displayPath: s.string({ min: 1, max: 1024, label: "Display path" }),
418
+ hostName: title("Host name"),
419
+ }),
420
+ ),
421
+ }),
422
+ ),
423
+ doc: {
424
+ params: "None.",
425
+ result: `\`{ selection: null }\` when the reader cancels, else \`{ selection: { token, displayPath, hostName } }\`. The token is single use, valid for ${LIMITS.selectionTokenMs / 60_000} minutes and only for this page; the page never sees a filesystem path.`,
426
+ },
427
+ });
428
+
429
+ export const projectsCreate = spec({
430
+ method: "projects.create",
431
+ description: "Create a project from a folder-picker selection.",
432
+ effect: "cross-session-write",
433
+ confirmed: true,
434
+ implemented: true,
435
+ validateParams: params(s.object({ selectionToken: s.string({ min: 1, max: 512, pattern: /^[A-Za-z0-9][A-Za-z0-9._~:-]*$/, label: "Selection token" }), name: s.optional(title("Name")) })),
436
+ validateResult: result(s.object({ project: projectChoice })),
437
+ doc: { params: "`{ selectionToken, name? }`.", result: "`{ project: { id, name, kind } }`.", notes: "An expired, reused or foreign token fails with `not_found`." },
438
+ });
439
+
440
+ export const voiceCaptureAndTranscribe = spec({
441
+ method: "voice.captureAndTranscribe",
442
+ description: "Record and transcribe the reader's voice through trusted chrome.",
443
+ effect: "device",
444
+ confirmed: true,
445
+ implemented: false,
446
+ validateParams: params(
447
+ s.object({
448
+ language: s.optional(s.string({ min: 2, max: 64, pattern: /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/, label: "Language" })),
449
+ prompt: s.optional(s.string({ max: 1000, label: "Prompt" })),
450
+ maxDurationSeconds: s.withDefault(s.integer(1, 120, "Duration"), 120),
451
+ }),
452
+ ),
453
+ validateResult: result(s.object({ text: s.string({ max: LIMITS.resultTextBytes, label: "Transcript" }) })),
454
+ doc: { params: "`{ language?, prompt?, maxDurationSeconds? }`.", result: "`{ text }`.", notes: "Deferred: the contract exists, the host reports `unknown_method`." },
455
+ });
456
+
457
+ export const ALL_CAPABILITIES = Object.freeze([
458
+ contextGet,
459
+ sessionActivity,
460
+ sessionsSnapshot,
461
+ projectsList,
462
+ providersList,
463
+ storageGet,
464
+ sessionReply,
465
+ storageSet,
466
+ pagesOpen,
467
+ sessionsOpenHost,
468
+ sessionsSend,
469
+ sessionsStart,
470
+ projectsCreate,
471
+ sessionsStop,
472
+ sessionsArchive,
473
+ sessionsMarkRead,
474
+ navigationOpenExternal,
475
+ projectsBrowse,
476
+ voiceCaptureAndTranscribe,
477
+ ]);
478
+
479
+ export { isEntityId, isOpaqueToken, isStorageKey };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Which sessions may own a page. spec 01 §Eligibility
3
+ *
4
+ * A session is eligible when it is visible (not an internal helper), a root
5
+ * (not a child, not a fork) and live (not archived, not deleted).
6
+ */
7
+ export interface EligibilityFacts {
8
+ readonly visibility: "visible" | "hidden";
9
+ readonly parentId: string | null;
10
+ readonly forkOfId: string | null;
11
+ readonly archived: boolean;
12
+ readonly deleted: boolean;
13
+ }
14
+
15
+ export type IneligibleReason = "hidden" | "child" | "fork" | "archived" | "deleted";
16
+
17
+ export function ineligibleReason(facts: EligibilityFacts): IneligibleReason | null {
18
+ if (facts.deleted) return "deleted";
19
+ if (facts.archived) return "archived";
20
+ if (facts.visibility !== "visible") return "hidden";
21
+ if (facts.parentId !== null) return "child";
22
+ if (facts.forkOfId !== null) return "fork";
23
+ return null;
24
+ }
25
+
26
+ export function isEligible(facts: EligibilityFacts): boolean {
27
+ return ineligibleReason(facts) === null;
28
+ }
29
+
30
+ export function describeIneligible(reason: IneligibleReason): string {
31
+ switch (reason) {
32
+ case "hidden":
33
+ return "this session is a hidden helper";
34
+ case "child":
35
+ return "this session is a child of another session";
36
+ case "fork":
37
+ return "this session is a fork of another session";
38
+ case "archived":
39
+ return "this session is archived";
40
+ case "deleted":
41
+ return "this session is deleted";
42
+ }
43
+ }
@@ -0,0 +1,116 @@
1
+ import { LIMITS } from "./limits.ts";
2
+
3
+ /** The fixed error set a page may see. spec R5.38 */
4
+ export const BRIDGE_ERROR_CODES = [
5
+ "invalid_json",
6
+ "invalid_request",
7
+ "invalid_params",
8
+ "invalid_response",
9
+ "request_too_large",
10
+ "response_too_large",
11
+ "unsupported_version",
12
+ "unknown_method",
13
+ "stale_page",
14
+ "confirmation_required",
15
+ "confirmation_invalid",
16
+ "cancelled",
17
+ "not_found",
18
+ "conflict",
19
+ "unavailable",
20
+ "rate_limited",
21
+ "handler_error",
22
+ "invalid_result",
23
+ ] as const;
24
+
25
+ export type BridgeErrorCode = (typeof BRIDGE_ERROR_CODES)[number];
26
+
27
+ const BRIDGE_ERROR_CODE_SET: ReadonlySet<string> = new Set(BRIDGE_ERROR_CODES);
28
+
29
+ export function isBridgeErrorCode(value: unknown): value is BridgeErrorCode {
30
+ return typeof value === "string" && BRIDGE_ERROR_CODE_SET.has(value);
31
+ }
32
+
33
+ /** Codes that only routes (not the bridge) produce. */
34
+ export type RouteErrorCode =
35
+ | "forbidden"
36
+ | "ineligible"
37
+ | "invalid_session"
38
+ | "no_page"
39
+ | "page_too_large";
40
+
41
+ export type PageErrorCode = BridgeErrorCode | RouteErrorCode;
42
+
43
+ const STATUS_BY_CODE: Record<PageErrorCode, number> = {
44
+ invalid_json: 400,
45
+ invalid_request: 400,
46
+ invalid_params: 400,
47
+ invalid_response: 502,
48
+ request_too_large: 413,
49
+ response_too_large: 500,
50
+ unsupported_version: 400,
51
+ unknown_method: 404,
52
+ stale_page: 409,
53
+ confirmation_required: 401,
54
+ confirmation_invalid: 403,
55
+ cancelled: 400,
56
+ not_found: 404,
57
+ conflict: 409,
58
+ unavailable: 503,
59
+ rate_limited: 429,
60
+ handler_error: 500,
61
+ invalid_result: 500,
62
+ forbidden: 403,
63
+ ineligible: 404,
64
+ invalid_session: 400,
65
+ no_page: 404,
66
+ page_too_large: 413,
67
+ };
68
+
69
+ /**
70
+ * One failure: a machine code, a message safe to show a reader or a page,
71
+ * an HTTP status, and (server-side only) the real cause for the log.
72
+ * spec R2.41–R2.43
73
+ */
74
+ export class PageError extends Error {
75
+ readonly code: PageErrorCode;
76
+ readonly status: number;
77
+ override readonly cause: unknown;
78
+
79
+ constructor(code: PageErrorCode, publicMessage: string, options?: { cause?: unknown; status?: number }) {
80
+ super(boundedMessage(publicMessage));
81
+ this.name = "PageError";
82
+ this.code = code;
83
+ this.status = options?.status ?? STATUS_BY_CODE[code];
84
+ this.cause = options?.cause;
85
+ }
86
+
87
+ static is(value: unknown): value is PageError {
88
+ return value instanceof PageError;
89
+ }
90
+ }
91
+
92
+ export function boundedMessage(message: string): string {
93
+ const normalized = message.replace(/\s+/g, " ").trim() || "Request failed";
94
+ return normalized.length <= LIMITS.errorMessageChars
95
+ ? normalized
96
+ : `${normalized.slice(0, LIMITS.errorMessageChars - 1)}…`;
97
+ }
98
+
99
+ /** The text of an unknown error, for logs only (never for a page). */
100
+ export function errorText(error: unknown): string {
101
+ if (error instanceof Error) return error.message;
102
+ return String(error);
103
+ }
104
+
105
+ export const PUBLIC_MESSAGES = Object.freeze({
106
+ noPage: "This session has no page yet. Run `bb thread-page init` in the session first.",
107
+ ineligible: "Only visible root sessions have pages.",
108
+ pageTooLarge: `The page's entry document is larger than ${LIMITS.entryDocumentBytes / (1024 * 1024)} MiB and was not served.`,
109
+ unavailable: "The page's source is unreachable. Reconnect its host and try again.",
110
+ staleCopy: "The source host is offline; this cached page is read-only.",
111
+ stalePage: "This page changed; reload it before responding.",
112
+ handler: "Could not execute the page action.",
113
+ rateLimited: "Too many requests from this page; try again shortly.",
114
+ invalidSession: "A valid session id is required.",
115
+ tokenInvalid: "This page session is invalid or expired; reload the page.",
116
+ });