@unifedev/thread-pages 0.3.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +77 -129
  2. package/dist/server.js +11779 -11801
  3. package/dist/server.meta.json +2 -2
  4. package/docs/B1-OWN-FILES.md +133 -0
  5. package/docs/FOR-PAGE-AUTHORS-1.1.md +167 -0
  6. package/docs/UPGRADING.md +53 -0
  7. package/package.json +26 -18
  8. package/server.ts +3 -2175
  9. package/src/agent/cli.ts +193 -0
  10. package/src/agent/guide.ts +450 -0
  11. package/src/agent/instruction.ts +59 -0
  12. package/src/agent/seed/seed.ts +73 -0
  13. package/{theme.ts → src/agent/seed/theme-css.ts} +9 -11
  14. package/src/agent/starter-hub.ts +217 -0
  15. package/src/bb/activity.ts +59 -0
  16. package/src/bb/bb-host.ts +280 -0
  17. package/src/bb/public-origin.ts +45 -0
  18. package/src/config/settings.ts +82 -0
  19. package/src/domain/capabilities/contract.ts +48 -0
  20. package/src/domain/capabilities/index.ts +10 -0
  21. package/src/domain/capabilities/protocol.ts +113 -0
  22. package/src/domain/capabilities/registry.ts +48 -0
  23. package/src/domain/capabilities/renamed.ts +34 -0
  24. package/src/domain/capabilities/schema.ts +198 -0
  25. package/src/domain/capabilities/specs.ts +479 -0
  26. package/src/domain/eligibility.ts +43 -0
  27. package/src/domain/errors.ts +116 -0
  28. package/src/domain/html/document.ts +109 -0
  29. package/src/domain/html/escape.ts +16 -0
  30. package/src/domain/ids.ts +37 -0
  31. package/src/domain/json/canonical.ts +19 -0
  32. package/src/domain/json/strict-json.ts +139 -0
  33. package/src/domain/limits.ts +98 -0
  34. package/src/domain/rate-limit.ts +64 -0
  35. package/src/domain/revision.ts +27 -0
  36. package/src/domain/submissions/idempotency.ts +59 -0
  37. package/src/domain/submissions/message.ts +42 -0
  38. package/src/domain/submissions/parse.ts +105 -0
  39. package/src/domain/tokens/action-token.ts +52 -0
  40. package/src/domain/tokens/confirmation.ts +99 -0
  41. package/src/domain/tokens/mac.ts +50 -0
  42. package/src/generated/kernel-runtime.ts +3 -0
  43. package/src/generated/shell-runtime.ts +3 -0
  44. package/src/host/contract.ts +65 -0
  45. package/src/host/types.ts +89 -0
  46. package/src/pages/inline.ts +277 -0
  47. package/src/pages/layout.ts +65 -0
  48. package/src/pages/page-store.ts +170 -0
  49. package/src/pages/site.ts +36 -0
  50. package/src/plugin.ts +81 -0
  51. package/src/runtime/kernel/anchors.ts +45 -0
  52. package/src/runtime/kernel/api.ts +15 -0
  53. package/src/runtime/kernel/bridge-client.ts +148 -0
  54. package/src/runtime/kernel/dirty.ts +51 -0
  55. package/src/runtime/kernel/forms.ts +114 -0
  56. package/src/runtime/kernel/install.ts +156 -0
  57. package/src/runtime/kernel/labels.ts +98 -0
  58. package/src/runtime/kernel/main.ts +6 -0
  59. package/src/runtime/kernel/readonly.ts +75 -0
  60. package/src/runtime/shared/protocol.ts +125 -0
  61. package/src/runtime/shell/confirm.ts +70 -0
  62. package/src/runtime/shell/install.ts +79 -0
  63. package/src/runtime/shell/main.ts +12 -0
  64. package/src/runtime/shell/navigate.ts +64 -0
  65. package/src/runtime/shell/poll.ts +125 -0
  66. package/src/runtime/shell/relay.ts +185 -0
  67. package/src/serving/action-request.ts +32 -0
  68. package/src/serving/bridge/dispatcher.ts +112 -0
  69. package/src/serving/bridge/handler.ts +37 -0
  70. package/src/serving/bridge/handlers/index.ts +26 -0
  71. package/src/serving/bridge/handlers/navigation.ts +43 -0
  72. package/src/serving/bridge/handlers/reads.ts +186 -0
  73. package/src/serving/bridge/handlers/writes.ts +175 -0
  74. package/src/serving/bridge/selection-store.ts +58 -0
  75. package/src/serving/bridge-route.ts +23 -0
  76. package/src/serving/context.ts +34 -0
  77. package/src/serving/document-route.ts +37 -0
  78. package/src/serving/home-route.ts +23 -0
  79. package/src/serving/responses.ts +81 -0
  80. package/src/serving/routes.ts +26 -0
  81. package/src/serving/session-access.ts +22 -0
  82. package/src/serving/shell-html.ts +77 -0
  83. package/src/serving/shell-route.ts +51 -0
  84. package/src/serving/signing-key.ts +25 -0
  85. package/src/serving/submit-route.ts +47 -0
  86. package/src/serving/upload-route.ts +46 -0
  87. package/tsconfig.json +10 -6
  88. package/ARCHITECTURE.md +0 -230
  89. package/PLUGIN_OVERVIEW.md +0 -83
  90. package/authoring.ts +0 -368
  91. package/bridge.ts +0 -1721
  92. package/docs/MODEL.md +0 -211
  93. package/docs/ROADMAP.md +0 -96
  94. package/home.ts +0 -419
  95. package/page.ts +0 -782
@@ -0,0 +1,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
+ }`;