@unifedev/thread-pages 0.3.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +77 -129
  2. package/dist/server.js +11426 -11802
  3. package/dist/server.meta.json +2 -2
  4. package/package.json +25 -18
  5. package/server.ts +3 -2175
  6. package/src/agent/cli.ts +193 -0
  7. package/src/agent/guide.ts +355 -0
  8. package/src/agent/instruction.ts +59 -0
  9. package/src/agent/seed/seed.ts +69 -0
  10. package/{theme.ts → src/agent/seed/theme-css.ts} +4 -11
  11. package/src/agent/starter-hub.ts +217 -0
  12. package/src/bb/activity.ts +59 -0
  13. package/src/bb/bb-host.ts +280 -0
  14. package/src/bb/public-origin.ts +45 -0
  15. package/src/config/settings.ts +82 -0
  16. package/src/domain/capabilities/contract.ts +48 -0
  17. package/src/domain/capabilities/index.ts +10 -0
  18. package/src/domain/capabilities/protocol.ts +112 -0
  19. package/src/domain/capabilities/registry.ts +48 -0
  20. package/src/domain/capabilities/schema.ts +198 -0
  21. package/src/domain/capabilities/specs.ts +479 -0
  22. package/src/domain/eligibility.ts +43 -0
  23. package/src/domain/errors.ts +116 -0
  24. package/src/domain/html/document.ts +109 -0
  25. package/src/domain/html/escape.ts +16 -0
  26. package/src/domain/ids.ts +37 -0
  27. package/src/domain/json/canonical.ts +19 -0
  28. package/src/domain/json/strict-json.ts +139 -0
  29. package/src/domain/limits.ts +88 -0
  30. package/src/domain/rate-limit.ts +64 -0
  31. package/src/domain/revision.ts +27 -0
  32. package/src/domain/submissions/idempotency.ts +59 -0
  33. package/src/domain/submissions/message.ts +42 -0
  34. package/src/domain/submissions/parse.ts +105 -0
  35. package/src/domain/tokens/action-token.ts +52 -0
  36. package/src/domain/tokens/confirmation.ts +99 -0
  37. package/src/domain/tokens/mac.ts +50 -0
  38. package/src/generated/kernel-runtime.ts +3 -0
  39. package/src/generated/shell-runtime.ts +3 -0
  40. package/src/host/contract.ts +65 -0
  41. package/src/host/types.ts +89 -0
  42. package/src/pages/layout.ts +65 -0
  43. package/src/pages/page-store.ts +136 -0
  44. package/src/pages/site.ts +36 -0
  45. package/src/plugin.ts +71 -0
  46. package/src/runtime/kernel/anchors.ts +45 -0
  47. package/src/runtime/kernel/api.ts +15 -0
  48. package/src/runtime/kernel/bridge-client.ts +148 -0
  49. package/src/runtime/kernel/dirty.ts +51 -0
  50. package/src/runtime/kernel/forms.ts +114 -0
  51. package/src/runtime/kernel/install.ts +156 -0
  52. package/src/runtime/kernel/labels.ts +98 -0
  53. package/src/runtime/kernel/main.ts +6 -0
  54. package/src/runtime/kernel/readonly.ts +75 -0
  55. package/src/runtime/shared/protocol.ts +125 -0
  56. package/src/runtime/shell/confirm.ts +70 -0
  57. package/src/runtime/shell/install.ts +79 -0
  58. package/src/runtime/shell/main.ts +12 -0
  59. package/src/runtime/shell/navigate.ts +64 -0
  60. package/src/runtime/shell/poll.ts +125 -0
  61. package/src/runtime/shell/relay.ts +185 -0
  62. package/src/serving/action-request.ts +32 -0
  63. package/src/serving/bridge/dispatcher.ts +112 -0
  64. package/src/serving/bridge/handler.ts +37 -0
  65. package/src/serving/bridge/handlers/index.ts +26 -0
  66. package/src/serving/bridge/handlers/navigation.ts +43 -0
  67. package/src/serving/bridge/handlers/reads.ts +186 -0
  68. package/src/serving/bridge/handlers/writes.ts +175 -0
  69. package/src/serving/bridge/selection-store.ts +58 -0
  70. package/src/serving/bridge-route.ts +23 -0
  71. package/src/serving/context.ts +34 -0
  72. package/src/serving/document-route.ts +37 -0
  73. package/src/serving/home-route.ts +23 -0
  74. package/src/serving/responses.ts +81 -0
  75. package/src/serving/routes.ts +26 -0
  76. package/src/serving/session-access.ts +22 -0
  77. package/src/serving/shell-html.ts +77 -0
  78. package/src/serving/shell-route.ts +51 -0
  79. package/src/serving/signing-key.ts +25 -0
  80. package/src/serving/submit-route.ts +47 -0
  81. package/src/serving/upload-route.ts +46 -0
  82. package/tsconfig.json +10 -6
  83. package/ARCHITECTURE.md +0 -230
  84. package/PLUGIN_OVERVIEW.md +0 -83
  85. package/authoring.ts +0 -368
  86. package/bridge.ts +0 -1721
  87. package/docs/MODEL.md +0 -211
  88. package/docs/ROADMAP.md +0 -96
  89. package/home.ts +0 -419
  90. package/page.ts +0 -782
