@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.
- package/README.md +77 -129
- package/dist/server.js +11426 -11802
- package/dist/server.meta.json +2 -2
- package/package.json +25 -18
- package/server.ts +3 -2175
- package/src/agent/cli.ts +193 -0
- package/src/agent/guide.ts +355 -0
- package/src/agent/instruction.ts +59 -0
- package/src/agent/seed/seed.ts +69 -0
- package/{theme.ts → src/agent/seed/theme-css.ts} +4 -11
- package/src/agent/starter-hub.ts +217 -0
- package/src/bb/activity.ts +59 -0
- package/src/bb/bb-host.ts +280 -0
- package/src/bb/public-origin.ts +45 -0
- package/src/config/settings.ts +82 -0
- package/src/domain/capabilities/contract.ts +48 -0
- package/src/domain/capabilities/index.ts +10 -0
- package/src/domain/capabilities/protocol.ts +112 -0
- package/src/domain/capabilities/registry.ts +48 -0
- package/src/domain/capabilities/schema.ts +198 -0
- package/src/domain/capabilities/specs.ts +479 -0
- package/src/domain/eligibility.ts +43 -0
- package/src/domain/errors.ts +116 -0
- package/src/domain/html/document.ts +109 -0
- package/src/domain/html/escape.ts +16 -0
- package/src/domain/ids.ts +37 -0
- package/src/domain/json/canonical.ts +19 -0
- package/src/domain/json/strict-json.ts +139 -0
- package/src/domain/limits.ts +88 -0
- package/src/domain/rate-limit.ts +64 -0
- package/src/domain/revision.ts +27 -0
- package/src/domain/submissions/idempotency.ts +59 -0
- package/src/domain/submissions/message.ts +42 -0
- package/src/domain/submissions/parse.ts +105 -0
- package/src/domain/tokens/action-token.ts +52 -0
- package/src/domain/tokens/confirmation.ts +99 -0
- package/src/domain/tokens/mac.ts +50 -0
- package/src/generated/kernel-runtime.ts +3 -0
- package/src/generated/shell-runtime.ts +3 -0
- package/src/host/contract.ts +65 -0
- package/src/host/types.ts +89 -0
- package/src/pages/layout.ts +65 -0
- package/src/pages/page-store.ts +136 -0
- package/src/pages/site.ts +36 -0
- package/src/plugin.ts +71 -0
- package/src/runtime/kernel/anchors.ts +45 -0
- package/src/runtime/kernel/api.ts +15 -0
- package/src/runtime/kernel/bridge-client.ts +148 -0
- package/src/runtime/kernel/dirty.ts +51 -0
- package/src/runtime/kernel/forms.ts +114 -0
- package/src/runtime/kernel/install.ts +156 -0
- package/src/runtime/kernel/labels.ts +98 -0
- package/src/runtime/kernel/main.ts +6 -0
- package/src/runtime/kernel/readonly.ts +75 -0
- package/src/runtime/shared/protocol.ts +125 -0
- package/src/runtime/shell/confirm.ts +70 -0
- package/src/runtime/shell/install.ts +79 -0
- package/src/runtime/shell/main.ts +12 -0
- package/src/runtime/shell/navigate.ts +64 -0
- package/src/runtime/shell/poll.ts +125 -0
- package/src/runtime/shell/relay.ts +185 -0
- package/src/serving/action-request.ts +32 -0
- package/src/serving/bridge/dispatcher.ts +112 -0
- package/src/serving/bridge/handler.ts +37 -0
- package/src/serving/bridge/handlers/index.ts +26 -0
- package/src/serving/bridge/handlers/navigation.ts +43 -0
- package/src/serving/bridge/handlers/reads.ts +186 -0
- package/src/serving/bridge/handlers/writes.ts +175 -0
- package/src/serving/bridge/selection-store.ts +58 -0
- package/src/serving/bridge-route.ts +23 -0
- package/src/serving/context.ts +34 -0
- package/src/serving/document-route.ts +37 -0
- package/src/serving/home-route.ts +23 -0
- package/src/serving/responses.ts +81 -0
- package/src/serving/routes.ts +26 -0
- package/src/serving/session-access.ts +22 -0
- package/src/serving/shell-html.ts +77 -0
- package/src/serving/shell-route.ts +51 -0
- package/src/serving/signing-key.ts +25 -0
- package/src/serving/submit-route.ts +47 -0
- package/src/serving/upload-route.ts +46 -0
- package/tsconfig.json +10 -6
- package/ARCHITECTURE.md +0 -230
- package/PLUGIN_OVERVIEW.md +0 -83
- package/authoring.ts +0 -368
- package/bridge.ts +0 -1721
- package/docs/MODEL.md +0 -211
- package/docs/ROADMAP.md +0 -96
- package/home.ts +0 -419
- package/page.ts +0 -782
|
@@ -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,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,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(s){m!==null&&clearTimeout(m),m=null,!(p||e.document.visibilityState!==\"visible\")&&(m=setTimeout(()=>{m=null,i()},s))}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 i(){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 s=await l(t.documentUrl,{method:\"GET\",credentials:\"same-origin\",cache:\"no-store\",headers:{\"if-none-match\":c},signal:v.signal});if(s.status===401||s.status===403){p=!0,o.setStatus(\"Session expired \\u2014 reload this page\",!0),o.showReload(!0);return}if(!s.ok&&s.status!==304){o.setStatus(\"Page unavailable\",!0);return}let f=s.headers.get(\"x-thread-page-stale\")===\"true\";o.setWorking(s.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=s.headers.get(\"etag\");u&&u!==c&&(c=u,n())}catch(s){s instanceof DOMException&&s.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:s=>{d=s},pollNow:()=>i(),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,i){if(!b(i)||!B(i.response,n.id)){d(r,w(n.id,\"invalid_response\",\"The Thread Page bridge returned an invalid response\"));return}let s=i.navigate===void 0?null:g(i.navigate);if(i.response.ok&&s){d(r,i.response),s.kind===\"external\"?l.external(s.url):l.inPlace(s.url);return}l.release(),d(r,i.response)}async function m(r,n){try{let i=await p({actionToken:t.actionToken,request:n});if(b(i)&&b(i.confirm)){let s=i.confirm;if(typeof s.challenge!=\"string\"||typeof s.summary!=\"string\"||s.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(s.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:s.challenge});y(r,n,a);return}y(r,n,i)}catch(i){l.release(),d(r,w(n.id,\"unavailable\",i instanceof Error?i.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 i=n.name||\"file\";if(n.size<=0)throw new Error(`Attachment ${i} is empty`);if(n.size>t.maxUploadBytes)throw new Error(`Attachment ${i} is larger than ${Math.round(t.maxUploadBytes/(1024*1024))} MiB`);let s=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:i,content:s})}),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 i=typeof n.submissionId==\"string\"?n.submissionId:\"\";try{let s=(Array.isArray(n.files)?n.files:[]).slice(0,t.maxUploads),f=[];for(let S=0;S<s.length;S+=1)d(r,{kind:\"thread-page:submit-progress\",submissionId:i,message:`Uploading ${S+1} of ${s.length}\\u2026`}),f.push(await v(s[S]));f.length>0&&d(r,{kind:\"thread-page:submit-progress\",submissionId:i,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:i,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:i,ok:h,message:typeof a.delivery==\"string\"?`Sent (${a.delivery})`:\"Sent\",error:typeof a.message==\"string\"?a.message:`Request failed (${u.status})`})}catch(s){d(r,{kind:\"thread-page:submit-result\",submissionId:i,ok:!1,error:s instanceof Error?s.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),i=O(e),s=D(y),f=I({config:t,confirmer:s,navigator:i,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
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a page lives. spec 01 §Storage, RW-3
|
|
3
|
+
*
|
|
4
|
+
* The page root is the session's storage directory itself. The entry document
|
|
5
|
+
* is `index.html`; reader uploads land in `uploads/` under host-generated
|
|
6
|
+
* names. Everything else in the root is the page's own site.
|
|
7
|
+
*/
|
|
8
|
+
export const ENTRY_FILE = "index.html";
|
|
9
|
+
export const UPLOAD_DIR = "uploads";
|
|
10
|
+
/** The prototype's entry file, recognised only to tell an agent about it. */
|
|
11
|
+
export const LEGACY_ENTRY_FILE = "thread-page.html";
|
|
12
|
+
|
|
13
|
+
const UPLOAD_NAME = /^[0-9]{8}-[0-9]{6}-[a-f0-9]{6}-[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
14
|
+
|
|
15
|
+
export function joinPath(root: string, ...segments: string[]): string {
|
|
16
|
+
const base = root.replace(/[\\/]+$/, "");
|
|
17
|
+
return [base, ...segments].join("/");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function entryPath(root: string): string {
|
|
21
|
+
return joinPath(root, ENTRY_FILE);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function legacyEntryPath(root: string): string {
|
|
25
|
+
return joinPath(root, LEGACY_ENTRY_FILE);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function uploadPath(root: string, name: string): string {
|
|
29
|
+
if (!isSafeUploadName(name)) throw new Error("Unsafe upload name");
|
|
30
|
+
return joinPath(root, UPLOAD_DIR, name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The reader's filename reduced to a safe suffix; the host chooses the rest. spec R4.21 */
|
|
34
|
+
export function sanitizeUploadSuffix(raw: string): string {
|
|
35
|
+
let decoded = raw;
|
|
36
|
+
try {
|
|
37
|
+
decoded = decodeURIComponent(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
decoded = raw;
|
|
40
|
+
}
|
|
41
|
+
const base = decoded.split(/[\\/]/).pop() ?? "";
|
|
42
|
+
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^[._-]+/, "");
|
|
43
|
+
return cleaned.slice(0, 80) || "upload";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `YYYYMMDD-HHMMSS-<6 hex>-<suffix>`; never taken from the reader. spec R4.21 */
|
|
47
|
+
export function uploadFileName(originalName: string, now: number, randomHex: string): string {
|
|
48
|
+
const stamp = new Date(now).toISOString().replace(/[-:]/g, "").replace(/\..+$/, "").replace("T", "-");
|
|
49
|
+
const name = `${stamp}-${randomHex.slice(0, 6)}-${sanitizeUploadSuffix(originalName)}`;
|
|
50
|
+
if (!isSafeUploadName(name)) throw new Error("Generated upload name is invalid");
|
|
51
|
+
return name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isSafeUploadName(name: string): boolean {
|
|
55
|
+
return UPLOAD_NAME.test(name) && !name.includes("..");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A relative path inside the page root: no absolute paths, no `.`/`..`/empty
|
|
60
|
+
* segments, no backslashes, no NUL. spec R1.4
|
|
61
|
+
*/
|
|
62
|
+
export function isSafeRelativePath(path: string): boolean {
|
|
63
|
+
if (path.length === 0 || path.length > 1024 || path.includes("\0") || path.includes("\\") || path.startsWith("/")) return false;
|
|
64
|
+
return path.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
|
|
65
|
+
}
|