@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
@@ -0,0 +1,59 @@
1
+ import { LIMITS } from "../limits.ts";
2
+
3
+ /**
4
+ * Bounded, expiring memory of outcomes keyed by a client id. A repeat with
5
+ * the same fingerprint replays the first outcome; a repeat with a different
6
+ * fingerprint is a conflict. spec R2.33, R2.34, R5.17
7
+ */
8
+ export type Remembered<T> =
9
+ | { readonly kind: "fresh"; readonly outcome: Promise<T> }
10
+ | { readonly kind: "replay"; readonly outcome: Promise<T> }
11
+ | { readonly kind: "conflict" };
12
+
13
+ interface Record<T> {
14
+ expiresAt: number;
15
+ fingerprint: string;
16
+ outcome: Promise<T>;
17
+ }
18
+
19
+ export interface OutcomeMemory<T> {
20
+ remember(key: string, fingerprint: string, produce: () => Promise<T>, now: number): Remembered<T>;
21
+ size(): number;
22
+ }
23
+
24
+ export function createOutcomeMemory<T>(options: { maxRecords?: number; ttlMs?: number } = {}): OutcomeMemory<T> {
25
+ const maxRecords = options.maxRecords ?? LIMITS.idempotencyRecords;
26
+ const ttlMs = options.ttlMs ?? LIMITS.idempotencyMs;
27
+ const records = new Map<string, Record<T>>();
28
+
29
+ function prune(now: number): void {
30
+ for (const [key, record] of records) {
31
+ if (record.expiresAt <= now) records.delete(key);
32
+ }
33
+ while (records.size >= maxRecords) {
34
+ const oldest = records.keys().next().value;
35
+ if (oldest === undefined) break;
36
+ records.delete(oldest);
37
+ }
38
+ }
39
+
40
+ return {
41
+ remember(key, fingerprint, produce, now) {
42
+ prune(now);
43
+ const existing = records.get(key);
44
+ if (existing) {
45
+ if (existing.fingerprint !== fingerprint) return { kind: "conflict" };
46
+ return { kind: "replay", outcome: existing.outcome };
47
+ }
48
+ const outcome = produce();
49
+ const record: Record<T> = { expiresAt: now + ttlMs, fingerprint, outcome };
50
+ records.set(key, record);
51
+ // A failed attempt must not poison the key: the next try is fresh.
52
+ outcome.catch(() => {
53
+ if (records.get(key) === record) records.delete(key);
54
+ });
55
+ return { kind: "fresh", outcome };
56
+ },
57
+ size: () => records.size,
58
+ };
59
+ }
@@ -0,0 +1,42 @@
1
+ import type { JsonValue } from "../json/strict-json.ts";
2
+ import { UPLOAD_DIR } from "../../pages/layout.ts";
3
+ import type { Submission } from "./parse.ts";
4
+
5
+ /**
6
+ * The message a form submission becomes. It names itself, names the form,
7
+ * leads with the chosen action, presents each answer under the label the
8
+ * reader saw, and states blanks explicitly. spec R2.35, R2.36
9
+ */
10
+ export function formatSubmissionMessage(submission: Submission): string {
11
+ const heading = submission.title.trim() || "Thread Page";
12
+ const sections = submission.answers.map((answer) => {
13
+ const label = answer.label.trim() || answer.name;
14
+ return `**${label}**\n${formatValue(answer.value)}`;
15
+ });
16
+ if (submission.files.length > 0) {
17
+ sections.push(
18
+ [
19
+ "**Attached files**",
20
+ ...submission.files.map((file) => `- \`$BB_THREAD_STORAGE/${file.path}\` (${file.name}, ${file.sizeBytes} bytes)`),
21
+ `They are in the \`${UPLOAD_DIR}/\` directory of your page root; read them with your normal tools.`,
22
+ ].join("\n"),
23
+ );
24
+ }
25
+ return [`The user answered the form on your Thread Page — ${heading}.`, ...sections].join("\n\n");
26
+ }
27
+
28
+ function formatValue(value: string | string[] | boolean): string {
29
+ if (typeof value === "boolean") return value ? "Yes" : "No";
30
+ if (Array.isArray(value)) return value.length > 0 ? value.join(", ") : "(left blank)";
31
+ return value.length > 0 ? value : "(left blank)";
32
+ }
33
+
34
+ /** The message a `session.reply` becomes. spec R5.16 */
35
+ export function formatReplyMessage(title: string | undefined, result: JsonValue): string {
36
+ const heading = title?.trim() || "Interactive response";
37
+ const serialized = JSON.stringify(result, null, 2) ?? "null";
38
+ let longestRun = 0;
39
+ for (const match of serialized.matchAll(/`+/g)) longestRun = Math.max(longestRun, match[0].length);
40
+ const fence = "`".repeat(Math.max(3, longestRun + 1));
41
+ return [`The user sent an interactive response from your Thread Page — ${heading}.`, `**Result**\n\n${fence}json\n${serialized}\n${fence}`].join("\n\n");
42
+ }
@@ -0,0 +1,105 @@
1
+ import { isRevision } from "../ids.ts";
2
+ import { LIMITS } from "../limits.ts";
3
+ import { UPLOAD_DIR, isSafeUploadName } from "../../pages/layout.ts";
4
+
5
+ /** A form answer as the kernel delivers it. spec R4.12 */
6
+ export type AnswerValue = string | string[] | boolean;
7
+
8
+ export interface Answer {
9
+ readonly name: string;
10
+ readonly label: string;
11
+ readonly value: AnswerValue;
12
+ }
13
+
14
+ export interface SubmissionFile {
15
+ readonly field: string;
16
+ readonly name: string;
17
+ readonly path: string;
18
+ readonly sizeBytes: number;
19
+ }
20
+
21
+ export interface Submission {
22
+ readonly actionToken: string;
23
+ readonly submissionId: string;
24
+ readonly pageRevision: string;
25
+ readonly title: string;
26
+ readonly answers: readonly Answer[];
27
+ readonly files: readonly SubmissionFile[];
28
+ }
29
+
30
+ /** Parses and bounds a submission body; returns null when it is not one. spec R2.32 */
31
+ export function parseSubmission(value: unknown): Submission | null {
32
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
33
+ const input = value as Record<string, unknown>;
34
+ if (
35
+ typeof input.actionToken !== "string" ||
36
+ input.actionToken.length === 0 ||
37
+ input.actionToken.length > LIMITS.tokenChars ||
38
+ typeof input.submissionId !== "string" ||
39
+ !/^[A-Za-z0-9._-]{1,128}$/.test(input.submissionId) ||
40
+ !isRevision(input.pageRevision) ||
41
+ typeof input.title !== "string" ||
42
+ input.title.length > 300 ||
43
+ !Array.isArray(input.answers) ||
44
+ input.answers.length > LIMITS.answersPerSubmission
45
+ ) {
46
+ return null;
47
+ }
48
+
49
+ const answers: Answer[] = [];
50
+ let total = input.title.length;
51
+ for (const raw of input.answers) {
52
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
53
+ const answer = raw as Record<string, unknown>;
54
+ if (typeof answer.name !== "string" || answer.name.length > 128 || typeof answer.label !== "string" || answer.label.length > 300) return null;
55
+ if (!isAnswerValue(answer.value)) return null;
56
+ total += answer.name.length + answer.label.length + valueLength(answer.value);
57
+ if (total > LIMITS.submissionBodyBytes) return null;
58
+ answers.push({ name: answer.name, label: answer.label, value: answer.value });
59
+ }
60
+
61
+ const files: SubmissionFile[] = [];
62
+ if (input.files !== undefined) {
63
+ if (!Array.isArray(input.files) || input.files.length > LIMITS.uploadsPerForm) return null;
64
+ for (const raw of input.files) {
65
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
66
+ const file = raw as Record<string, unknown>;
67
+ if (
68
+ typeof file.field !== "string" ||
69
+ file.field.length > 128 ||
70
+ typeof file.name !== "string" ||
71
+ !isSafeUploadName(file.name) ||
72
+ typeof file.path !== "string" ||
73
+ file.path !== `${UPLOAD_DIR}/${file.name}` ||
74
+ typeof file.sizeBytes !== "number" ||
75
+ !Number.isSafeInteger(file.sizeBytes) ||
76
+ file.sizeBytes < 0 ||
77
+ file.sizeBytes > LIMITS.uploadFileBytes
78
+ ) {
79
+ return null;
80
+ }
81
+ files.push({ field: file.field, name: file.name, path: file.path, sizeBytes: file.sizeBytes });
82
+ }
83
+ }
84
+
85
+ return {
86
+ actionToken: input.actionToken,
87
+ submissionId: input.submissionId,
88
+ pageRevision: input.pageRevision,
89
+ title: input.title,
90
+ answers,
91
+ files,
92
+ };
93
+ }
94
+
95
+ function isAnswerValue(value: unknown): value is AnswerValue {
96
+ if (typeof value === "boolean") return true;
97
+ if (typeof value === "string") return value.length <= LIMITS.answerValueChars;
98
+ return Array.isArray(value) && value.length <= LIMITS.answerListItems && value.every((item) => typeof item === "string" && item.length <= 2_000);
99
+ }
100
+
101
+ function valueLength(value: AnswerValue): number {
102
+ if (typeof value === "boolean") return 1;
103
+ if (typeof value === "string") return value.length;
104
+ return value.reduce((sum, item) => sum + item.length, 0);
105
+ }
@@ -0,0 +1,52 @@
1
+ import { isRevision, isSessionId } from "../ids.ts";
2
+ import { LIMITS } from "../limits.ts";
3
+ import { isRecord, lifetimeValid, openToken, signPayload } from "./mac.ts";
4
+
5
+ /**
6
+ * The action token: authority to act as one page's owner session, for one
7
+ * page revision, for a bounded time. Held only by the shell, sent only in
8
+ * request bodies. spec R2.7–R2.10
9
+ */
10
+ export interface ActionToken {
11
+ readonly v: 3;
12
+ readonly scope: "action";
13
+ readonly session: string;
14
+ readonly revision: string;
15
+ readonly iat: number;
16
+ readonly exp: number;
17
+ }
18
+
19
+ export function mintActionToken(args: { session: string; revision: string; now: number }, key: Uint8Array): { token: string; payload: ActionToken } {
20
+ const payload: ActionToken = {
21
+ v: 3,
22
+ scope: "action",
23
+ session: args.session,
24
+ revision: args.revision,
25
+ iat: args.now,
26
+ exp: args.now + LIMITS.actionTokenMs,
27
+ };
28
+ return { token: signPayload(payload, key), payload };
29
+ }
30
+
31
+ export function verifyActionToken(token: string, key: Uint8Array, now: number): ActionToken | null {
32
+ if (typeof token !== "string" || token.length === 0 || token.length > LIMITS.tokenChars) return null;
33
+ const payload = openToken(token, key);
34
+ if (!isRecord(payload)) return null;
35
+ if (
36
+ payload.v !== 3 ||
37
+ payload.scope !== "action" ||
38
+ !isSessionId(payload.session) ||
39
+ !isRevision(payload.revision) ||
40
+ !lifetimeValid({ iat: payload.iat, exp: payload.exp }, now, LIMITS.actionTokenMs)
41
+ ) {
42
+ return null;
43
+ }
44
+ return {
45
+ v: 3,
46
+ scope: "action",
47
+ session: payload.session,
48
+ revision: payload.revision,
49
+ iat: payload.iat as number,
50
+ exp: payload.exp as number,
51
+ };
52
+ }
@@ -0,0 +1,99 @@
1
+ import { isMethodName, isRequestId, isRevision, isSessionId } from "../ids.ts";
2
+ import { fingerprint } from "../json/canonical.ts";
3
+ import type { JsonValue } from "../json/strict-json.ts";
4
+ import { LIMITS } from "../limits.ts";
5
+ import { isRecord, lifetimeValid, openToken, signPayload } from "./mac.ts";
6
+
7
+ /**
8
+ * A signed confirmation challenge for one confirmed capability call.
9
+ *
10
+ * Minted by the host with its own summary, shown by the trusted shell,
11
+ * returned unchanged, and verified before acting. Bound to one request id,
12
+ * method, canonical parameter fingerprint, page revision and session; short
13
+ * lived. spec R3.17–R3.21
14
+ */
15
+ export interface ConfirmationChallenge {
16
+ readonly v: 3;
17
+ readonly scope: "confirm";
18
+ readonly session: string;
19
+ readonly revision: string;
20
+ readonly requestId: string;
21
+ readonly method: string;
22
+ readonly paramsHash: string;
23
+ readonly summary: string;
24
+ readonly iat: number;
25
+ readonly exp: number;
26
+ }
27
+
28
+ export interface ConfirmationBinding {
29
+ readonly session: string;
30
+ readonly revision: string;
31
+ readonly requestId: string;
32
+ readonly method: string;
33
+ readonly params: JsonValue;
34
+ }
35
+
36
+ export function paramsFingerprint(params: JsonValue): string {
37
+ return fingerprint(params);
38
+ }
39
+
40
+ export function mintChallenge(binding: ConfirmationBinding, summary: string, now: number, key: Uint8Array): { challenge: string; payload: ConfirmationChallenge } {
41
+ const bounded = summary.length <= LIMITS.summaryChars ? summary : `${summary.slice(0, LIMITS.summaryChars - 1)}…`;
42
+ const payload: ConfirmationChallenge = {
43
+ v: 3,
44
+ scope: "confirm",
45
+ session: binding.session,
46
+ revision: binding.revision,
47
+ requestId: binding.requestId,
48
+ method: binding.method,
49
+ paramsHash: paramsFingerprint(binding.params),
50
+ summary: bounded,
51
+ iat: now,
52
+ exp: now + LIMITS.confirmationMs,
53
+ };
54
+ return { challenge: signPayload(payload, key), payload };
55
+ }
56
+
57
+ export function openChallenge(challenge: string, key: Uint8Array, now: number): ConfirmationChallenge | null {
58
+ if (typeof challenge !== "string" || challenge.length === 0 || challenge.length > LIMITS.tokenChars) return null;
59
+ const payload = openToken(challenge, key);
60
+ if (!isRecord(payload)) return null;
61
+ if (
62
+ payload.v !== 3 ||
63
+ payload.scope !== "confirm" ||
64
+ !isSessionId(payload.session) ||
65
+ !isRevision(payload.revision) ||
66
+ !isRequestId(payload.requestId) ||
67
+ !isMethodName(payload.method) ||
68
+ !isRevision(payload.paramsHash) ||
69
+ typeof payload.summary !== "string" ||
70
+ payload.summary.length === 0 ||
71
+ payload.summary.length > LIMITS.summaryChars ||
72
+ !lifetimeValid({ iat: payload.iat, exp: payload.exp }, now, LIMITS.confirmationMs)
73
+ ) {
74
+ return null;
75
+ }
76
+ return {
77
+ v: 3,
78
+ scope: "confirm",
79
+ session: payload.session,
80
+ revision: payload.revision,
81
+ requestId: payload.requestId,
82
+ method: payload.method,
83
+ paramsHash: payload.paramsHash,
84
+ summary: payload.summary,
85
+ iat: payload.iat as number,
86
+ exp: payload.exp as number,
87
+ };
88
+ }
89
+
90
+ /** A challenge approves exactly one invocation: every bound field must match. */
91
+ export function challengeMatches(challenge: ConfirmationChallenge, binding: ConfirmationBinding): boolean {
92
+ return (
93
+ challenge.session === binding.session &&
94
+ challenge.revision === binding.revision &&
95
+ challenge.requestId === binding.requestId &&
96
+ challenge.method === binding.method &&
97
+ challenge.paramsHash === paramsFingerprint(binding.params)
98
+ );
99
+ }
@@ -0,0 +1,50 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * Signed tokens: `<base64url(json)>.<base64url(hmac-sha256)>`, verified in
5
+ * constant time with a key only the host holds. spec R2.6
6
+ */
7
+ export function signPayload(payload: unknown, key: Uint8Array): string {
8
+ const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
9
+ return `${encoded}.${signature(encoded, key)}`;
10
+ }
11
+
12
+ /** Returns the decoded payload when the signature verifies, otherwise null. */
13
+ export function openToken(token: string, key: Uint8Array): unknown | null {
14
+ const parts = token.split(".");
15
+ if (parts.length !== 2) return null;
16
+ const [encoded, supplied] = parts as [string, string];
17
+ if (!encoded || !supplied) return null;
18
+ try {
19
+ const expected = Buffer.from(signature(encoded, key), "ascii");
20
+ const given = Buffer.from(supplied, "ascii");
21
+ if (given.byteLength !== expected.byteLength) return null;
22
+ if (!timingSafeEqual(given, expected)) return null;
23
+ return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as unknown;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function signature(encoded: string, key: Uint8Array): string {
30
+ return createHmac("sha256", key).update(encoded, "ascii").digest("base64url");
31
+ }
32
+
33
+ export function isRecord(value: unknown): value is Record<string, unknown> {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
36
+
37
+ /** A token lifetime check shared by every token kind. */
38
+ export function lifetimeValid(payload: { iat: unknown; exp: unknown }, now: number, maxLifetimeMs: number): boolean {
39
+ const { iat, exp } = payload;
40
+ return (
41
+ typeof iat === "number" &&
42
+ Number.isSafeInteger(iat) &&
43
+ typeof exp === "number" &&
44
+ Number.isSafeInteger(exp) &&
45
+ iat <= now + 30_000 &&
46
+ exp > now &&
47
+ exp > iat &&
48
+ exp - iat <= maxLifetimeMs
49
+ );
50
+ }
@@ -0,0 +1,3 @@
1
+ // Generated by scripts/build-runtime.mjs from src/runtime/kernel/main.ts.
2
+ // Do not edit; run `npm run build:runtime`.
3
+ export const KERNEL_RUNTIME = "\"use strict\";(()=>{var ee=Object.defineProperty;var te=(e,t,n)=>t in e?ee(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var F=(e,t,n)=>te(e,typeof t!=\"symbol\"?t+\"\":t,n);var v=Object.freeze({entryDocumentBytes:5242880,uploadFileBytes:25165824,uploadsPerForm:8,submissionBodyBytes:65536,answersPerSubmission:64,answerValueChars:8e3,answerListItems:64,capabilityPayloadBytes:65536,capabilityJsonDepth:16,capabilityJsonNodes:1e4,promptChars:32768,resultTextBytes:65536,titleChars:240,storageValueBytes:32768,storageKeyChars:128,snapshotDefault:100,snapshotMax:200,activityDefault:8,activityMax:20,actionTokenMs:72e5,confirmationMs:12e4,selectionTokenMs:6e5,selectionTokens:32,idempotencyRecords:512,idempotencyMs:3e5,ratePerMinute:120,rateConcurrent:8,shellPollMs:1e4,watchDefaultMs:8e3,watchMinMs:2e3,watchMaxMs:3e5,inlineFileBytes:2097152,inlineTotalBytes:3145728,inlineCssDepth:3,offlineCopyBytes:204800,offlineCacheEntries:32,offlineCacheBytes:8388608,requestIdChars:96,methodNameChars:96,tokenChars:4096,errorMessageChars:512,summaryChars:512,projectsMax:200,providersMax:64,modelsPerProvider:64});var A=[\"invalid_json\",\"invalid_request\",\"invalid_params\",\"invalid_response\",\"request_too_large\",\"response_too_large\",\"unsupported_version\",\"unknown_method\",\"stale_page\",\"confirmation_required\",\"confirmation_invalid\",\"cancelled\",\"not_found\",\"conflict\",\"unavailable\",\"rate_limited\",\"handler_error\",\"invalid_result\"],he=new Set(A);var ye=Object.freeze({noPage:\"This session has no page yet. Run `bb thread-page init` in the session first.\",ineligible:\"Only visible root sessions have pages.\",pageTooLarge:`The page's entry document is larger than ${v.entryDocumentBytes/(1024*1024)} MiB and was not served.`,unavailable:\"The page's source is unreachable. Reconnect its host and try again.\",staleCopy:\"The source host is offline; this cached page is read-only.\",stalePage:\"This page changed; reload it before responding.\",handler:\"Could not execute the page action.\",rateLimited:\"Too many requests from this page; try again shortly.\",invalidSession:\"A valid session id is required.\",tokenInvalid:\"This page session is invalid or expired; reload the page.\"});var I=1,B=1,ne=new Set(A);function w(e){return typeof e==\"object\"&&e!==null&&!Array.isArray(e)}function _(e,t){return Object.keys(e).length===t.length&&t.every(o=>Object.prototype.hasOwnProperty.call(e,o))}function q(e,t){if(!w(e)||e.v!==B||typeof e.id!=\"string\"||typeof e.ok!=\"boolean\"||t!==void 0&&e.id!==t)return!1;if(e.ok===!0)return _(e,[\"v\",\"id\",\"ok\",\"result\"]);if(!_(e,[\"v\",\"id\",\"ok\",\"error\"])||!w(e.error))return!1;let n=e.error;return _(n,[\"code\",\"message\"])&&typeof n.code==\"string\"&&ne.has(n.code)&&typeof n.message==\"string\"&&n.message.length>0&&n.message.length<=512}function N(e){let t=e?.getAttribute(\"data-config\");if(!t)throw new Error(\"Thread Page runtime: configuration is missing\");return JSON.parse(t)}function re(e,t,n){let o=e.getAttribute(\"href\");if(o===null)return{kind:\"default\"};if(o.startsWith(\"#\"))return{kind:\"default\"};let s;try{s=new URL(o,n??t)}catch{return{kind:\"block\"}}return s.protocol!==\"http:\"&&s.protocol!==\"https:\"?{kind:\"block\"}:n&&s.href.startsWith(n)?{kind:\"default\"}:e.hasAttribute(\"download\")?{kind:\"default\"}:{kind:\"external\",url:s.href,label:(e.textContent||\"\").replace(/\\s+/g,\" \").trim().slice(0,160)}}function U(e,t){e.addEventListener(\"click\",n=>{if(n.defaultPrevented||n.button!==0)return;let s=n.target?.closest?.(\"a[href]\");if(!s)return;let u=e.querySelector(\"base\")?.getAttribute(\"href\")??null,l=u?new URL(u,e.baseURI).href:null,a=re(s,e.baseURI,l);a.kind!==\"default\"&&(n.preventDefault(),a.kind===\"external\"&&t(a.url,a.label))},!0)}function K(e,t){let n=Object.freeze({version:1,invoke:t.invoke,watch:t.watch,setDirty:t.setDirty});Object.defineProperty(e,\"threadPage\",{value:n,writable:!1,configurable:!1,enumerable:!0})}var T=class extends Error{constructor(n,o){super(o);F(this,\"code\");this.name=\"ThreadPageError\",this.code=n,Object.defineProperty(this,\"code\",{value:n,enumerable:!0,writable:!1})}};function j(e,t){let n=new Map,o=[],s=null,u=0;function l(){return u+=1,`tp-${typeof crypto<\"u\"&&typeof crypto.randomUUID==\"function\"?crypto.randomUUID():`${Date.now()}-${u}`}`}function a(m){let p=n.get(m);if(!(!p||!s))try{s(p.request)}catch(h){n.delete(m),p.reject(new T(\"invalid_request\",h instanceof Error?h.message:\"The request could not be sent\"))}}function f(m,p){return new Promise((h,y)=>{if(typeof m!=\"string\"){y(new T(\"invalid_request\",\"A method name is required\"));return}let b=l(),r={v:B,id:b,method:m,params:p===void 0?null:p,pageRevision:e};n.set(b,{request:r,resolve:h,reject:y}),s?a(b):o.push(b)})}function g(m,p,h,y){if(typeof h!=\"function\")throw new TypeError(\"Thread Page watch needs a listener\");let b=y?.intervalMs,r=typeof b==\"number\"&&Number.isFinite(b)?Math.max(v.watchMinMs,Math.min(v.watchMaxMs,Math.round(b))):v.watchDefaultMs,i=!1,c=!1,d=null;function E(x){i||(d!==null&&clearTimeout(d),d=setTimeout(k,x))}async function k(){if(d=null,!(i||c||t.visibilityState===\"hidden\")){c=!0;try{let x=await f(m,p);i||h(x,null)}catch(x){i||h(void 0,x)}finally{c=!1,i||E(r)}}}function R(){i||(t.visibilityState===\"hidden\"?(d!==null&&clearTimeout(d),d=null):E(0))}return t.addEventListener(\"visibilitychange\",R),E(0),()=>{i||(i=!0,d!==null&&clearTimeout(d),d=null,t.removeEventListener(\"visibilitychange\",R))}}return{invoke:f,watch:g,attach(m){for(s=m;o.length>0;){let p=o.shift();p&&a(p)}},receive(m){if(typeof m!=\"object\"||m===null)return!1;let p=m.id;if(typeof p!=\"string\")return!1;let h=n.get(p);if(!h)return!1;if(n.delete(p),!q(m,p))return h.reject(new T(\"invalid_response\",\"The Thread Page bridge returned an invalid response\")),!0;let y=m;return y.ok?h.resolve(y.result):h.reject(new T(y.error.code,y.error.message)),!0}}}function z(e){let t=new Map,n=0,o=!1,s=!1;function u(){let l=o||t.size>0;l!==s&&(s=l,e(l))}return{isDirty:()=>s,markForm(l){return n+=1,t.set(l,n),u(),n},versionOf:l=>t.get(l),clearForm(l,a){a!==void 0&&t.get(l)===a&&(t.delete(l),u())},setCustom(l){o=l===!0,u()}}}var oe=\"input,textarea,select,button,option,small,output,[data-thread-page-range],[data-thread-page-status]\";function H(e){if(!e)return\"\";let t=e.cloneNode(!0);for(let n of Array.from(t.querySelectorAll(oe)))n.remove();return(t.textContent||\"\").replace(/\\s+/g,\" \").trim()}function ie(e,t){let n=t.getAttribute(\"data-label\");if(n&&n.trim())return n.trim();let o=t.closest(\"fieldset\");if(o){let l=H(o.querySelector(\"legend\"));if(l)return l}let s=t.getAttribute(\"aria-label\");if(s&&s.trim())return s.trim();let u=t.closest(\"label\");if(u){let l=H(u);if(l)return l}if(t.id){let l=e.ownerDocument,a=Array.from(l.querySelectorAll(\"label[for]\")).find(g=>g.htmlFor===t.id),f=H(a??null);if(f)return f}return t.name}var se=new Set([\"button\",\"submit\",\"reset\",\"image\",\"file\"]);function ae(e){return Array.from(e.elements).filter(t=>{let n=t;return typeof n.name==\"string\"&&n.name.length>0&&!n.disabled&&\"type\"in n})}function $(e,t){let n=ae(e),o=[],s=new Set;if(t&&(C(t)===\"button\"||C(t)===\"input\")){let u=t,l=u.value||(u.textContent||\"\").trim();o.push({name:u.name||\"action\",label:\"Action\",value:l}),u.name&&s.add(u.name)}for(let u of n){let l=u.name,a=String(u.type||\"\").toLowerCase();if(s.has(l)||se.has(a))continue;s.add(l);let f=n.filter(g=>g.name===l);o.push({name:l,label:ie(e,u),value:le(u,f,a)})}return o}function C(e){return e.tagName.toLowerCase()}function le(e,t,n){if(n===\"checkbox\"){let o=t.filter(s=>C(s)===\"input\");return o.length===1?o[0]?.checked===!0:o.filter(s=>s.checked).map(s=>s.value)}if(n===\"radio\"){let o=t.find(s=>C(s)===\"input\"&&s.checked);return o?o.value:\"\"}return C(e)===\"select\"&&e.multiple?Array.from(e.selectedOptions).map(o=>o.value):t.length>1?t.map(o=>String(o.value??\"\")):String(e.value??\"\")}var ue=\"data-thread-page-manual\",V=\"data-thread-page-status\",de=\"data-thread-page-range\";function L(e){return e.hasAttribute(ue)}function S(e){let t=[];return\"tagName\"in e&&e.tagName.toLowerCase()===\"form\"&&t.push(e),\"querySelectorAll\"in e&&t.push(...Array.from(e.querySelectorAll(\"form\"))),t.filter(n=>!L(n))}function M(e){let t=e.querySelector(`[${V}]`);return t||(t=e.ownerDocument.createElement(\"p\"),t.setAttribute(V,\"\"),t.setAttribute(\"role\",\"status\"),e.appendChild(t)),t}var G=new WeakSet;function W(e){e.noValidate=!0;for(let t of Array.from(e.querySelectorAll('input[type=\"range\"]'))){if(G.has(t))continue;G.add(t);let n=e.ownerDocument.createElement(\"output\");n.setAttribute(de,\"\");let o=()=>{n.textContent=String(t.value)};t.addEventListener(\"input\",o),o(),t.insertAdjacentElement(\"afterend\",n)}}function P(e){return Array.from(e.querySelectorAll(\"input,textarea,select,button,fieldset\"))}function ce(e){let t=[];for(let n of Array.from(e.querySelectorAll('input[type=\"file\"]')))if(!n.disabled)for(let o of Array.from(n.files??[])){if(t.length>=v.uploadsPerForm)return t;t.push({field:n.name||\"file\",file:o})}return t}function Z(e){let t=[];for(let n of P(e))n.disabled||(n.disabled=!0,t.push(n));return t}function D(e){for(let t of e)t.disabled=!1}function me(e){let t=e.getAttribute(\"data-title\");return t&&t.trim()?t.trim().slice(0,300):(e.ownerDocument.querySelector(\"h1\")?.textContent||\"\").trim().slice(0,300)||\"Thread Page\"}function J(e,t,n){return{submissionId:n,form:e,title:me(e),answers:$(e,t),files:ce(e)}}var X=\"data-thread-page-offline\",O=\"Offline copy \\u2014 responses are disabled until the source host reconnects.\";function Y(e,t){let n=new Set,o=t;function s(){if(!e.body)return;let a=e.querySelector(`[${X}=\"host\"]`);o&&!a?(a=e.createElement(\"aside\"),a.setAttribute(X,\"host\"),a.setAttribute(\"role\",\"status\"),a.setAttribute(\"style\",\"position:relative;z-index:2147483647;margin:0;padding:.75rem 1rem;border-bottom:1px solid currentColor;font:600 14px/1.4 system-ui,sans-serif;background:Canvas;color:CanvasText\"),a.textContent=O,e.body.insertBefore(a,e.body.firstChild)):!o&&a&&a.remove()}function u(a){for(let f of S(a)){for(let g of P(f))g.disabled||(g.disabled=!0,n.add(g));M(f).textContent=O}}function l(){for(let a of n)a.disabled=!1;n.clear();for(let a of S(e)){let f=M(a);f.textContent===O&&(f.textContent=\"\")}}return{isReadOnly:()=>o,apply(a){o=a,a?u(e):l(),s()},prepare(a){o&&u(a),s()}}}function Q(e,t){let n=e.document,o=null,s=new Map,u=new WeakSet;function l(r){if(!o)return!1;try{return o.postMessage(r),!0}catch{return!1}}let a=z(r=>{l({kind:r?\"thread-page:dirty\":\"thread-page:clean\"})}),f=j(t.pageRevision,n),g=Y(n,t.stale);K(e,{version:1,invoke:(r,i)=>f.invoke(r,i),watch:(r,i,c,d)=>f.watch(r,i,c,d),setDirty:r=>a.setCustom(r!==!1)});function m(r){for(let i of S(r))W(i);g.prepare(r)}m(n),n.readyState===\"loading\"&&n.addEventListener(\"DOMContentLoaded\",()=>m(n),{once:!0}),typeof e.MutationObserver==\"function\"&&n.documentElement&&new e.MutationObserver(i=>{for(let c of i)for(let d of Array.from(c.addedNodes))d.nodeType===1&&m(d)}).observe(n.documentElement,{childList:!0,subtree:!0});function p(r){let c=r.target?.closest?.(\"form\");!c||L(c)||a.markForm(c)}n.addEventListener(\"input\",p,!0),n.addEventListener(\"change\",p,!0),n.addEventListener(\"submit\",r=>{let i=r.target;if(!i||i.tagName?.toLowerCase()!==\"form\"||L(i)||(r.preventDefault(),g.isReadOnly()||u.has(i)))return;let c=`sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,10)}`,d=i,E=J(d,r.submitter??null,c),k={form:d,disabled:[],dirtyVersion:a.versionOf(d)};s.set(c,k),u.add(d),M(d).textContent=E.files.length>0?\"Uploading\\u2026\":\"Sending\\u2026\",k.disabled=Z(d),l({kind:\"thread-page:submit\",submissionId:c,title:E.title,answers:E.answers,files:E.files})||(s.delete(c),u.delete(d),D(k.disabled),M(d).textContent=\"Page connection is not ready; try again in a moment.\")},!0),U(n,(r,i)=>{f.invoke(\"navigation.openExternal\",i?{url:r,label:i}:{url:r}).catch(()=>{})});function h(r){if(w(r)){if(r.kind===\"thread-page:source-state\"){g.apply(r.stale===!0);return}if(r.kind===\"thread-page:submit-progress\"){let i=typeof r.submissionId==\"string\"?s.get(r.submissionId):void 0;i&&(M(i.form).textContent=String(r.message??\"Working\\u2026\").slice(0,160));return}if(r.kind===\"thread-page:submit-result\"){let i=typeof r.submissionId==\"string\"?s.get(r.submissionId):void 0;if(!i)return;s.delete(r.submissionId),u.delete(i.form);let c=r.ok===!0;M(i.form).textContent=c?String(r.message??\"Sent\").slice(0,160):String(r.error??\"Could not send\").slice(0,160),D(i.disabled),g.isReadOnly()&&g.apply(!0),c&&a.clearForm(i.form,i.dirtyVersion);return}f.receive(r)}}function y(r){o=r,r.onmessage=i=>h(i.data),r.start?.(),f.attach(i=>{r.postMessage(i)}),a.isDirty()&&l({kind:\"thread-page:dirty\"})}function b(r){if(o||r.source!==e.parent)return;let i=r.data;if(!w(i)||i.kind!==\"thread-page:connect\"||i.version!==I||!r.ports||r.ports.length!==1)return;r.stopImmediatePropagation();let c=r.ports[0];c&&y(c)}return e.addEventListener(\"message\",b,!0),t.stale&&g.apply(!0),e.parent.postMessage({kind:\"thread-page:ready\",version:I},\"*\"),{deliver:r=>h(r),connect:r=>y(r)}}Q(window,N(document.currentScript));})();";
@@ -0,0 +1,3 @@
1
+ // Generated by scripts/build-runtime.mjs from src/runtime/shell/main.ts.
2
+ // Do not edit; run `npm run build:runtime`.
3
+ export const SHELL_RUNTIME = "\"use strict\";(()=>{var M=Object.freeze({entryDocumentBytes:5242880,uploadFileBytes:25165824,uploadsPerForm:8,submissionBodyBytes:65536,answersPerSubmission:64,answerValueChars:8e3,answerListItems:64,capabilityPayloadBytes:65536,capabilityJsonDepth:16,capabilityJsonNodes:1e4,promptChars:32768,resultTextBytes:65536,titleChars:240,storageValueBytes:32768,storageKeyChars:128,snapshotDefault:100,snapshotMax:200,activityDefault:8,activityMax:20,actionTokenMs:72e5,confirmationMs:12e4,selectionTokenMs:6e5,selectionTokens:32,idempotencyRecords:512,idempotencyMs:3e5,ratePerMinute:120,rateConcurrent:8,shellPollMs:1e4,watchDefaultMs:8e3,watchMinMs:2e3,watchMaxMs:3e5,inlineFileBytes:2097152,inlineTotalBytes:3145728,inlineCssDepth:3,offlineCopyBytes:204800,offlineCacheEntries:32,offlineCacheBytes:8388608,requestIdChars:96,methodNameChars:96,tokenChars:4096,errorMessageChars:512,summaryChars:512,projectsMax:200,providersMax:64,modelsPerProvider:64});var x=[\"invalid_json\",\"invalid_request\",\"invalid_params\",\"invalid_response\",\"request_too_large\",\"response_too_large\",\"unsupported_version\",\"unknown_method\",\"stale_page\",\"confirmation_required\",\"confirmation_invalid\",\"cancelled\",\"not_found\",\"conflict\",\"unavailable\",\"rate_limited\",\"handler_error\",\"invalid_result\"],Y=new Set(x);var Q=Object.freeze({noPage:\"This session has no page yet. Run `bb thread-page init` in the session first.\",ineligible:\"Only visible root sessions have pages.\",pageTooLarge:`The page's entry document is larger than ${M.entryDocumentBytes/(1024*1024)} MiB and was not served.`,unavailable:\"The page's source is unreachable. Reconnect its host and try again.\",staleCopy:\"The source host is offline; this cached page is read-only.\",stalePage:\"This page changed; reload it before responding.\",handler:\"Could not execute the page action.\",rateLimited:\"Too many requests from this page; try again shortly.\",invalidSession:\"A valid session id is required.\",tokenInvalid:\"This page session is invalid or expired; reload the page.\"});var E=1,_=1,$=new Set(x),j=/^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/,F=/^[a-z][a-zA-Z0-9]*(?:\\.[a-z][a-zA-Z0-9]*)+$/;function b(e){return typeof e==\"object\"&&e!==null&&!Array.isArray(e)}function R(e,t){return Object.keys(e).length===t.length&&t.every(l=>Object.prototype.hasOwnProperty.call(e,l))}function C(e){return typeof e==\"string\"&&j.test(e)}function T(e,t){return b(e)&&R(e,[\"v\",\"id\",\"method\",\"params\",\"pageRevision\"])&&e.v===_&&C(e.id)&&typeof e.method==\"string\"&&e.method.length>=3&&e.method.length<=96&&F.test(e.method)&&e.pageRevision===t}function B(e,t){if(!b(e)||e.v!==_||typeof e.id!=\"string\"||typeof e.ok!=\"boolean\"||t!==void 0&&e.id!==t)return!1;if(e.ok===!0)return R(e,[\"v\",\"id\",\"ok\",\"result\"]);if(!R(e,[\"v\",\"id\",\"ok\",\"error\"])||!b(e.error))return!1;let o=e.error;return R(o,[\"code\",\"message\"])&&typeof o.code==\"string\"&&$.has(o.code)&&typeof o.message==\"string\"&&o.message.length>0&&o.message.length<=512}function w(e,t,o){return{v:1,id:C(e)?e:\"invalid\",ok:!1,error:{code:t,message:o.slice(0,512)||\"Request failed\"}}}function P(e){let t=e?.getAttribute(\"data-config\");if(!t)throw new Error(\"Thread Page runtime: configuration is missing\");return JSON.parse(t)}function D(e){let t=e.querySelector(\"p\"),o=e.querySelector('button[value=\"cancel\"]'),l=e.querySelector('button[value=\"confirm\"]'),c=null,d;function p(g){let y=c;if(c=null,g&&d)try{d()}catch{}d=void 0,e.open&&e.close(),y?.(g)}return o?.addEventListener(\"click\",g=>{g.preventDefault(),p(!1)}),l?.addEventListener(\"click\",g=>{g.preventDefault(),p(!0)}),e.addEventListener(\"cancel\",g=>{g.preventDefault(),p(!1)}),e.addEventListener(\"close\",()=>{c&&p(!1)}),{confirm(g,y){return new Promise(m=>{if(c&&p(!1),t&&(t.textContent=g),c=m,d=y,typeof e.showModal==\"function\")try{e.showModal()}catch{p(!1)}else p(!1)})}}}function O(e){let t=null;return{inPlace(o){e.location.assign(o)},reserveWindow(){try{if(t=e.open(\"\",\"_blank\"),t)try{t.opener=null}catch{}}catch{t=null}},external(o){let l=t;if(t=null,l&&!l.closed)try{l.location.href=o;return}catch{try{l.close()}catch{}}e.location.assign(o)},release(){let o=t;t=null;try{o?.close()}catch{}}}}function A(e,t,o,l=e.fetch.bind(e)){let c=`\"${t.pageRevision}\"`,d=!1,p=!1,g=!1,y=t.stale,m=null,v=null;function k(i){m!==null&&clearTimeout(m),m=null,!(p||e.document.visibilityState!==\"visible\")&&(m=setTimeout(()=>{m=null,s()},i))}function r(){m!==null&&clearTimeout(m),m=null,v?.abort(),v=null}function n(){d?(o.setStatus(\"Page changed \\u2014 reload when ready\",!0),o.showReload(!0)):o.reloadView()}async function s(){if(!(p||g||e.document.visibilityState!==\"visible\")){if(Date.now()>=t.expiresAt-3e4){p=!0,d?(o.setStatus(\"Session expiring \\u2014 reload when ready\",!0),o.showReload(!0)):o.reloadView();return}g=!0,v=new AbortController;try{let i=await l(t.documentUrl,{method:\"GET\",credentials:\"same-origin\",cache:\"no-store\",headers:{\"if-none-match\":c},signal:v.signal});if(i.status===401||i.status===403){p=!0,o.setStatus(\"Session expired \\u2014 reload this page\",!0),o.showReload(!0);return}if(!i.ok&&i.status!==304){o.setStatus(\"Page unavailable\",!0);return}let f=i.headers.get(\"x-thread-page-stale\")===\"true\";o.setWorking(i.headers.get(\"x-thread-page-activity\")===\"working\"),f!==y&&(y=f,o.onStaleChanged(f)),o.setStatus(f?\"Offline copy \\u2014 read-only\":\"\",f);let u=i.headers.get(\"etag\");u&&u!==c&&(c=u,n())}catch(i){i instanceof DOMException&&i.name===\"AbortError\"||o.setStatus(\"Cannot check for updates\",!0)}finally{v=null,g=!1,k(t.pollMs)}}}return e.document.addEventListener(\"visibilitychange\",()=>{e.document.visibilityState===\"visible\"?k(0):r()}),{start:()=>k(t.pollMs),setDirty:i=>{d=i},pollNow:()=>s(),isStopped:()=>p}}function I(e){let{config:t,confirmer:o,navigator:l}=e,c=e.fetchImpl??fetch;function d(r,n){r.postMessage(n)}async function p(r){return(await c(t.bridgeUrl,{method:\"POST\",credentials:\"same-origin\",cache:\"no-store\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify(r)})).json().catch(()=>null)}function g(r){return!b(r)||r.kind!==\"page\"&&r.kind!==\"host\"&&r.kind!==\"external\"||typeof r.url!=\"string\"||r.kind===\"external\"&&!/^https?:\\/\\//i.test(r.url)||r.kind!==\"external\"&&!r.url.startsWith(\"/\")?null:{kind:r.kind,url:r.url}}function y(r,n,s){if(!b(s)||!B(s.response,n.id)){d(r,w(n.id,\"invalid_response\",\"The Thread Page bridge returned an invalid response\"));return}let i=s.navigate===void 0?null:g(s.navigate);if(s.response.ok&&i){d(r,s.response),i.kind===\"external\"?l.external(i.url):l.inPlace(i.url);return}l.release(),d(r,s.response)}async function m(r,n){try{let s=await p({actionToken:t.actionToken,request:n});if(b(s)&&b(s.confirm)){let i=s.confirm;if(typeof i.challenge!=\"string\"||typeof i.summary!=\"string\"||i.requestId!==n.id){d(r,w(n.id,\"invalid_response\",\"The Thread Page bridge returned an invalid confirmation\"));return}let f=n.method===\"navigation.openExternal\";if(!await o.confirm(i.summary,f?()=>l.reserveWindow():void 0)){d(r,w(n.id,\"cancelled\",\"You declined this action\"));return}let a=await p({actionToken:t.actionToken,request:n,confirmation:i.challenge});y(r,n,a);return}y(r,n,s)}catch(s){l.release(),d(r,w(n.id,\"unavailable\",s instanceof Error?s.message:\"The Thread Page bridge is unavailable\"))}}async function v(r){let n=r.file;if(!n||typeof n.size!=\"number\")throw new Error(\"Attachment is not a file\");let s=n.name||\"file\";if(n.size<=0)throw new Error(`Attachment ${s} is empty`);if(n.size>t.maxUploadBytes)throw new Error(`Attachment ${s} is larger than ${Math.round(t.maxUploadBytes/(1024*1024))} MiB`);let i=await V(n),f=await c(t.uploadUrl,{method:\"POST\",credentials:\"same-origin\",cache:\"no-store\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify({actionToken:t.actionToken,pageRevision:t.pageRevision,name:s,content:i})}),u=await f.json().catch(()=>null);if(!f.ok||!u||u.ok!==!0||typeof u.name!=\"string\"||typeof u.path!=\"string\"||typeof u.sizeBytes!=\"number\")throw new Error(u&&typeof u.message==\"string\"&&u.message||`Upload failed (${f.status})`);return{field:String(r.field||\"file\").slice(0,128),name:u.name,path:u.path,sizeBytes:u.sizeBytes}}async function k(r,n){let s=typeof n.submissionId==\"string\"?n.submissionId:\"\";try{let i=(Array.isArray(n.files)?n.files:[]).slice(0,t.maxUploads),f=[];for(let S=0;S<i.length;S+=1)d(r,{kind:\"thread-page:submit-progress\",submissionId:s,message:`Uploading ${S+1} of ${i.length}\\u2026`}),f.push(await v(i[S]));f.length>0&&d(r,{kind:\"thread-page:submit-progress\",submissionId:s,message:\"Sending\\u2026\"});let u=await c(t.submitUrl,{method:\"POST\",credentials:\"same-origin\",cache:\"no-store\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify({actionToken:t.actionToken,submissionId:s,pageRevision:t.pageRevision,title:n.title,answers:n.answers,files:f})}),a=await u.json().catch(()=>({ok:!1,message:\"Invalid server response\"})),h=u.ok&&a.ok===!0;d(r,{kind:\"thread-page:submit-result\",submissionId:s,ok:h,message:typeof a.delivery==\"string\"?`Sent (${a.delivery})`:\"Sent\",error:typeof a.message==\"string\"?a.message:`Request failed (${u.status})`})}catch(i){d(r,{kind:\"thread-page:submit-result\",submissionId:s,ok:!1,error:i instanceof Error?i.message:\"Request failed\"})}}return{handle(r,n){if(b(n)){if(n.kind===\"thread-page:dirty\"){e.onDirty(!0);return}if(n.kind===\"thread-page:clean\"){e.onDirty(!1);return}if(n.kind===\"thread-page:submit\"){k(r,n);return}if(!T(n,t.pageRevision)){d(r,w(n.id,\"invalid_request\",\"Invalid Thread Page bridge request\"));return}m(r,n)}}}}async function V(e){let t=new Uint8Array(await e.arrayBuffer()),o=\"\",l=32768;for(let c=0;c<t.length;c+=l)o+=String.fromCharCode.apply(null,Array.from(t.subarray(c,c+l)));return btoa(o)}function L(e,t,o,l){let{frame:c,status:d,work:p,reload:g,dialog:y}=o,m=null,v=!0,k=t.stale,n=A(e,t,{setStatus(a,h){d.textContent=a,d.dataset.tone=h?\"warn\":\"\"},setWorking(a){p.dataset.visible=a&&t.workingLabel?\"true\":\"false\"},showReload(a){g.dataset.visible=a?\"true\":\"false\"},onStaleChanged(a){k=a,m?.postMessage({kind:\"thread-page:source-state\",stale:a})},reloadView(){e.location.reload()}},l),s=O(e),i=D(y),f=I({config:t,confirmer:i,navigator:s,onDirty:a=>n.setDirty(a),...l?{fetchImpl:l}:{}});function u(){let a=new e.MessageChannel,h=a.port1;m=h,h.onmessage=S=>f.handle(h,S.data),h.start?.(),c.contentWindow?.postMessage({kind:\"thread-page:connect\",version:E},\"*\",[a.port2]),h.postMessage({kind:\"thread-page:source-state\",stale:k})}return e.addEventListener(\"message\",a=>{if(!v||a.origin!==\"null\"||a.source!==c.contentWindow)return;let h=a.data;!b(h)||h.kind!==\"thread-page:ready\"||h.version!==E||(v=!1,u())}),g.addEventListener(\"click\",()=>e.location.reload()),c.src=t.documentUrl,n.start(),{poller:n}}var W=P(document.currentScript),q=document.querySelector(\"iframe\"),N=document.querySelector(\"[data-shell-status]\"),U=document.querySelector(\"[data-shell-working]\"),H=document.querySelector(\"[data-shell-reload]\"),z=document.querySelector(\"dialog\");if(!q||!N||!U||!H||!z)throw new Error(\"Thread Page shell: chrome is incomplete\");L(window,W,{frame:q,status:N,work:U,reload:H,dialog:z});})();";
@@ -0,0 +1,65 @@
1
+ import type { JsonValue } from "../domain/json/strict-json.ts";
2
+ import type {
3
+ ActivityItem,
4
+ Delivery,
5
+ FileContent,
6
+ HostLogger,
7
+ ProjectRecord,
8
+ ProviderChoice,
9
+ SendMode,
10
+ SessionListQuery,
11
+ SessionRecord,
12
+ StartSessionArgs,
13
+ StorageLocation,
14
+ WriteOutcome,
15
+ } from "./types.ts";
16
+
17
+ /**
18
+ * What a host must provide for Thread Pages to run on it. spec 08
19
+ *
20
+ * bb implements this in src/bb. Everything above the domain talks to this
21
+ * interface and nothing else, so a second host is one new package.
22
+ */
23
+ export interface SessionHost {
24
+ readonly sessions: {
25
+ /** null when the session does not exist. */
26
+ get(id: string): Promise<SessionRecord | null>;
27
+ list(query: SessionListQuery): Promise<SessionRecord[]>;
28
+ send(id: string, text: string, mode: SendMode): Promise<{ delivery: Delivery }>;
29
+ start(args: StartSessionArgs): Promise<{ id: string }>;
30
+ stop(id: string): Promise<void>;
31
+ archive(id: string): Promise<void>;
32
+ /** Sets the reader's read mark; returns the mark afterwards. */
33
+ markRead(id: string, read: boolean): Promise<{ unread: boolean }>;
34
+ activity(id: string, limit: number): Promise<ActivityItem[]>;
35
+ storage(id: string): Promise<StorageLocation>;
36
+ };
37
+ readonly projects: {
38
+ list(): Promise<ProjectRecord[]>;
39
+ /** Opens the host's native folder picker; null when cancelled. */
40
+ browse(hostId: string): Promise<{ path: string; hostName: string } | null>;
41
+ create(args: { name: string; hostId: string; path: string }): Promise<ProjectRecord>;
42
+ };
43
+ readonly providers: {
44
+ /** Throws when the host cannot enumerate; never returns an empty list to mean that. spec R5.15 */
45
+ list(): Promise<ProviderChoice[]>;
46
+ };
47
+ readonly files: {
48
+ /** null when the file does not exist. Throws `unavailable` when the host cannot be reached. */
49
+ read(location: StorageLocation, relativePath: string): Promise<FileContent | null>;
50
+ /** `exists` when `onlyIfAbsent` is set and the file is already there. */
51
+ write(location: StorageLocation, relativePath: string, bytes: Uint8Array, options: { onlyIfAbsent: boolean }): Promise<WriteOutcome>;
52
+ /** Which of the given absolute paths exist on a host, in one batched call where possible. */
53
+ exist(hostId: string, absolutePaths: readonly string[]): Promise<Record<string, boolean>>;
54
+ };
55
+ readonly kv: {
56
+ get(key: string): Promise<JsonValue | undefined>;
57
+ set(key: string, value: JsonValue): Promise<void>;
58
+ delete(key: string): Promise<void>;
59
+ };
60
+ readonly origin: {
61
+ /** The externally reachable origin (scheme + authority), or null when local-only. */
62
+ public(): Promise<string | null>;
63
+ };
64
+ readonly log: HostLogger;
65
+ }
@@ -0,0 +1,89 @@
1
+ import type { SessionState } from "../domain/capabilities/specs.ts";
2
+
3
+ /**
4
+ * The host's things, projected into the product's vocabulary. Nothing here is
5
+ * shaped like a particular host's API. spec 08
6
+ */
7
+ export interface SessionRecord {
8
+ readonly id: string;
9
+ readonly title: string;
10
+ readonly projectId: string | null;
11
+ readonly state: SessionState;
12
+ readonly visibility: "visible" | "hidden";
13
+ readonly parentId: string | null;
14
+ readonly forkOfId: string | null;
15
+ readonly archived: boolean;
16
+ readonly deleted: boolean;
17
+ readonly updatedAtMs: number;
18
+ /** When the session last asked for the reader's attention (a turn ended, a question). */
19
+ readonly attentionAtMs: number;
20
+ /** Whether the reader has not looked since the last attention. */
21
+ readonly unread: boolean;
22
+ /** The host's environment identity for `sessions.start` reuse; never shown to a page. */
23
+ readonly environmentId: string | null;
24
+ }
25
+
26
+ export interface SessionListQuery {
27
+ readonly projectId?: string;
28
+ readonly archived: boolean;
29
+ /** Only sessions without a parent, the way the host's own sidebar lists them. */
30
+ readonly rootsOnly: boolean;
31
+ readonly offset: number;
32
+ readonly limit: number;
33
+ }
34
+
35
+ export type SendMode = "queue" | "steer";
36
+ export type Delivery = "started" | "queued" | "steered";
37
+
38
+ export interface StartSessionArgs {
39
+ readonly projectId: string;
40
+ readonly prompt: string;
41
+ readonly title?: string;
42
+ readonly providerId?: string;
43
+ readonly model?: string;
44
+ readonly reasoningLevel?: string;
45
+ readonly environment: { readonly kind: "project-default" } | { readonly kind: "reuse"; readonly environmentId: string };
46
+ }
47
+
48
+ export interface ActivityItem {
49
+ readonly kind: string;
50
+ readonly done: boolean;
51
+ readonly atMs: number;
52
+ readonly label: string;
53
+ readonly text: string;
54
+ }
55
+
56
+ export interface ProjectRecord {
57
+ readonly id: string;
58
+ readonly name: string;
59
+ readonly kind: "standard" | "personal";
60
+ /** Host id of the project's default source; needed to open a picker there. */
61
+ readonly hostId: string | null;
62
+ }
63
+
64
+ export interface ProviderChoice {
65
+ readonly id: string;
66
+ readonly displayName: string;
67
+ readonly available: boolean;
68
+ readonly models: readonly { readonly id: string; readonly displayName: string; readonly isDefault: boolean; readonly reasoningLevels: readonly string[] }[];
69
+ }
70
+
71
+ export interface StorageLocation {
72
+ readonly hostId: string;
73
+ readonly rootPath: string;
74
+ }
75
+
76
+ export interface FileContent {
77
+ readonly bytes: Uint8Array;
78
+ readonly sha256: string;
79
+ readonly modifiedAtMs: number | null;
80
+ }
81
+
82
+ export type WriteOutcome = "written" | "exists";
83
+
84
+ export interface HostLogger {
85
+ debug(message: string): void;
86
+ info(message: string): void;
87
+ warn(message: string): void;
88
+ error(message: string): void;
89
+ }