@@ -0,0 +1,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,355 @@
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
+ runtimeApi(),
21
+ capabilities(registry),
22
+ startingSessions(),
23
+ network(),
24
+ unavailable(),
25
+ composition(),
26
+ home(),
27
+ accessibility(),
28
+ limits(),
29
+ limitations(site),
30
+ ].join("\n\n");
31
+ }
32
+
33
+ const intro = () => `# Thread Pages — authoring guide
34
+
35
+ A page is a complete HTML document you edit directly; saving publishes it. It
36
+ runs in a sandboxed frame on an opaque origin with no host credentials, and
37
+ talks to the host only through captured forms and \`window.threadPage\`. Use
38
+ the smallest shape that makes the task easier: plain semantic HTML first, a
39
+ mini-app only when the shape of the thing is not prose.
40
+
41
+ Your page root is your session's storage directory (\`$BB_THREAD_STORAGE\`):
42
+
43
+ ${ENTRY_FILE} the entry document — the page
44
+ <any files> served beside it, nested directories included
45
+ ${UPLOAD_DIR}/ files the reader attached, named by the host`;
46
+
47
+ const plainHtml = () => `## What plain HTML already gives you
48
+
49
+ The seed carries its own stylesheet, so semantic HTML is already styled.
50
+
51
+ h2, p, ul, table the page's type scale and rhythm
52
+ form a panel wired to your session, with a status line
53
+ fieldset + legend a named group; the legend becomes the question
54
+ label wrapping one the label becomes that answer's name
55
+ small inside a label a hint (never part of the answer's name)
56
+ input type=range a slider with a live value readout
57
+ input type=file uploaded on submit, path sent to you
58
+ details/summary detail on demand; add name="x" for an accordion
59
+ div.card a boxed aside
60
+ p.needs-you a flagged block, for what is blocked on the reader
61
+ span.label a small uppercase tag
62
+
63
+ Three class names; everything else keys off what the element is. The look is
64
+ three attributes on <html>: data-theme (paper | terminal | atrium | volume |
65
+ bloom), data-mode (system | light | dark), data-atmos (on | off). Extra CSS
66
+ goes in one more <style>, everything inside @scope (main), colour and shape
67
+ from var(--token) only — that is what keeps a bespoke chart right in every
68
+ world and in dark mode. You may replace the stylesheet entirely; the page is
69
+ yours.`;
70
+
71
+ const forms = () => `## Forms
72
+
73
+ Every <form> in the document is captured and delivered to your session as a
74
+ message — no JavaScript needed. Add data-thread-page-manual to a form your
75
+ own script owns; the host then leaves it entirely alone.
76
+
77
+ - Nothing is required and blank is a real answer: native validation is
78
+ suppressed, and a blank field arrives as "(left blank)".
79
+ - Answer names come from, in order: data-label on the control, the enclosing
80
+ fieldset's legend, aria-label, the wrapping label's text, a <label for>,
81
+ the field name. Hints, options and nested controls are excluded.
82
+ - Groups collapse: one checkbox is Yes/No; several checkboxes with one name
83
+ are a list of the checked values; radios are the one checked value or
84
+ blank; a multiple <select> is a list.
85
+ - The submit button's value leads the message as **Action**, so several
86
+ <button name="action" value="…"> give one-click answers.
87
+ - Each form has its own pending, dirty and status state. While a submission
88
+ is in flight its controls are disabled; afterwards the status line says
89
+ "Sent (queued)" or why it failed.
90
+ - Typing into a captured form marks the page dirty, so a new version of the
91
+ page does not reload under the reader. Custom state the host cannot see:
92
+ window.threadPage.setDirty(true|false).
93
+
94
+ The message you receive looks like:
95
+
96
+ The user answered the form on your Thread Page — <form's data-title or the h1>.
97
+
98
+ **Action**
99
+ Approve
100
+
101
+ **Which approach**
102
+ second
103
+
104
+ **Anything else**
105
+ (left blank)
106
+
107
+ ### The dialog trap
108
+
109
+ A <form method="dialog"> inside a <dialog> is a form, so it is captured too.
110
+ If you write one as a purely local confirm and forget the opt-out attribute,
111
+ pressing its button **sends a real message you did not intend**, and because
112
+ its buttons carry control-flow values, you receive a plausible fabricated
113
+ decision:
114
+
115
+ The user answered the form on your Thread Page — <the page's heading>.
116
+
117
+ **Action**
118
+ confirm
119
+
120
+ Nothing marks it as accidental, and if a turn is running it arrives on the
121
+ next one, detached from what caused it. Put data-thread-page-manual on every
122
+ dialog form that is not meant to answer you.`;
123
+
124
+ const uploads = () => `## Files the reader sends you
125
+
126
+ A captured form may contain <input type="file"> (multiple is fine). On submit
127
+ the files are uploaded first, then the submission is delivered naming them:
128
+
129
+ **Attached files**
130
+ - \`$BB_THREAD_STORAGE/${UPLOAD_DIR}/20260908-161200-3f9a1c-report.pdf\` (…, 48213 bytes)
131
+
132
+ Read them from there with your normal tools. Limits: ${mebibytes(LIMITS.uploadFileBytes)} per file,
133
+ ${LIMITS.uploadsPerForm} files per form (extras are dropped visibly). Names are generated by
134
+ the host; the reader's filename is only a suffix. An upload that fails shows
135
+ in the form's status line and no submission claims the missing file.`;
136
+
137
+ const ownFiles = (site: SiteStrategy) => `## Files you show the reader
138
+
139
+ Put them beside ${ENTRY_FILE} and reference them relatively — nested paths,
140
+ spaces and punctuation in names are all fine:
141
+
142
+ <link rel="stylesheet" href="style.css">
143
+ <script src="app.js"></script>
144
+ <img src="figures/chart.png" alt="…">
145
+
146
+ No permission, no declaration, no API: writing a file into your page root is
147
+ enough. Keep everything inside your own page root; another agent's page is
148
+ not yours to write.${
149
+ site.name === "core-storage"
150
+ ? `
151
+
152
+ **One limitation on this host:** \`fetch("data.json")\` of your own file from
153
+ page script is refused (403) — the host's file route rejects the sandbox's
154
+ \`Origin: null\`. Subresources (<script>, <link>, <img>) load normally, so
155
+ load data with <script src="data.js"> or inline it in the document. Remote
156
+ fetches work (see Network).`
157
+ : `
158
+
159
+ Page script may also fetch its own files as data: \`await fetch("data.json")\`.`
160
+ }`;
161
+
162
+ const runtimeApi = () => `## window.threadPage
163
+
164
+ The complete page-facing API; it is frozen and cannot be replaced.
165
+
166
+ window.threadPage.version // 1
167
+ await window.threadPage.invoke(method, params)
168
+ const stop = window.threadPage.watch(method, params, (value, error) => {…}, { intervalMs })
169
+ window.threadPage.setDirty(true | false)
170
+
171
+ - \`invoke\` resolves with the capability's result and rejects with an Error
172
+ whose \`code\` is one of: invalid_json, invalid_request, invalid_params,
173
+ invalid_response, request_too_large, response_too_large,
174
+ unsupported_version, unknown_method, stale_page, confirmation_required,
175
+ confirmation_invalid, cancelled, not_found, conflict, unavailable,
176
+ rate_limited, handler_error, invalid_result. Calls made before the page is
177
+ connected are queued, never lost.
178
+ - \`watch\` polls a read capability: default every ${LIMITS.watchDefaultMs / 1000} s, clamped to
179
+ ${LIMITS.watchMinMs / 1000} s–${LIMITS.watchMaxMs / 60_000} min, paused while the tab is hidden. Errors go to the
180
+ listener's second argument. Call the returned function to stop; a page that
181
+ never calls watch causes no polling.
182
+ - \`stale_page\` means the page changed under the call: the shell offers a
183
+ reload. \`cancelled\` means the reader declined a confirmation — a normal
184
+ outcome every page calling a confirmed capability must handle, not an error.
185
+
186
+ Check what is enabled rather than assume: \`(await invoke("context.get")).capabilities\`.`;
187
+
188
+ function capabilities(registry: CapabilityRegistry): string {
189
+ const rows = registry.list().map((spec) => {
190
+ const status = spec.implemented ? (spec.confirmed ? "confirmed in trusted chrome" : "no confirmation") : "not implemented on this host: unknown_method";
191
+ const lines = [`### \`${spec.method}\` — ${spec.effect} · ${status}`, "", spec.description, "", `Parameters: ${spec.doc.params}`, "", `Result: ${spec.doc.result}`];
192
+ if (spec.doc.notes) lines.push("", spec.doc.notes);
193
+ return lines.join("\n");
194
+ });
195
+ return `## Capabilities
196
+
197
+ Every way a page can affect anything outside itself. Effects: read;
198
+ own-session-write; cross-session-write, destructive and device (always
199
+ confirmed); navigation (confirmed when it leaves this host). A confirmed
200
+ capability shows a dialog in trusted chrome with the host's own wording; you
201
+ do not build it and cannot word it. Every capability validates its
202
+ parameters exactly — unknown keys are refused — and returns only the fields
203
+ listed here.
204
+
205
+ ${rows.join("\n\n")}`;
206
+ }
207
+
208
+ const startingSessions = () => `## Starting work from a page
209
+
210
+ \`sessions.start\` is how a page that should stay put comes to exist: its
211
+ buttons start fresh sessions instead of messaging you, so nothing asks you to
212
+ rewrite it. It succeeds with only a project and a prompt:
213
+
214
+ await window.threadPage.invoke("sessions.start", {
215
+ projectId, prompt: "Run the test suite. Report failures only; change nothing."
216
+ });
217
+
218
+ What you get when you say nothing, and how to say otherwise:
219
+
220
+ - environment: the project's default. Otherwise \`environment: { sameAs: sessionId }\`
221
+ runs in the same environment as that session.
222
+ - provider, model, reasoningLevel: the project's defaults. Otherwise name ids
223
+ from \`providers.list\`.
224
+ - title: the host's own. Otherwise \`title\`.
225
+ - The session is a visible root owned by the reader, never a child of yours.
226
+
227
+ The confirmation names the project and the prompt. Handle \`cancelled\`:
228
+
229
+ try { await invoke("sessions.start", {…}); say("Started."); }
230
+ catch (e) { say(e.code === "cancelled" ? "Nothing started." : e.message); }
231
+
232
+ \`sessions.send\` steers an existing session the same way; it refuses your own
233
+ session — use \`session.reply\` for that.`;
234
+
235
+ const network = () => `## Network
236
+
237
+ Pages have internet access: fetch any origin, load remote fonts, scripts,
238
+ stylesheets, images and media, open WebSockets. The page still holds no host
239
+ credential — reaching a URL and acting as the host are different things.
240
+
241
+ Two consequences to know: page script runs in the reader's browser, so it can
242
+ reach what that device can reach, including its own network; and script can
243
+ navigate its own frame with data in the URL. Both are accepted, documented
244
+ properties of the model, not bugs to work around.`;
245
+
246
+ const unavailable = () => `## What the sandbox silences
247
+
248
+ These do nothing, silently — the worst failure mode — so never rely on them:
249
+
250
+ - \`window.open\` — use \`pages.open\` for another page, \`sessions.openHost\`
251
+ for the host application, and a plain <a href="https://…"> or
252
+ \`navigation.openExternal\` for the web.
253
+ - \`window.prompt\`, \`alert\`, \`confirm\` — build the input or the question into
254
+ the page (an <input>, a <dialog> with data-thread-page-manual, a second
255
+ form), or use a confirmed capability, which renders its own dialog.
256
+ - top-level navigation — \`pages.open\` and \`sessions.openHost\` navigate the
257
+ reader's view in place through trusted chrome; the back button returns.
258
+
259
+ An ordinary <a href="https://…"> works: the host intercepts the click and
260
+ routes it through \`navigation.openExternal\`, which confirms and names the
261
+ destination. Same-document fragments (#section) work natively. The trust
262
+ boundary is not configurable: no setting widens the sandbox.`;
263
+
264
+ const composition = () => `## One agent, one page
265
+
266
+ Your page is yours alone. You never read or write another agent's page, and
267
+ the host provides no mechanism to. If the reader wants a dashboard, a console
268
+ or a second view, start a session with instructions to build it; that agent
269
+ writes its own page. Link to it with \`pages.open\`, or suggest making it home.
270
+ If you want another agent's page changed, send that agent a message with
271
+ \`sessions.send\` rather than editing its file. Do not create a session merely
272
+ to hold a page: a page that stays put is owned by a real agent that built it
273
+ and then stopped.`;
274
+
275
+ const home = () => `## The home page
276
+
277
+ One page is home; every other page shows a "← Sessions" link back to it in
278
+ chrome you never write. \`bb thread-page home\` sets the pointer for the
279
+ current session (\`--clear\` removes it) and creates the plain seed if the
280
+ session has no page yet; it never touches an existing page. Home is an
281
+ ordinary page — a hub is one an agent builds, and the right place to build it
282
+ is a session dedicated to it, so nothing else ever rewrites it.
283
+
284
+ ### Setting one up, step by step
285
+
286
+ 1. In the session that should own it (start one for the purpose if you are
287
+ mid-task), run \`bb thread-page home\`. It prints the link every page will
288
+ carry.
289
+ 2. Replace <main> in that session's index.html with the starter hub below,
290
+ then stop. The page stays put because its buttons open other pages or
291
+ start fresh sessions; nothing messages this session.
292
+ 3. Tell the reader the link and that the hub is theirs to change: they can
293
+ ask this session to regroup, restyle or add jobs any time.
294
+
295
+ ### A starter hub
296
+
297
+ Complete and working as written; drop it into <main>. It follows what the
298
+ reader already sees in bb: no archived sessions, sub-agents hidden, the
299
+ sessions that need them first (working, waiting on them, unread — a failed
300
+ session only until they have looked), five recent per project then "Show
301
+ more", one line per session, search with "/", Read/Unread, Stop, Archive and
302
+ start-a-session with the confirmations handled. Views and
303
+ collapsed projects persist in \`storage\`. It widens the page for the list;
304
+ that is allowed — the page owns its stylesheet.
305
+
306
+ ${starterHubForGuide()}
307
+
308
+ Refresh on a slow watch, not a tight timer: the page shares a rate budget of
309
+ ${LIMITS.ratePerMinute} requests a minute with its own forms. Grouping is yours to change: a
310
+ group can be any set of projects, and \`data-theme\` on a group's element can
311
+ give it its own look.`;
312
+
313
+ const accessibility = () => `## Before you save
314
+
315
+ - Read it once at 320px wide, once in dark mode, once with reduced motion.
316
+ - Every action reachable by keyboard; nothing pointer-only.
317
+ - Inline SVG for diagrams and charts, with var(--accent) inside it; a zero
318
+ gets a visible stub or the eye reads missing data.
319
+ - grep -o '#[0-9a-fA-F]\\{3,8\\}' ${ENTRY_FILE} inside your <style> should be empty.`;
320
+
321
+ const limits = () => `## Limits
322
+
323
+ | Limit | Value |
324
+ | --- | --- |
325
+ | Entry document | ${mebibytes(LIMITS.entryDocumentBytes)}, refused above, never truncated |
326
+ | Other files in the page root | ${mebibytes(25 * 1024 * 1024)} per file (${mebibytes(10 * 1024 * 1024)} for images), the host's read limit |
327
+ | Upload per file | ${mebibytes(LIMITS.uploadFileBytes)} |
328
+ | Uploads per form | ${LIMITS.uploadsPerForm} |
329
+ | Submission body | ${kibibytes(LIMITS.submissionBodyBytes)} excluding uploaded bytes; ${LIMITS.answersPerSubmission} answers; ${LIMITS.answerValueChars} characters per answer |
330
+ | Capability payload | ${kibibytes(LIMITS.capabilityPayloadBytes)} request and response, depth ${LIMITS.capabilityJsonDepth}, ${LIMITS.capabilityJsonNodes} nodes |
331
+ | Prompt | ${kibibytes(LIMITS.promptChars)} characters (sessions.start, sessions.send) |
332
+ | session.reply result | ${kibibytes(LIMITS.resultTextBytes)} |
333
+ | Title | ${LIMITS.titleChars} characters |
334
+ | storage value | ${kibibytes(LIMITS.storageValueBytes)} per key; keys ${LIMITS.storageKeyChars} characters |
335
+ | sessions.snapshot | ${LIMITS.snapshotDefault} default, ${LIMITS.snapshotMax} maximum per call |
336
+ | session.activity | ${LIMITS.activityDefault} default, ${LIMITS.activityMax} maximum |
337
+ | Page session (action token) | ${LIMITS.actionTokenMs / 3_600_000} hours, then the shell reloads or asks |
338
+ | Confirmation | ${LIMITS.confirmationMs / 60_000} minutes to answer the dialog |
339
+ | Folder selection | ${LIMITS.selectionTokenMs / 60_000} minutes, single use |
340
+ | Submission idempotency | ${LIMITS.idempotencyRecords} records, ${LIMITS.idempotencyMs / 60_000} minutes |
341
+ | Rate limit | ${LIMITS.ratePerMinute} accepted requests a minute and ${LIMITS.rateConcurrent} in flight, per page; refused with rate_limited |
342
+ | Shell revision poll | every ${LIMITS.shellPollMs / 1000} s while visible |
343
+ | watch interval | ${LIMITS.watchDefaultMs / 1000} s default, ${LIMITS.watchMinMs / 1000} s–${LIMITS.watchMaxMs / 60_000} min |
344
+ | Offline copy | entry documents up to ${kibibytes(LIMITS.offlineCopyBytes)} are kept so the page opens read-only when its host is unreachable |`;
345
+
346
+ const limitations = (site: SiteStrategy) => `## Known limitations
347
+
348
+ - A page served from the offline copy is read-only: captured forms are
349
+ disabled and effectful capabilities answer unavailable.
350
+ - A confirmed capability that fails on the host answers handler_error with a
351
+ generic message; the cause is in the plugin log (\`bb plugin logs thread-pages\`).
352
+ - Embedding another page or site in an <iframe> is blocked (frame-src 'none').
353
+ - \`voice.captureAndTranscribe\` is not implemented: unknown_method.${
354
+ site.name === "core-storage" ? `\n- fetch() of your own files from page script is refused on this host (see Files you show the reader).` : ""
355
+ }`;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The standing instruction, injected into eligible new sessions when the
3
+ * `agentInstructions` setting is on. It is paid for by every session, so it
4
+ * holds only what changes behaviour; the host truncates instructions at
5
+ * 4096 characters, so it must stay under that. spec R6.14–R6.17
6
+ */
7
+ export const DEFAULT_AGENT_INSTRUCTION = `# The page is the conversation
8
+
9
+ The reader does not read chat. Every turn you write or update one HTML page;
10
+ they read it and answer from inside it, and the answer arrives as your next
11
+ message. Chat carries the link and one line. A page they cannot answer from is
12
+ a dead end.
13
+
14
+ Start every turn with \`bb thread-page init\`. It prints the page path and the
15
+ link. Read an existing page before editing it; saving publishes it at once and
16
+ an open page reloads itself. If init says SKIP, this session is a helper:
17
+ answer in chat and stay off the page.
18
+
19
+ ## Every page ends with a way to answer
20
+
21
+ Any <form> is wired automatically; nothing is required and blank is a real
22
+ answer. Plain semantic HTML is already styled: <fieldset><legend> names a
23
+ group, a wrapping <label> names one control, <small> is a hint, several
24
+ <button name value> give one-click answers.
25
+
26
+ Asking well is most of the work: buttons and radios for decisions, checkboxes
27
+ for multi-select, free text only where the answer is genuinely open. A scale
28
+ needs a meaning at both ends, never a bare 1-to-5. Always leave one open field
29
+ for what you failed to anticipate: a form that permits only the answers you
30
+ expect takes the decision away from the reader.
31
+
32
+ ## What belongs on the page
33
+
34
+ What you did, at the level they could explain to someone else; decisions that
35
+ are theirs, with the options and your recommendation; what only they can
36
+ supply; anything a wrong assumption of yours would make costly. Report
37
+ failures, skipped steps and your own mistakes plainly. Conclusion first.
38
+
39
+ ## One agent, one page
40
+
41
+ Your page is yours alone: you never read or write another agent's page. To
42
+ create another interface — a dashboard, a console, a second view — start a
43
+ session with instructions to build it; that agent writes its own page. Link to
44
+ it, or suggest making it home. A page that should stay put is one whose forms
45
+ start fresh sessions instead of messaging you: nothing then asks you to
46
+ rewrite it. If you want another agent's page changed, talk to that agent.
47
+
48
+ ## The home page
49
+
50
+ One page is home; every other page links back to it in chrome you never
51
+ write. init says whether one exists. When the reader asks for one place to
52
+ see and steer their sessions, build it in a session dedicated to it: run
53
+ \`bb thread-page home\` there and follow \`bb thread-page guide\` §The home page.
54
+
55
+ ## More
56
+
57
+ A page that needs more than prose and a form — files beside it, a chart, live
58
+ session state, starting or steering sessions, links — runs
59
+ \`bb thread-page guide\` first.`;
@@ -0,0 +1,69 @@
1
+ import { escapeHtml } from "../../domain/html/escape.ts";
2
+ import { THEME_CSS } from "./theme-css.ts";
3
+
4
+ /**
5
+ * The document a new page starts from: complete, valid, with a working
6
+ * captured form, its own stylesheet, and a short comment stating the
7
+ * conventions while the agent is already reading the file. spec R6.18–R6.23
8
+ */
9
+ export const DEFAULT_PAGE_SEED = `<!doctype html>
10
+ <html lang="en" data-theme="volume" data-mode="system" data-atmos="on">
11
+ <head>
12
+ <meta charset="utf-8">
13
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
14
+ <title>{{TITLE}}</title>
15
+ <style>${THEME_CSS}</style>
16
+ </head>
17
+ <body>
18
+ <div class="atmosphere" aria-hidden="true"></div>
19
+ <div class="wrap">
20
+
21
+ <header class="brief-head">
22
+ <h1>{{TITLE}}</h1>
23
+ <p class="brief-meta"><span>{{DATE}}</span></p>
24
+ </header>
25
+
26
+ <!--
27
+ Write inside <main>. Plain semantic HTML is already styled: h2, p, ul,
28
+ table, form, fieldset/legend, a wrapping label, small, details. Three
29
+ class names exist: .card boxes an aside, .needs-you flags a block that is
30
+ blocked on the reader, .label is a small uppercase tag.
31
+
32
+ Every <form> answers this session automatically unless it carries
33
+ data-thread-page-manual. Blank answers are valid. A <form method="dialog">
34
+ you only meant as a local confirm still sends a message unless it opts out.
35
+
36
+ Files you put beside this index.html are served relatively: <img
37
+ src="chart.png">, <link href="page.css">, <script src="app.js">, nested
38
+ paths included. Ordinary <a href="https://…"> links work.
39
+
40
+ data-theme: paper | terminal | atrium | volume | bloom.
41
+ data-mode: system | light | dark. data-atmos: on | off.
42
+ Extra CSS goes in one more <style>, everything inside @scope (main),
43
+ colour and shape from var(--token) only.
44
+
45
+ Never use window.prompt, alert, confirm or window.open: the sandbox
46
+ silences them. For anything more — charts, files, live session state,
47
+ starting sessions, links — run: bb thread-page guide
48
+ -->
49
+ <main>
50
+ <p>Replace this with what changed and what you need from the reader.</p>
51
+
52
+ <form data-title="{{TITLE}}">
53
+ <label>Reply
54
+ <textarea name="reply" rows="4"></textarea>
55
+ </label>
56
+ <button name="action" value="Reply">Reply</button>
57
+ </form>
58
+ </main>
59
+
60
+ </div>
61
+ </body>
62
+ </html>
63
+ `;
64
+
65
+ /** Applies the seed's substitutions; both are escaped. spec R6.21 */
66
+ export function renderSeed(template: string, title: string, now: Date = new Date()): string {
67
+ const date = now.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" });
68
+ return template.replaceAll("{{TITLE}}", escapeHtml(title)).replaceAll("{{DATE}}", escapeHtml(date));
69
+ }