@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.
- package/README.md +77 -129
- package/dist/server.js +11779 -11801
- package/dist/server.meta.json +2 -2
- package/docs/B1-OWN-FILES.md +133 -0
- package/docs/FOR-PAGE-AUTHORS-1.1.md +167 -0
- package/docs/UPGRADING.md +53 -0
- package/package.json +26 -18
- package/server.ts +3 -2175
- package/src/agent/cli.ts +193 -0
- package/src/agent/guide.ts +450 -0
- package/src/agent/instruction.ts +59 -0
- package/src/agent/seed/seed.ts +73 -0
- package/{theme.ts → src/agent/seed/theme-css.ts} +9 -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 +113 -0
- package/src/domain/capabilities/registry.ts +48 -0
- package/src/domain/capabilities/renamed.ts +34 -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 +98 -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/inline.ts +277 -0
- package/src/pages/layout.ts +65 -0
- package/src/pages/page-store.ts +170 -0
- package/src/pages/site.ts +36 -0
- package/src/plugin.ts +81 -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
package/src/agent/cli.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { BbPluginApi, PluginCliContext, PluginCliResult } from "@get-bb/plugin-sdk";
|
|
2
|
+
import { describeIneligible, ineligibleReason } from "../domain/eligibility.ts";
|
|
3
|
+
import { PageError, errorText } from "../domain/errors.ts";
|
|
4
|
+
import { isSessionId } from "../domain/ids.ts";
|
|
5
|
+
import { LIMITS } from "../domain/limits.ts";
|
|
6
|
+
import type { SessionRecord } from "../host/types.ts";
|
|
7
|
+
import { ENTRY_FILE, LEGACY_ENTRY_FILE, UPLOAD_DIR, entryPath, joinPath, legacyEntryPath } from "../pages/layout.ts";
|
|
8
|
+
import { homeUrl, pageUrl, type ServingContext } from "../serving/context.ts";
|
|
9
|
+
import { renderSeed } from "./seed/seed.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `bb thread-page init | home [--clear] | guide | status`. spec 06 §The command, RW-11
|
|
13
|
+
*/
|
|
14
|
+
export interface CliDeps {
|
|
15
|
+
serving: ServingContext;
|
|
16
|
+
guide: string;
|
|
17
|
+
/** The exact instruction a new eligible session receives now, or null when none would. */
|
|
18
|
+
effectiveInstruction(): string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registerCli(bb: BbPluginApi, deps: CliDeps): void {
|
|
22
|
+
bb.cli.register({
|
|
23
|
+
name: "thread-page",
|
|
24
|
+
summary: "The page this session writes for its reader: create it, print the authoring guide, make it home",
|
|
25
|
+
commands: [
|
|
26
|
+
{ name: "init", summary: "Create this session's page if absent; print its path and link", usage: "bb thread-page init" },
|
|
27
|
+
{ name: "guide", summary: "Print the authoring guide (forms, files, capabilities, limits)", usage: "bb thread-page guide" },
|
|
28
|
+
{ name: "home", summary: "Make this session's page the home page every page links back to", usage: "bb thread-page home [--clear]" },
|
|
29
|
+
{ name: "status", summary: "Show settings, the instruction new sessions get, and this session's page", usage: "bb thread-page status" },
|
|
30
|
+
],
|
|
31
|
+
async run(argv, context) {
|
|
32
|
+
const [command, ...rest] = argv;
|
|
33
|
+
try {
|
|
34
|
+
switch (command) {
|
|
35
|
+
case "init":
|
|
36
|
+
return rest.length === 0 ? await init(deps, context) : usage();
|
|
37
|
+
case "guide":
|
|
38
|
+
return rest.length === 0 ? { exitCode: 0, stdout: `${deps.guide}\n` } : usage();
|
|
39
|
+
case "home":
|
|
40
|
+
if (rest.length === 0) return await home(deps, context);
|
|
41
|
+
if (rest.length === 1 && rest[0] === "--clear") return await clearHome(deps);
|
|
42
|
+
return usage("bb thread-page home [--clear]");
|
|
43
|
+
case "status":
|
|
44
|
+
return rest.length === 0 ? await status(deps, context) : usage();
|
|
45
|
+
default:
|
|
46
|
+
return usage();
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
deps.serving.host.log.warn(`cli ${command ?? ""}: ${errorText(PageError.is(error) ? (error.cause ?? error) : error)}`);
|
|
50
|
+
return { exitCode: 1, stderr: `Could not run thread-page ${command ?? ""}: ${errorText(error)}\n` };
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function usage(text = "bb thread-page <init|guide|home [--clear]|status>"): PluginCliResult {
|
|
57
|
+
return { exitCode: 2, stderr: `Usage: ${text}\n` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function currentSession(deps: CliDeps, context: PluginCliContext): Promise<{ id: string; session: SessionRecord } | { skip: string }> {
|
|
61
|
+
if (!context.threadId) return { skip: "no current session" };
|
|
62
|
+
const session = await deps.serving.host.sessions.get(context.threadId);
|
|
63
|
+
if (!session) return { skip: "the current session does not exist" };
|
|
64
|
+
const reason = ineligibleReason(session);
|
|
65
|
+
if (reason) return { skip: describeIneligible(reason) };
|
|
66
|
+
return { id: session.id, session };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function skipLine(reason: string): PluginCliResult {
|
|
70
|
+
return { exitCode: 0, stdout: `state: SKIP — ${reason}; this session has no page. Answer normally in chat and do not create one.\n` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function link(deps: CliDeps, path: string): Promise<string> {
|
|
74
|
+
const origin = await deps.serving.host.origin.public();
|
|
75
|
+
return origin ? `${origin}${path}` : path;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function ensurePage(deps: CliDeps, id: string, title: string): Promise<{ absolutePath: string; state: "created" | "existing"; legacy: boolean; problem: string | null }> {
|
|
79
|
+
const { serving } = deps;
|
|
80
|
+
const location = await serving.host.sessions.storage(id);
|
|
81
|
+
const seed = renderSeed(serving.settings.current().pageSeedHtml, title);
|
|
82
|
+
const outcome = await serving.host.files.write(location, ENTRY_FILE, Buffer.from(seed, "utf8"), { onlyIfAbsent: true });
|
|
83
|
+
const state = outcome === "written" ? "created" : "existing";
|
|
84
|
+
let problem: string | null = null;
|
|
85
|
+
if (state === "created") {
|
|
86
|
+
await serving.pages.remember(id, seed);
|
|
87
|
+
} else {
|
|
88
|
+
try {
|
|
89
|
+
await serving.pages.load(id);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
problem = PageError.is(error) ? error.message : errorText(error);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const legacy = await serving.host.files
|
|
95
|
+
.exist(location.hostId, [legacyEntryPath(location.rootPath)])
|
|
96
|
+
.then((existence) => existence[legacyEntryPath(location.rootPath)] === true)
|
|
97
|
+
.catch(() => false);
|
|
98
|
+
return { absolutePath: entryPath(location.rootPath), state, legacy, problem };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Tells the agent whether the reader has a home page, and how one comes to exist. */
|
|
102
|
+
async function homeLine(deps: CliDeps, current: string): Promise<string> {
|
|
103
|
+
const home = deps.serving.settings.current().homeSessionId;
|
|
104
|
+
if (isSessionId(home)) {
|
|
105
|
+
return home === current
|
|
106
|
+
? "home: this page is the home page; every other page links back to it."
|
|
107
|
+
: `home: ${await link(deps, homeUrl(deps.serving.routeBase))} (every page links back to it; you never write that link)`;
|
|
108
|
+
}
|
|
109
|
+
return "home: none set. If the reader wants one place to see and steer their sessions, run `bb thread-page home` in a session dedicated to it and build the hub from `bb thread-page guide` §The home page.";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function init(deps: CliDeps, context: PluginCliContext): Promise<PluginCliResult> {
|
|
113
|
+
const current = await currentSession(deps, context);
|
|
114
|
+
if ("skip" in current) return skipLine(current.skip);
|
|
115
|
+
const { absolutePath, state, legacy, problem } = await ensurePage(deps, current.id, current.session.title);
|
|
116
|
+
const url = await link(deps, pageUrl(deps.serving.routeBase, current.id));
|
|
117
|
+
const lines = [
|
|
118
|
+
`page: ${absolutePath}`,
|
|
119
|
+
`link: [Open the Thread Page](${url})`,
|
|
120
|
+
state === "created"
|
|
121
|
+
? "state: NEW — seeded; make this page fit the task, keep a way to answer, then reply in chat with the link and one line."
|
|
122
|
+
: "state: EXISTING — read it before editing; update it this turn, keep a way to answer, then reply in chat with the link and one line.",
|
|
123
|
+
`site: files beside ${ENTRY_FILE} are served relatively (nested paths included); ${UPLOAD_DIR}/ holds what the reader attaches.`,
|
|
124
|
+
"guide: bb thread-page guide (files, charts, live session state, starting sessions, links, limits)",
|
|
125
|
+
await homeLine(deps, current.id),
|
|
126
|
+
];
|
|
127
|
+
if (problem) lines.push(`warning: the existing page cannot be served — ${problem}`);
|
|
128
|
+
if (legacy) lines.push(`note: a ${LEGACY_ENTRY_FILE} from the previous plugin version is beside it; it is not served. Move what you want from it into ${ENTRY_FILE}.`);
|
|
129
|
+
return { exitCode: 0, stdout: `${lines.join("\n")}\n` };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function home(deps: CliDeps, context: PluginCliContext): Promise<PluginCliResult> {
|
|
133
|
+
const current = await currentSession(deps, context);
|
|
134
|
+
if ("skip" in current) return { exitCode: 2, stderr: `Cannot make this session home: ${current.skip}. Run it from a visible root session.\n` };
|
|
135
|
+
const { serving } = deps;
|
|
136
|
+
const previous = serving.settings.current().homeSessionId;
|
|
137
|
+
const lines: string[] = [];
|
|
138
|
+
if (isSessionId(previous) && previous !== current.id) {
|
|
139
|
+
const other = await serving.host.sessions.get(previous).catch(() => null);
|
|
140
|
+
lines.push(`warning: home was ${other ? `“${other.title}” (${previous})` : previous}; it now points here instead.`);
|
|
141
|
+
}
|
|
142
|
+
const { state } = await ensurePage(deps, current.id, current.session.title);
|
|
143
|
+
await serving.settings.set({ homeSessionId: current.id });
|
|
144
|
+
const url = await link(deps, homeUrl(serving.routeBase));
|
|
145
|
+
lines.push(
|
|
146
|
+
`home: ${current.id}`,
|
|
147
|
+
`link: [Sessions](${url})`,
|
|
148
|
+
"Every other page now shows a “← Sessions” link back to this one.",
|
|
149
|
+
state === "created"
|
|
150
|
+
? "state: NEW — a plain seed was created for this session; build the hub yourself (sessions.snapshot, projects.list, pages.open, sessions.start). See bb thread-page guide §The home page."
|
|
151
|
+
: "state: EXISTING — this session's page was left untouched.",
|
|
152
|
+
);
|
|
153
|
+
return { exitCode: 0, stdout: `${lines.join("\n")}\n` };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function clearHome(deps: CliDeps): Promise<PluginCliResult> {
|
|
157
|
+
await deps.serving.settings.set({ homeSessionId: null });
|
|
158
|
+
return { exitCode: 0, stdout: "home: cleared — pages no longer show a Sessions link.\n" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function status(deps: CliDeps, context: PluginCliContext): Promise<PluginCliResult> {
|
|
162
|
+
const { serving } = deps;
|
|
163
|
+
const settings = serving.settings.current();
|
|
164
|
+
const instruction = deps.effectiveInstruction();
|
|
165
|
+
const lines = [
|
|
166
|
+
"# Thread Pages status",
|
|
167
|
+
"",
|
|
168
|
+
`agentInstructions: ${settings.agentInstructions ? "on" : "off"}`,
|
|
169
|
+
`workingLabel: ${settings.workingLabel ? JSON.stringify(settings.workingLabel) : "(blank — indicator hidden)"}`,
|
|
170
|
+
`homeSessionId: ${settings.homeSessionId || "(none — pages show no Sessions link)"}`,
|
|
171
|
+
`site strategy: ${serving.site.name}`,
|
|
172
|
+
`limits: entry ${LIMITS.entryDocumentBytes / (1024 * 1024)} MiB, upload ${LIMITS.uploadFileBytes / (1024 * 1024)} MiB × ${LIMITS.uploadsPerForm}, rate ${LIMITS.ratePerMinute}/min`,
|
|
173
|
+
"",
|
|
174
|
+
"## Instruction a new eligible session receives now",
|
|
175
|
+
"",
|
|
176
|
+
instruction ?? "(none — agentInstructions is off)",
|
|
177
|
+
];
|
|
178
|
+
const current = await currentSession(deps, context);
|
|
179
|
+
lines.push("", "## This session");
|
|
180
|
+
if ("skip" in current) {
|
|
181
|
+
lines.push(`no page: ${current.skip}`);
|
|
182
|
+
} else {
|
|
183
|
+
const location = await serving.host.sessions.storage(current.id);
|
|
184
|
+
lines.push(`page: ${joinPath(location.rootPath, ENTRY_FILE)}`, `link: ${await link(deps, pageUrl(serving.routeBase, current.id))}`);
|
|
185
|
+
try {
|
|
186
|
+
const page = await serving.pages.load(current.id);
|
|
187
|
+
lines.push(`revision: ${page.revision}${page.stale ? " (offline copy)" : ""}`);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
lines.push(`revision: ${PageError.is(error) ? error.message : errorText(error)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { exitCode: 0, stdout: `${lines.join("\n")}\n` };
|
|
193
|
+
}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import type { CapabilityRegistry } from "../domain/capabilities/registry.ts";
|
|
2
|
+
import { LIMITS, kibibytes, mebibytes } from "../domain/limits.ts";
|
|
3
|
+
import { ENTRY_FILE, UPLOAD_DIR } from "../pages/layout.ts";
|
|
4
|
+
import type { SiteStrategy } from "../pages/site.ts";
|
|
5
|
+
import { starterHubForGuide } from "./starter-hub.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The authoring guide, printed by `bb thread-page guide`. Assembled from
|
|
9
|
+
* prose, the limits table and the capability registry at load, so every
|
|
10
|
+
* number and every capability in it is the implementation's own.
|
|
11
|
+
* spec R6.24–R6.26
|
|
12
|
+
*/
|
|
13
|
+
export function buildGuide(registry: CapabilityRegistry, site: SiteStrategy): string {
|
|
14
|
+
return [
|
|
15
|
+
intro(),
|
|
16
|
+
plainHtml(),
|
|
17
|
+
forms(),
|
|
18
|
+
uploads(),
|
|
19
|
+
ownFiles(site),
|
|
20
|
+
keepingCurrent(),
|
|
21
|
+
runtimeApi(),
|
|
22
|
+
capabilities(registry),
|
|
23
|
+
startingSessions(),
|
|
24
|
+
network(),
|
|
25
|
+
unavailable(),
|
|
26
|
+
composition(),
|
|
27
|
+
home(),
|
|
28
|
+
accessibility(),
|
|
29
|
+
upgrading(),
|
|
30
|
+
limits(),
|
|
31
|
+
limitations(site),
|
|
32
|
+
].join("\n\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const intro = () => `# Thread Pages — authoring guide
|
|
36
|
+
|
|
37
|
+
A page is a complete HTML document you edit directly; saving publishes it. It
|
|
38
|
+
runs in a sandboxed frame on an opaque origin with no host credentials, and
|
|
39
|
+
talks to the host only through captured forms and \`window.threadPage\`. Use
|
|
40
|
+
the smallest shape that makes the task easier: plain semantic HTML first, a
|
|
41
|
+
mini-app only when the shape of the thing is not prose.
|
|
42
|
+
|
|
43
|
+
Your page root is your session's storage directory (\`$BB_THREAD_STORAGE\`):
|
|
44
|
+
|
|
45
|
+
${ENTRY_FILE} the entry document — the page
|
|
46
|
+
<any files> served beside it, nested directories included
|
|
47
|
+
${UPLOAD_DIR}/ files the reader attached, named by the host`;
|
|
48
|
+
|
|
49
|
+
const plainHtml = () => `## What plain HTML already gives you
|
|
50
|
+
|
|
51
|
+
The seed carries its own stylesheet, so semantic HTML is already styled.
|
|
52
|
+
|
|
53
|
+
h2, p, ul, table the page's type scale and rhythm
|
|
54
|
+
form a panel wired to your session, with a status line
|
|
55
|
+
fieldset + legend a named group; the legend becomes the question
|
|
56
|
+
label wrapping one the label becomes that answer's name
|
|
57
|
+
small inside a label a hint (never part of the answer's name)
|
|
58
|
+
input type=range a slider with a live value readout
|
|
59
|
+
input type=file uploaded on submit, path sent to you
|
|
60
|
+
details/summary detail on demand; add name="x" for an accordion
|
|
61
|
+
div.card a boxed aside
|
|
62
|
+
p.needs-you a flagged block, for what is blocked on the reader
|
|
63
|
+
span.label a small uppercase tag
|
|
64
|
+
|
|
65
|
+
Three class names; everything else keys off what the element is. The look is
|
|
66
|
+
three attributes on <html>: data-theme (paper | terminal | atrium | volume |
|
|
67
|
+
bloom), data-mode (system | light | dark), data-atmos (on | off). Extra CSS
|
|
68
|
+
goes in one more <style>, everything inside @scope (main), colour and shape
|
|
69
|
+
from var(--token) only — that is what keeps a bespoke chart right in every
|
|
70
|
+
world and in dark mode. You may replace the stylesheet entirely; the page is
|
|
71
|
+
yours.`;
|
|
72
|
+
|
|
73
|
+
const forms = () => `## Forms
|
|
74
|
+
|
|
75
|
+
Every <form> in the document is captured and delivered to your session as a
|
|
76
|
+
message — no JavaScript needed. Add data-thread-page-manual to a form your
|
|
77
|
+
own script owns; the host then leaves it entirely alone.
|
|
78
|
+
|
|
79
|
+
- Nothing is required and blank is a real answer: native validation is
|
|
80
|
+
suppressed, and a blank field arrives as "(left blank)".
|
|
81
|
+
- Answer names come from, in order: data-label on the control, the enclosing
|
|
82
|
+
fieldset's legend, aria-label, the wrapping label's text, a <label for>,
|
|
83
|
+
the field name. Hints, options and nested controls are excluded.
|
|
84
|
+
- Groups collapse: one checkbox is Yes/No; several checkboxes with one name
|
|
85
|
+
are a list of the checked values; radios are the one checked value or
|
|
86
|
+
blank; a multiple <select> is a list.
|
|
87
|
+
- The submit button's value leads the message as **Action**, so several
|
|
88
|
+
<button name="action" value="…"> give one-click answers.
|
|
89
|
+
- Each form has its own pending, dirty and status state. While a submission
|
|
90
|
+
is in flight its controls are disabled; afterwards the status line says
|
|
91
|
+
"Sent (queued)" or why it failed.
|
|
92
|
+
- Typing into a captured form marks the page dirty, so a new version of the
|
|
93
|
+
page does not reload under the reader. Custom state the host cannot see:
|
|
94
|
+
window.threadPage.setDirty(true|false).
|
|
95
|
+
|
|
96
|
+
The message you receive looks like:
|
|
97
|
+
|
|
98
|
+
The user answered the form on your Thread Page — <form's data-title or the h1>.
|
|
99
|
+
|
|
100
|
+
**Action**
|
|
101
|
+
Approve
|
|
102
|
+
|
|
103
|
+
**Which approach**
|
|
104
|
+
second
|
|
105
|
+
|
|
106
|
+
**Anything else**
|
|
107
|
+
(left blank)
|
|
108
|
+
|
|
109
|
+
### The dialog trap
|
|
110
|
+
|
|
111
|
+
A <form method="dialog"> inside a <dialog> is a form, so it is captured too.
|
|
112
|
+
If you write one as a purely local confirm and forget the opt-out attribute,
|
|
113
|
+
pressing its button **sends a real message you did not intend**, and because
|
|
114
|
+
its buttons carry control-flow values, you receive a plausible fabricated
|
|
115
|
+
decision:
|
|
116
|
+
|
|
117
|
+
The user answered the form on your Thread Page — <the page's heading>.
|
|
118
|
+
|
|
119
|
+
**Action**
|
|
120
|
+
confirm
|
|
121
|
+
|
|
122
|
+
Nothing marks it as accidental, and if a turn is running it arrives on the
|
|
123
|
+
next one, detached from what caused it. Put data-thread-page-manual on every
|
|
124
|
+
dialog form that is not meant to answer you.`;
|
|
125
|
+
|
|
126
|
+
const uploads = () => `## Files the reader sends you
|
|
127
|
+
|
|
128
|
+
A captured form may contain <input type="file"> (multiple is fine). On submit
|
|
129
|
+
the files are uploaded first, then the submission is delivered naming them:
|
|
130
|
+
|
|
131
|
+
**Attached files**
|
|
132
|
+
- \`$BB_THREAD_STORAGE/${UPLOAD_DIR}/20260908-161200-3f9a1c-report.pdf\` (…, 48213 bytes)
|
|
133
|
+
|
|
134
|
+
Read them from there with your normal tools. Limits: ${mebibytes(LIMITS.uploadFileBytes)} per file,
|
|
135
|
+
${LIMITS.uploadsPerForm} files per form (extras are dropped visibly). Names are generated by
|
|
136
|
+
the host; the reader's filename is only a suffix. An upload that fails shows
|
|
137
|
+
in the form's status line and no submission claims the missing file.`;
|
|
138
|
+
|
|
139
|
+
const ownFiles = (site: SiteStrategy) => `## Files you show the reader
|
|
140
|
+
|
|
141
|
+
Put them beside ${ENTRY_FILE} and reference them relatively — nested paths,
|
|
142
|
+
spaces and punctuation in names are all fine:
|
|
143
|
+
|
|
144
|
+
<link rel="stylesheet" href="style.css">
|
|
145
|
+
<script src="app.js"></script>
|
|
146
|
+
<img src="figures/chart.png" alt="…">
|
|
147
|
+
|
|
148
|
+
No permission, no declaration, no API: writing a file into your page root is
|
|
149
|
+
enough. Keep everything inside your own page root; another agent's page is
|
|
150
|
+
not yours to write.
|
|
151
|
+
|
|
152
|
+
**How this actually works, because it constrains what you can do.** Your page
|
|
153
|
+
runs in a sandbox on an opaque origin, and a request it makes for itself
|
|
154
|
+
carries no credential. A bb on loopback asks for none and the file arrives; a
|
|
155
|
+
bb reached over an authenticated origin — which is how the reader opens the
|
|
156
|
+
page on a phone — refuses it. So the host resolves your relative references
|
|
157
|
+
**when it serves the document**: each one is read from your page root and
|
|
158
|
+
rewritten to a \`data:\` URL before the reader's browser ever sees it. The
|
|
159
|
+
consequences worth knowing:
|
|
160
|
+
|
|
161
|
+
- It works the same on every origin. Write the reference; do not work around it.
|
|
162
|
+
- Your files are **inside the document**, so they count against the ${mebibytes(LIMITS.entryDocumentBytes)}
|
|
163
|
+
entry limit, and a page over ${kibibytes(LIMITS.offlineCopyBytes)} keeps no offline copy. Per file at
|
|
164
|
+
most ${mebibytes(LIMITS.inlineFileBytes)}, ${mebibytes(LIMITS.inlineTotalBytes)} across the page; base64 adds a third to both.
|
|
165
|
+
- A file that is missing, too large or over the budget is **left as you wrote
|
|
166
|
+
it** and named in the plugin log (\`bb plugin logs thread-pages\`). The page
|
|
167
|
+
still renders; that one reference does not resolve.
|
|
168
|
+
- \`url()\` inside a stylesheet you reference is followed too, so backgrounds
|
|
169
|
+
and \`@font-face\` survive. Absolute and remote URLs are never touched.
|
|
170
|
+
- Changing a file beside ${ENTRY_FILE} changes the document, so an open page
|
|
171
|
+
reloads — see *Keeping a page's data current*. You do not have to touch
|
|
172
|
+
${ENTRY_FILE} to publish new data.
|
|
173
|
+
${
|
|
174
|
+
site.name === "core-storage"
|
|
175
|
+
? `
|
|
176
|
+
**One limitation left on this host:** \`fetch("data.json")\` of your own file
|
|
177
|
+
from page script is refused (403) — the host's file route rejects the
|
|
178
|
+
sandbox's \`Origin: null\`, and only subresource references are resolved for
|
|
179
|
+
you. Load data with <script src="data.js"> or inline it in the document.
|
|
180
|
+
Remote fetches work (see Network).`
|
|
181
|
+
: `
|
|
182
|
+
Page script may also fetch its own files as data: \`await fetch("data.json")\`.`
|
|
183
|
+
}`;
|
|
184
|
+
|
|
185
|
+
const keepingCurrent = () => `## Keeping a page's data current
|
|
186
|
+
|
|
187
|
+
The entry document is the only artifact guaranteed to reach every reader, on
|
|
188
|
+
every origin. Rewriting it is therefore how you push new data to an open page:
|
|
189
|
+
the shell notices the new revision within ${LIMITS.shellPollMs / 1000} s and reloads the page under
|
|
190
|
+
the reader, preserving what they were typing. You do not need a poller, a
|
|
191
|
+
sidecar or a socket for this — a page that follows a data source is a page
|
|
192
|
+
something rewrites.
|
|
193
|
+
|
|
194
|
+
Three things to get right:
|
|
195
|
+
|
|
196
|
+
- **Make the build deterministic.** An unchanged data set must produce a
|
|
197
|
+
byte-identical document. This is the non-obvious half: a generated timestamp
|
|
198
|
+
in the payload turns every rebuild into a reload for every reader, and the
|
|
199
|
+
page will look like it is flickering for no reason.
|
|
200
|
+
- **Set \`setDirty(true)\` while the reader is mid-edit** in state the host
|
|
201
|
+
cannot see. A captured form does this for you; your own widgets do not.
|
|
202
|
+
- **Refresh on a slow watch, not a tight timer.** A page shares a budget of
|
|
203
|
+
${LIMITS.ratePerMinute} requests a minute with its own forms.
|
|
204
|
+
|
|
205
|
+
\`window.threadPage.watch\` is the other half, for live host state — sessions,
|
|
206
|
+
activity — that does not live in your file. Use the document rewrite for data
|
|
207
|
+
you generate, and \`watch\` for data the host owns.`;
|
|
208
|
+
|
|
209
|
+
const runtimeApi = () => `## window.threadPage
|
|
210
|
+
|
|
211
|
+
The complete page-facing API; it is frozen and cannot be replaced.
|
|
212
|
+
|
|
213
|
+
window.threadPage.version // 1
|
|
214
|
+
await window.threadPage.invoke(method, params)
|
|
215
|
+
const stop = window.threadPage.watch(method, params, (value, error) => {…}, { intervalMs })
|
|
216
|
+
window.threadPage.setDirty(true | false)
|
|
217
|
+
|
|
218
|
+
- \`invoke\` resolves with the capability's result and rejects with an Error
|
|
219
|
+
whose \`code\` is one of: invalid_json, invalid_request, invalid_params,
|
|
220
|
+
invalid_response, request_too_large, response_too_large,
|
|
221
|
+
unsupported_version, unknown_method, stale_page, confirmation_required,
|
|
222
|
+
confirmation_invalid, cancelled, not_found, conflict, unavailable,
|
|
223
|
+
rate_limited, handler_error, invalid_result. Calls made before the page is
|
|
224
|
+
connected are queued, never lost.
|
|
225
|
+
- \`watch\` polls a read capability: default every ${LIMITS.watchDefaultMs / 1000} s, clamped to
|
|
226
|
+
${LIMITS.watchMinMs / 1000} s–${LIMITS.watchMaxMs / 60_000} min, paused while the tab is hidden. Errors go to the
|
|
227
|
+
listener's second argument. Call the returned function to stop; a page that
|
|
228
|
+
never calls watch causes no polling.
|
|
229
|
+
- \`stale_page\` means the page changed under the call: the shell offers a
|
|
230
|
+
reload. \`cancelled\` means the reader declined a confirmation — a normal
|
|
231
|
+
outcome every page calling a confirmed capability must handle, not an error.
|
|
232
|
+
|
|
233
|
+
Check what is enabled rather than assume: \`(await invoke("context.get")).capabilities\`.`;
|
|
234
|
+
|
|
235
|
+
function capabilities(registry: CapabilityRegistry): string {
|
|
236
|
+
const rows = registry.list().map((spec) => {
|
|
237
|
+
const status = spec.implemented ? (spec.confirmed ? "confirmed in trusted chrome" : "no confirmation") : "not implemented on this host: unknown_method";
|
|
238
|
+
const lines = [`### \`${spec.method}\` — ${spec.effect} · ${status}`, "", spec.description, "", `Parameters: ${spec.doc.params}`, "", `Result: ${spec.doc.result}`];
|
|
239
|
+
if (spec.doc.notes) lines.push("", spec.doc.notes);
|
|
240
|
+
return lines.join("\n");
|
|
241
|
+
});
|
|
242
|
+
return `## Capabilities
|
|
243
|
+
|
|
244
|
+
Every way a page can affect anything outside itself. Effects: read;
|
|
245
|
+
own-session-write; cross-session-write, destructive and device (always
|
|
246
|
+
confirmed); navigation (confirmed when it leaves this host). A confirmed
|
|
247
|
+
capability shows a dialog in trusted chrome with the host's own wording; you
|
|
248
|
+
do not build it and cannot word it. Every capability validates its
|
|
249
|
+
parameters exactly — unknown keys are refused — and returns only the fields
|
|
250
|
+
listed here.
|
|
251
|
+
|
|
252
|
+
${rows.join("\n\n")}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const startingSessions = () => `## Starting work from a page
|
|
256
|
+
|
|
257
|
+
\`sessions.start\` is how a page that should stay put comes to exist: its
|
|
258
|
+
buttons start fresh sessions instead of messaging you, so nothing asks you to
|
|
259
|
+
rewrite it. It succeeds with only a project and a prompt:
|
|
260
|
+
|
|
261
|
+
await window.threadPage.invoke("sessions.start", {
|
|
262
|
+
projectId, prompt: "Run the test suite. Report failures only; change nothing."
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
What you get when you say nothing, and how to say otherwise:
|
|
266
|
+
|
|
267
|
+
- environment: the project's default. Otherwise \`environment: { sameAs: sessionId }\`
|
|
268
|
+
runs in the same environment as that session.
|
|
269
|
+
- provider, model, reasoningLevel: the project's defaults. Otherwise name ids
|
|
270
|
+
from \`providers.list\`.
|
|
271
|
+
- title: the host's own. Otherwise \`title\`.
|
|
272
|
+
- The session is a visible root owned by the reader, never a child of yours.
|
|
273
|
+
|
|
274
|
+
The confirmation names the project and the prompt. Handle \`cancelled\`:
|
|
275
|
+
|
|
276
|
+
try { await invoke("sessions.start", {…}); say("Started."); }
|
|
277
|
+
catch (e) { say(e.code === "cancelled" ? "Nothing started." : e.message); }
|
|
278
|
+
|
|
279
|
+
\`sessions.send\` steers an existing session the same way; it refuses your own
|
|
280
|
+
session — use \`session.reply\` for that.`;
|
|
281
|
+
|
|
282
|
+
const network = () => `## Network
|
|
283
|
+
|
|
284
|
+
Pages have internet access: fetch any origin, load remote fonts, scripts,
|
|
285
|
+
stylesheets, images and media, open WebSockets. The page still holds no host
|
|
286
|
+
credential — reaching a URL and acting as the host are different things.
|
|
287
|
+
|
|
288
|
+
Two consequences to know: page script runs in the reader's browser, so it can
|
|
289
|
+
reach what that device can reach, including its own network; and script can
|
|
290
|
+
navigate its own frame with data in the URL. Both are accepted, documented
|
|
291
|
+
properties of the model, not bugs to work around.`;
|
|
292
|
+
|
|
293
|
+
const unavailable = () => `## What the sandbox silences
|
|
294
|
+
|
|
295
|
+
These do nothing, silently — the worst failure mode — so never rely on them:
|
|
296
|
+
|
|
297
|
+
- \`window.open\` — use \`pages.open\` for another page, \`sessions.openHost\`
|
|
298
|
+
for the host application, and a plain <a href="https://…"> or
|
|
299
|
+
\`navigation.openExternal\` for the web.
|
|
300
|
+
- \`window.prompt\`, \`alert\`, \`confirm\` — build the input or the question into
|
|
301
|
+
the page (an <input>, a <dialog> with data-thread-page-manual, a second
|
|
302
|
+
form), or use a confirmed capability, which renders its own dialog.
|
|
303
|
+
- top-level navigation — \`pages.open\` and \`sessions.openHost\` navigate the
|
|
304
|
+
reader's view in place through trusted chrome; the back button returns.
|
|
305
|
+
|
|
306
|
+
An ordinary <a href="https://…"> works: the host intercepts the click and
|
|
307
|
+
routes it through \`navigation.openExternal\`, which confirms and names the
|
|
308
|
+
destination. Same-document fragments (#section) work natively. The trust
|
|
309
|
+
boundary is not configurable: no setting widens the sandbox.`;
|
|
310
|
+
|
|
311
|
+
const composition = () => `## One agent, one page
|
|
312
|
+
|
|
313
|
+
Your page is yours alone. You never read or write another agent's page, and
|
|
314
|
+
the host provides no mechanism to. If the reader wants a dashboard, a console
|
|
315
|
+
or a second view, start a session with instructions to build it; that agent
|
|
316
|
+
writes its own page. Link to it with \`pages.open\`, or suggest making it home.
|
|
317
|
+
If you want another agent's page changed, send that agent a message with
|
|
318
|
+
\`sessions.send\` rather than editing its file. Do not create a session merely
|
|
319
|
+
to hold a page: a page that stays put is owned by a real agent that built it
|
|
320
|
+
and then stopped.
|
|
321
|
+
|
|
322
|
+
**The whole file is yours.** There is no page-editing API and there is not
|
|
323
|
+
meant to be one: ${ENTRY_FILE} is a file in your storage directory that you
|
|
324
|
+
read and write with your ordinary tools. Nothing in it is reserved — not the
|
|
325
|
+
stylesheet, not the header, not the comment the seed came with. Rewriting the
|
|
326
|
+
document whole is the expected way to change it, and safer than splicing,
|
|
327
|
+
because a splice computed from string indices can silently eat content that a
|
|
328
|
+
whole-document write cannot.
|
|
329
|
+
|
|
330
|
+
**A page may be build output.** A repository script generating pages into
|
|
331
|
+
several sessions' storage — so a team gets one identical interface from a
|
|
332
|
+
checkout rather than from three agents independently writing HTML — is
|
|
333
|
+
legitimate. The rule that does not bend: every page still has one owning
|
|
334
|
+
session, and that session's agent builds the page the first time, whether or
|
|
335
|
+
not a script takes over afterwards. A page with no agent behind it is a page
|
|
336
|
+
nobody can be asked to change.`;
|
|
337
|
+
|
|
338
|
+
const home = () => `## The home page
|
|
339
|
+
|
|
340
|
+
One page is home; every other page shows a "← Sessions" link back to it in
|
|
341
|
+
chrome you never write. \`bb thread-page home\` sets the pointer for the
|
|
342
|
+
current session (\`--clear\` removes it) and creates the plain seed if the
|
|
343
|
+
session has no page yet; it never touches an existing page. Home is an
|
|
344
|
+
ordinary page — a hub is one an agent builds, and the right place to build it
|
|
345
|
+
is a session dedicated to it, so nothing else ever rewrites it.
|
|
346
|
+
|
|
347
|
+
### Setting one up, step by step
|
|
348
|
+
|
|
349
|
+
1. In the session that should own it (start one for the purpose if you are
|
|
350
|
+
mid-task), run \`bb thread-page home\`. It prints the link every page will
|
|
351
|
+
carry.
|
|
352
|
+
2. Replace <main> in that session's index.html with the starter hub below,
|
|
353
|
+
then stop. The page stays put because its buttons open other pages or
|
|
354
|
+
start fresh sessions; nothing messages this session.
|
|
355
|
+
3. Tell the reader the link and that the hub is theirs to change: they can
|
|
356
|
+
ask this session to regroup, restyle or add jobs any time.
|
|
357
|
+
|
|
358
|
+
### A starter hub
|
|
359
|
+
|
|
360
|
+
Complete and working as written; drop it into <main>. It follows what the
|
|
361
|
+
reader already sees in bb: no archived sessions, sub-agents hidden, the
|
|
362
|
+
sessions that need them first (working, waiting on them, unread — a failed
|
|
363
|
+
session only until they have looked), five recent per project then "Show
|
|
364
|
+
more", one line per session, search with "/", Read/Unread, Stop, Archive and
|
|
365
|
+
start-a-session with the confirmations handled. Views and
|
|
366
|
+
collapsed projects persist in \`storage\`. It widens the page for the list;
|
|
367
|
+
that is allowed — the page owns its stylesheet.
|
|
368
|
+
|
|
369
|
+
${starterHubForGuide()}
|
|
370
|
+
|
|
371
|
+
Refresh on a slow watch, not a tight timer: the page shares a rate budget of
|
|
372
|
+
${LIMITS.ratePerMinute} requests a minute with its own forms. Grouping is yours to change: a
|
|
373
|
+
group can be any set of projects, and \`data-theme\` on a group's element can
|
|
374
|
+
give it its own look.`;
|
|
375
|
+
|
|
376
|
+
const accessibility = () => `## Before you save
|
|
377
|
+
|
|
378
|
+
- Read it once at 320px wide, once in dark mode, once with reduced motion.
|
|
379
|
+
- Every action reachable by keyboard; nothing pointer-only.
|
|
380
|
+
- Inline SVG for diagrams and charts, with var(--accent) inside it; a zero
|
|
381
|
+
gets a visible stub or the eye reads missing data.
|
|
382
|
+
- grep -o '#[0-9a-fA-F]\\{3,8\\}' ${ENTRY_FILE} inside your <style> should be empty.
|
|
383
|
+
- Read it once over the reader's real origin, not only loopback. A local bb
|
|
384
|
+
requires no credential and a remote one does, so anything the page loads for
|
|
385
|
+
itself can work for you and fail for them. Authentication is the one axis
|
|
386
|
+
where behaviour genuinely differs between your machine and theirs.`;
|
|
387
|
+
|
|
388
|
+
const upgrading = () => `## If your page predates 1.1
|
|
389
|
+
|
|
390
|
+
Three things to fix in a page written against 1.0.x. Each is a one-line edit
|
|
391
|
+
and none of them announces itself.
|
|
392
|
+
|
|
393
|
+
1. **Add \`[hidden] { display: none !important; }\`** to your <style>. A class
|
|
394
|
+
rule that sets display outranks the attribute, so an element you wrote
|
|
395
|
+
\`hidden\` renders as an empty bar. New pages carry the fix; yours has its
|
|
396
|
+
own copy of the stylesheet and will not get it.
|
|
397
|
+
2. **Delete the seed's old authoring comment** if it is still there. It spelled
|
|
398
|
+
tags out literally, so every string operation you run on your own file sees
|
|
399
|
+
a <main> and a <style> that are not elements, and the obvious splice starts
|
|
400
|
+
inside the comment.
|
|
401
|
+
3. **Move inlined data back out.** 1.0 told you to inline anything the page
|
|
402
|
+
could not do without, because a file beside the page failed on the reader's
|
|
403
|
+
origin. That is fixed: reference it relatively and it works everywhere. Your
|
|
404
|
+
entry document gets small again, which makes it cheap to rewrite.
|
|
405
|
+
|
|
406
|
+
Then check \`bb plugin logs thread-pages\` once, and read the page over the
|
|
407
|
+
reader's real origin rather than loopback.
|
|
408
|
+
|
|
409
|
+
Full notes, including what still is not possible:
|
|
410
|
+
\`docs/FOR-PAGE-AUTHORS-1.1.md\` in the plugin, and \`docs/UPGRADING.md\` for
|
|
411
|
+
the 0.3.x method names.`;
|
|
412
|
+
|
|
413
|
+
const limits = () => `## Limits
|
|
414
|
+
|
|
415
|
+
| Limit | Value |
|
|
416
|
+
| --- | --- |
|
|
417
|
+
| Entry document | ${mebibytes(LIMITS.entryDocumentBytes)}, refused above, never truncated |
|
|
418
|
+
| Other files in the page root | ${mebibytes(25 * 1024 * 1024)} per file (${mebibytes(10 * 1024 * 1024)} for images), the host's read limit |
|
|
419
|
+
| Upload per file | ${mebibytes(LIMITS.uploadFileBytes)} |
|
|
420
|
+
| Uploads per form | ${LIMITS.uploadsPerForm} |
|
|
421
|
+
| Submission body | ${kibibytes(LIMITS.submissionBodyBytes)} excluding uploaded bytes; ${LIMITS.answersPerSubmission} answers; ${LIMITS.answerValueChars} characters per answer |
|
|
422
|
+
| Capability payload | ${kibibytes(LIMITS.capabilityPayloadBytes)} request and response, depth ${LIMITS.capabilityJsonDepth}, ${LIMITS.capabilityJsonNodes} nodes |
|
|
423
|
+
| Prompt | ${kibibytes(LIMITS.promptChars)} characters (sessions.start, sessions.send) |
|
|
424
|
+
| session.reply result | ${kibibytes(LIMITS.resultTextBytes)} |
|
|
425
|
+
| Title | ${LIMITS.titleChars} characters |
|
|
426
|
+
| storage value | ${kibibytes(LIMITS.storageValueBytes)} per key; keys ${LIMITS.storageKeyChars} characters |
|
|
427
|
+
| sessions.snapshot | ${LIMITS.snapshotDefault} default, ${LIMITS.snapshotMax} maximum per call |
|
|
428
|
+
| session.activity | ${LIMITS.activityDefault} default, ${LIMITS.activityMax} maximum |
|
|
429
|
+
| Page session (action token) | ${LIMITS.actionTokenMs / 3_600_000} hours, then the shell reloads or asks |
|
|
430
|
+
| Confirmation | ${LIMITS.confirmationMs / 60_000} minutes to answer the dialog |
|
|
431
|
+
| Folder selection | ${LIMITS.selectionTokenMs / 60_000} minutes, single use |
|
|
432
|
+
| Submission idempotency | ${LIMITS.idempotencyRecords} records, ${LIMITS.idempotencyMs / 60_000} minutes |
|
|
433
|
+
| Rate limit | ${LIMITS.ratePerMinute} accepted requests a minute and ${LIMITS.rateConcurrent} in flight, per page; refused with rate_limited |
|
|
434
|
+
| Shell revision poll | every ${LIMITS.shellPollMs / 1000} s while visible |
|
|
435
|
+
| watch interval | ${LIMITS.watchDefaultMs / 1000} s default, ${LIMITS.watchMinMs / 1000} s–${LIMITS.watchMaxMs / 60_000} min |
|
|
436
|
+
| Offline copy | entry documents up to ${kibibytes(LIMITS.offlineCopyBytes)} are kept so the page opens read-only when its host is unreachable |`;
|
|
437
|
+
|
|
438
|
+
const limitations = (site: SiteStrategy) => `## Known limitations
|
|
439
|
+
|
|
440
|
+
- A page served from the offline copy is read-only: captured forms are
|
|
441
|
+
disabled and effectful capabilities answer unavailable.
|
|
442
|
+
- A confirmed capability that fails on the host answers handler_error with a
|
|
443
|
+
generic message; the cause is in the plugin log (\`bb plugin logs thread-pages\`).
|
|
444
|
+
- Embedding another page or site in an <iframe> is blocked (frame-src 'none').
|
|
445
|
+
- \`voice.captureAndTranscribe\` is not implemented: unknown_method.${
|
|
446
|
+
site.name === "core-storage"
|
|
447
|
+
? `\n- fetch() of your own files from page script is refused on this host (see Files you show the reader).` +
|
|
448
|
+
`\n- Your own files are carried inside the entry document rather than served as files, because this host cannot authorise a sandboxed document's own requests. That is why they count against the document's size limits.`
|
|
449
|
+
: ""
|
|
450
|
+
}`;
|