@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
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import type { BbPluginApi } from "@get-bb/plugin-sdk";
|
|
2
|
+
import { PageError, PUBLIC_MESSAGES, errorText } from "../domain/errors.ts";
|
|
3
|
+
import type { JsonValue } from "../domain/json/strict-json.ts";
|
|
4
|
+
import type { SessionHost } from "../host/contract.ts";
|
|
5
|
+
import type { ActivityItem, ProjectRecord, ProviderChoice, SessionRecord, StorageLocation } from "../host/types.ts";
|
|
6
|
+
import { joinPath } from "../pages/layout.ts";
|
|
7
|
+
import { activityItemsOf, asRecord, sessionStateOf } from "./activity.ts";
|
|
8
|
+
import { createPublicOrigin } from "./public-origin.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The bb implementation of the host contract. Everything bb-shaped stops
|
|
12
|
+
* here: thread records are projected into session records, list results are
|
|
13
|
+
* reduced to the fields the product needs, and errors become `PageError`s.
|
|
14
|
+
* spec 08
|
|
15
|
+
*/
|
|
16
|
+
export function createBbHost(bb: BbPluginApi): SessionHost {
|
|
17
|
+
const publicOrigin = createPublicOrigin(bb);
|
|
18
|
+
|
|
19
|
+
async function pendingInteraction(threadId: string): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
const listed = (await bb.sdk.threads.interactions.list({ threadId })) as unknown;
|
|
22
|
+
const record = asRecord(listed);
|
|
23
|
+
const interactions = Array.isArray(listed) ? listed : Array.isArray(record?.interactions) ? record.interactions : [];
|
|
24
|
+
return interactions.length > 0;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function projectThread(thread: Record<string, unknown>, hasPendingInteraction: boolean): SessionRecord {
|
|
31
|
+
return {
|
|
32
|
+
id: String(thread.id),
|
|
33
|
+
title: (typeof thread.title === "string" && thread.title) || (typeof thread.titleFallback === "string" && thread.titleFallback) || "Untitled",
|
|
34
|
+
projectId: typeof thread.projectId === "string" ? thread.projectId : null,
|
|
35
|
+
state: sessionStateOf(thread, hasPendingInteraction),
|
|
36
|
+
visibility: thread.visibility === "hidden" ? "hidden" : "visible",
|
|
37
|
+
parentId: typeof thread.parentThreadId === "string" ? thread.parentThreadId : null,
|
|
38
|
+
forkOfId: typeof thread.sourceThreadId === "string" ? thread.sourceThreadId : null,
|
|
39
|
+
archived: thread.archivedAt !== null && thread.archivedAt !== undefined,
|
|
40
|
+
deleted: thread.deletedAt !== null && thread.deletedAt !== undefined,
|
|
41
|
+
updatedAtMs: typeof thread.updatedAt === "number" ? Math.max(0, Math.trunc(thread.updatedAt)) : 0,
|
|
42
|
+
attentionAtMs: typeof thread.latestAttentionAt === "number" ? Math.max(0, Math.trunc(thread.latestAttentionAt)) : 0,
|
|
43
|
+
unread: unreadOf(thread),
|
|
44
|
+
environmentId: typeof thread.environmentId === "string" ? thread.environmentId : null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function getThread(id: string): Promise<Record<string, unknown> | null> {
|
|
49
|
+
try {
|
|
50
|
+
const thread = (await bb.sdk.threads.get({ threadId: id })) as unknown;
|
|
51
|
+
return asRecord(thread);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (isNotFound(error)) return null;
|
|
54
|
+
throw hostUnavailable(error);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const host: SessionHost = {
|
|
59
|
+
sessions: {
|
|
60
|
+
async get(id) {
|
|
61
|
+
const thread = await getThread(id);
|
|
62
|
+
if (!thread) return null;
|
|
63
|
+
const idle = sessionStateOf(thread, false) === "idle";
|
|
64
|
+
return projectThread(thread, idle ? await pendingInteraction(id) : false);
|
|
65
|
+
},
|
|
66
|
+
async list(query) {
|
|
67
|
+
const rows = (await bb.sdk.threads.list({
|
|
68
|
+
...(query.projectId ? { projectId: query.projectId } : {}),
|
|
69
|
+
// bb lists archived and live threads together unless told which; always say.
|
|
70
|
+
archived: query.archived,
|
|
71
|
+
...(query.rootsOnly ? { hasParent: false } : {}),
|
|
72
|
+
limit: query.limit,
|
|
73
|
+
offset: query.offset,
|
|
74
|
+
})) as unknown;
|
|
75
|
+
if (!Array.isArray(rows)) return [];
|
|
76
|
+
return rows
|
|
77
|
+
.map((row) => asRecord(row))
|
|
78
|
+
.filter((row): row is Record<string, unknown> => row !== null)
|
|
79
|
+
.map((row) => projectThread(row, row.hasPendingInteraction === true));
|
|
80
|
+
},
|
|
81
|
+
async send(id, text, mode) {
|
|
82
|
+
const before = await getThread(id);
|
|
83
|
+
const wasWorking = before ? sessionStateOf(before, false) === "working" : false;
|
|
84
|
+
const sent = await bb.sdk.threads.send({
|
|
85
|
+
threadId: id,
|
|
86
|
+
mode: mode === "steer" ? "steer-if-active" : "queue-if-active",
|
|
87
|
+
input: [{ type: "text", text, mentions: [] }],
|
|
88
|
+
});
|
|
89
|
+
if (sent.delivery === "queued") return { delivery: "queued" };
|
|
90
|
+
return { delivery: mode === "steer" && wasWorking ? "steered" : "started" };
|
|
91
|
+
},
|
|
92
|
+
async start(args) {
|
|
93
|
+
const spawned = await bb.sdk.threads.spawn({
|
|
94
|
+
projectId: args.projectId,
|
|
95
|
+
prompt: args.prompt,
|
|
96
|
+
...(args.title ? { title: args.title } : {}),
|
|
97
|
+
...(args.providerId ? { providerId: args.providerId } : {}),
|
|
98
|
+
...(args.model ? { model: args.model } : {}),
|
|
99
|
+
...(args.reasoningLevel ? { reasoningLevel: args.reasoningLevel as never } : {}),
|
|
100
|
+
environment: args.environment.kind === "reuse" ? { type: "reuse", environmentId: args.environment.environmentId } : { type: "project-default" },
|
|
101
|
+
// The reader started this work: a visible root, never a hidden helper.
|
|
102
|
+
visibility: "visible",
|
|
103
|
+
});
|
|
104
|
+
return { id: spawned.id };
|
|
105
|
+
},
|
|
106
|
+
async stop(id) {
|
|
107
|
+
await bb.sdk.threads.stop({ threadId: id });
|
|
108
|
+
},
|
|
109
|
+
async archive(id) {
|
|
110
|
+
await bb.sdk.threads.archive({ threadId: id });
|
|
111
|
+
},
|
|
112
|
+
async markRead(id, read) {
|
|
113
|
+
const after = (read ? await bb.sdk.threads.markRead({ threadId: id }) : await bb.sdk.threads.markUnread({ threadId: id })) as unknown;
|
|
114
|
+
const record = asRecord(after);
|
|
115
|
+
return { unread: record ? unreadOf(record) : !read };
|
|
116
|
+
},
|
|
117
|
+
async activity(id, limit): Promise<ActivityItem[]> {
|
|
118
|
+
const events = (await bb.sdk.threads.events.list({
|
|
119
|
+
threadId: id,
|
|
120
|
+
order: "desc",
|
|
121
|
+
limit: "80",
|
|
122
|
+
types: ["item/started", "item/completed"],
|
|
123
|
+
})) as unknown;
|
|
124
|
+
return activityItemsOf(Array.isArray(events) ? events : [], limit);
|
|
125
|
+
},
|
|
126
|
+
async storage(id): Promise<StorageLocation> {
|
|
127
|
+
try {
|
|
128
|
+
const location = await bb.sdk.threads.storageLocation({ threadId: id });
|
|
129
|
+
return { hostId: location.hostId, rootPath: location.storageRootPath };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (isNotFound(error)) throw new PageError("not_found", "That session is not available", { cause: error });
|
|
132
|
+
throw hostUnavailable(error);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
projects: {
|
|
137
|
+
async list(): Promise<ProjectRecord[]> {
|
|
138
|
+
const projects = (await bb.sdk.projects.list({ includePersonal: true })) as unknown;
|
|
139
|
+
if (!Array.isArray(projects)) return [];
|
|
140
|
+
return projects
|
|
141
|
+
.map((raw) => asRecord(raw))
|
|
142
|
+
.filter((project): project is Record<string, unknown> => project !== null && typeof project.id === "string")
|
|
143
|
+
.map((project) => ({
|
|
144
|
+
id: String(project.id),
|
|
145
|
+
name: typeof project.name === "string" ? project.name : "Untitled",
|
|
146
|
+
kind: project.kind === "personal" ? "personal" : "standard",
|
|
147
|
+
hostId: defaultHostId(project.sources),
|
|
148
|
+
}));
|
|
149
|
+
},
|
|
150
|
+
async browse(hostId) {
|
|
151
|
+
const picked = await bb.sdk.hosts.pickFolder({ hostId, clientHostId: hostId });
|
|
152
|
+
if (!picked.path) return null;
|
|
153
|
+
const hostRecord = await bb.sdk.hosts.get({ hostId }).catch(() => null);
|
|
154
|
+
return { path: picked.path, hostName: hostRecord?.name ?? "this device" };
|
|
155
|
+
},
|
|
156
|
+
async create(args) {
|
|
157
|
+
const created = await bb.sdk.projects.create({ name: args.name, source: { type: "local_path", hostId: args.hostId, path: args.path } });
|
|
158
|
+
return { id: created.id, name: created.name, kind: created.kind === "personal" ? "personal" : "standard", hostId: args.hostId };
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
providers: {
|
|
162
|
+
async list(): Promise<ProviderChoice[]> {
|
|
163
|
+
let providers: unknown;
|
|
164
|
+
try {
|
|
165
|
+
providers = await bb.sdk.providers.list();
|
|
166
|
+
} catch (error) {
|
|
167
|
+
throw new PageError("unavailable", "Providers cannot be listed right now", { cause: error });
|
|
168
|
+
}
|
|
169
|
+
if (!Array.isArray(providers)) throw new PageError("unavailable", "Providers cannot be listed right now");
|
|
170
|
+
const choices = await Promise.all(
|
|
171
|
+
providers
|
|
172
|
+
.map((raw) => asRecord(raw))
|
|
173
|
+
.filter((provider): provider is Record<string, unknown> => provider !== null && typeof provider.id === "string")
|
|
174
|
+
.map(async (provider) => {
|
|
175
|
+
const id = String(provider.id);
|
|
176
|
+
const catalog = (await bb.sdk.providers.models({ providerId: id }).catch(() => null)) as unknown;
|
|
177
|
+
const models = asRecord(catalog)?.models;
|
|
178
|
+
return {
|
|
179
|
+
id,
|
|
180
|
+
displayName: typeof provider.displayName === "string" ? provider.displayName : id,
|
|
181
|
+
available: provider.available !== false,
|
|
182
|
+
models: (Array.isArray(models) ? models : [])
|
|
183
|
+
.map((raw) => asRecord(raw))
|
|
184
|
+
.filter((model): model is Record<string, unknown> => model !== null && typeof model.id === "string")
|
|
185
|
+
.map((model) => ({
|
|
186
|
+
id: String(model.id),
|
|
187
|
+
displayName: typeof model.displayName === "string" ? model.displayName : String(model.id),
|
|
188
|
+
isDefault: model.isDefault === true,
|
|
189
|
+
reasoningLevels: (Array.isArray(model.supportedReasoningEfforts) ? model.supportedReasoningEfforts : [])
|
|
190
|
+
.map((effort) => asRecord(effort)?.reasoningEffort)
|
|
191
|
+
.filter((level): level is string => typeof level === "string"),
|
|
192
|
+
})),
|
|
193
|
+
};
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
return choices;
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
files: {
|
|
200
|
+
async read(location, relativePath) {
|
|
201
|
+
try {
|
|
202
|
+
const file = await bb.sdk.files.read({ hostId: location.hostId, path: joinPath(location.rootPath, relativePath), rootPath: location.rootPath });
|
|
203
|
+
const bytes = file.contentEncoding === "base64" ? Buffer.from(file.content, "base64") : Buffer.from(file.content, "utf8");
|
|
204
|
+
return { bytes, sha256: file.sha256, modifiedAtMs: typeof file.modifiedAtMs === "number" ? file.modifiedAtMs : null };
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (isNotFound(error)) return null;
|
|
207
|
+
throw hostUnavailable(error);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
async write(location, relativePath, bytes, options) {
|
|
211
|
+
try {
|
|
212
|
+
const written = await bb.sdk.files.write({
|
|
213
|
+
hostId: location.hostId,
|
|
214
|
+
path: joinPath(location.rootPath, relativePath),
|
|
215
|
+
rootPath: location.rootPath,
|
|
216
|
+
content: Buffer.from(bytes).toString("base64"),
|
|
217
|
+
contentEncoding: "base64",
|
|
218
|
+
createParents: true,
|
|
219
|
+
...(options.onlyIfAbsent ? { expectedSha256: null } : {}),
|
|
220
|
+
mode: 0o644,
|
|
221
|
+
});
|
|
222
|
+
return written.outcome === "written" ? "written" : "exists";
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (options.onlyIfAbsent && isConflict(error)) return "exists";
|
|
225
|
+
throw hostUnavailable(error);
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
async exist(hostId, absolutePaths) {
|
|
229
|
+
if (absolutePaths.length === 0) return {};
|
|
230
|
+
try {
|
|
231
|
+
const result = await bb.sdk.hosts.pathsExist({ hostId, paths: [...absolutePaths] });
|
|
232
|
+
return Object.fromEntries(absolutePaths.map((path) => [path, result.existence[path] === true]));
|
|
233
|
+
} catch {
|
|
234
|
+
return Object.fromEntries(absolutePaths.map((path) => [path, false]));
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
kv: {
|
|
239
|
+
get: (key) => bb.storage.kv.get<JsonValue>(key),
|
|
240
|
+
set: (key, value) => bb.storage.kv.set(key, value),
|
|
241
|
+
delete: (key) => bb.storage.kv.delete(key),
|
|
242
|
+
},
|
|
243
|
+
origin: { public: publicOrigin },
|
|
244
|
+
log: bb.log,
|
|
245
|
+
};
|
|
246
|
+
return host;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** bb marks a thread unread when it asked for attention after the reader last looked. */
|
|
250
|
+
function unreadOf(thread: Record<string, unknown>): boolean {
|
|
251
|
+
const attention = typeof thread.latestAttentionAt === "number" ? thread.latestAttentionAt : 0;
|
|
252
|
+
const read = typeof thread.lastReadAt === "number" ? thread.lastReadAt : null;
|
|
253
|
+
return attention > 0 && (read === null || read < attention);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function defaultHostId(sources: unknown): string | null {
|
|
257
|
+
if (!Array.isArray(sources)) return null;
|
|
258
|
+
const records = sources.map((raw) => asRecord(raw)).filter((source): source is Record<string, unknown> => source !== null);
|
|
259
|
+
const chosen = records.find((source) => source.isDefault === true) ?? records[0];
|
|
260
|
+
return chosen && typeof chosen.hostId === "string" ? chosen.hostId : null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isNotFound(error: unknown): boolean {
|
|
264
|
+
const record = asRecord(error);
|
|
265
|
+
if (record) {
|
|
266
|
+
if (record.code === "ENOENT" || record.status === 404) return true;
|
|
267
|
+
const body = asRecord(record.body);
|
|
268
|
+
if (body?.code === "ENOENT" || body?.code === "not_found") return true;
|
|
269
|
+
}
|
|
270
|
+
return /\b(enoent|not found|does not exist|no such file)\b/i.test(errorText(error));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isConflict(error: unknown): boolean {
|
|
274
|
+
const record = asRecord(error);
|
|
275
|
+
return record?.status === 409 || /\b(conflict|already exists|exists)\b/i.test(errorText(error));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function hostUnavailable(error: unknown): PageError {
|
|
279
|
+
return PageError.is(error) ? error : new PageError("unavailable", PUBLIC_MESSAGES.unavailable, { cause: error });
|
|
280
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { BbPluginApi } from "@get-bb/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The origin a reader can reach this bb at from anywhere: the Connect
|
|
5
|
+
* plugin's public URL when paired, else the operator-configured app URL,
|
|
6
|
+
* else nothing (local-only). Only a positive answer is cached, so a link is
|
|
7
|
+
* never stale for long after Connect comes up. spec R6.7, R8.15
|
|
8
|
+
*/
|
|
9
|
+
export function createPublicOrigin(bb: BbPluginApi, cacheMs = 30_000): () => Promise<string | null> {
|
|
10
|
+
let cached: { at: number; origin: string } | null = null;
|
|
11
|
+
return async () => {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
if (cached && now - cached.at < cacheMs) return cached.origin;
|
|
14
|
+
const origin = (await connectOrigin(bb)) ?? configuredOrigin(bb);
|
|
15
|
+
cached = origin ? { at: now, origin } : null;
|
|
16
|
+
return origin;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function connectOrigin(bb: BbPluginApi): Promise<string | null> {
|
|
21
|
+
try {
|
|
22
|
+
const status = (await bb.sdk.plugins.callRpc({
|
|
23
|
+
pluginId: "connect",
|
|
24
|
+
method: "status",
|
|
25
|
+
input: null,
|
|
26
|
+
// Connect validates its own output; we read two fields.
|
|
27
|
+
outputSchema: { parse: (value: unknown) => value } as never,
|
|
28
|
+
})) as { state?: unknown; url?: unknown } | null;
|
|
29
|
+
if (status && status.state === "connected" && typeof status.url === "string") {
|
|
30
|
+
return new URL(status.url).origin;
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// Connect absent, disabled or unpaired: a normal local-only answer.
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function configuredOrigin(bb: BbPluginApi): string | null {
|
|
39
|
+
try {
|
|
40
|
+
const url = bb.server.experimental_appUrl;
|
|
41
|
+
return url ? new URL(url).origin : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { BbPluginApi } from "@get-bb/plugin-sdk";
|
|
2
|
+
import { DEFAULT_AGENT_INSTRUCTION } from "../agent/instruction.ts";
|
|
3
|
+
import { DEFAULT_PAGE_SEED } from "../agent/seed/seed.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The five settings and nothing more. spec 07
|
|
7
|
+
*
|
|
8
|
+
* Read live: a change applies to the next request without a restart (R7.5).
|
|
9
|
+
* Neither the seed nor the instruction ever touches an existing page (R7.1).
|
|
10
|
+
*/
|
|
11
|
+
export interface Settings {
|
|
12
|
+
readonly agentInstructions: boolean;
|
|
13
|
+
readonly agentInstructionText: string;
|
|
14
|
+
readonly pageSeedHtml: string;
|
|
15
|
+
readonly workingLabel: string;
|
|
16
|
+
readonly homeSessionId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_WORKING_LABEL = "Working — this is the last saved version";
|
|
20
|
+
|
|
21
|
+
export interface LiveSettings {
|
|
22
|
+
current(): Settings;
|
|
23
|
+
set(values: Partial<{ [K in keyof Settings]: Settings[K] | null }>): Promise<Settings>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function defineSettings(bb: BbPluginApi): Promise<LiveSettings> {
|
|
27
|
+
const handle = bb.settings.define({
|
|
28
|
+
agentInstructions: {
|
|
29
|
+
type: "boolean",
|
|
30
|
+
label: "Agent instructions",
|
|
31
|
+
description: "Inject the standing Thread Pages instruction into every eligible new session.",
|
|
32
|
+
default: false,
|
|
33
|
+
},
|
|
34
|
+
agentInstructionText: {
|
|
35
|
+
type: "string",
|
|
36
|
+
label: "Agent instruction text",
|
|
37
|
+
description: "What eligible new sessions receive when Agent instructions is on. Changing it affects future sessions only.",
|
|
38
|
+
experimental_multiline: true,
|
|
39
|
+
default: DEFAULT_AGENT_INSTRUCTION,
|
|
40
|
+
},
|
|
41
|
+
pageSeedHtml: {
|
|
42
|
+
type: "string",
|
|
43
|
+
label: "New-page seed",
|
|
44
|
+
description: "The complete HTML a new page starts from. {{TITLE}} is replaced, escaped. Existing pages are never rewritten.",
|
|
45
|
+
experimental_multiline: true,
|
|
46
|
+
default: DEFAULT_PAGE_SEED,
|
|
47
|
+
},
|
|
48
|
+
workingLabel: {
|
|
49
|
+
type: "string",
|
|
50
|
+
label: "Working indicator text",
|
|
51
|
+
description: "Shown in the page header while the owning session is mid-turn. Blank hides the indicator.",
|
|
52
|
+
default: DEFAULT_WORKING_LABEL,
|
|
53
|
+
},
|
|
54
|
+
homeSessionId: {
|
|
55
|
+
type: "string",
|
|
56
|
+
label: "Home page session",
|
|
57
|
+
description: "The session whose page is home; every other page links back to it. Set with `bb thread-page home`.",
|
|
58
|
+
default: "",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
let current = normalize(await handle.get());
|
|
62
|
+
handle.onChange((next) => {
|
|
63
|
+
current = normalize(next);
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
current: () => current,
|
|
67
|
+
async set(values) {
|
|
68
|
+
current = normalize(await handle.experimental_set(values));
|
|
69
|
+
return current;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalize(values: { agentInstructions: boolean; agentInstructionText: string; pageSeedHtml: string; workingLabel: string; homeSessionId: string }): Settings {
|
|
75
|
+
return {
|
|
76
|
+
agentInstructions: values.agentInstructions === true,
|
|
77
|
+
agentInstructionText: values.agentInstructionText,
|
|
78
|
+
pageSeedHtml: values.pageSeedHtml,
|
|
79
|
+
workingLabel: values.workingLabel.trim(),
|
|
80
|
+
homeSessionId: values.homeSessionId.trim(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { JsonValue, Validation } from "../json/strict-json.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What every capability declares. spec R5.1
|
|
5
|
+
*
|
|
6
|
+
* A spec is pure: it names the capability, classes its effect, and validates
|
|
7
|
+
* its parameters and its result. Execution and confirmation wording live in
|
|
8
|
+
* the server-side handler, which is the only place with host access.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* `reader-state` changes only what the reader sees about a session — its
|
|
12
|
+
* read mark — never the session's work; it is not confirmed. spec R5.7a
|
|
13
|
+
*/
|
|
14
|
+
export const EFFECT_CLASSES = ["read", "own-session-write", "cross-session-write", "destructive", "navigation", "device", "reader-state"] as const;
|
|
15
|
+
export type EffectClass = (typeof EFFECT_CLASSES)[number];
|
|
16
|
+
|
|
17
|
+
/** Effects that must be confirmed in trusted chrome. spec R5.7 */
|
|
18
|
+
export const CONFIRMED_EFFECTS: ReadonlySet<EffectClass> = new Set(["cross-session-write", "destructive", "device"]);
|
|
19
|
+
|
|
20
|
+
export interface CapabilityDoc {
|
|
21
|
+
/** One paragraph on the parameters, for the guide. */
|
|
22
|
+
readonly params: string;
|
|
23
|
+
/** One paragraph on the result, for the guide. */
|
|
24
|
+
readonly result: string;
|
|
25
|
+
/** Anything an author must know: refusals, defaults, confirmation wording. */
|
|
26
|
+
readonly notes?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CapabilitySpec<Params = unknown, Result = unknown> {
|
|
30
|
+
readonly method: string;
|
|
31
|
+
readonly description: string;
|
|
32
|
+
readonly effect: EffectClass;
|
|
33
|
+
readonly confirmed: boolean;
|
|
34
|
+
/** `false` means the contract exists but the host does not implement it: `unknown_method`. spec R5.6 */
|
|
35
|
+
readonly implemented: boolean;
|
|
36
|
+
readonly validateParams: (value: JsonValue | undefined) => Validation<Params>;
|
|
37
|
+
readonly validateResult: (value: unknown) => Validation<Result>;
|
|
38
|
+
readonly doc: CapabilityDoc;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type AnyCapabilitySpec = CapabilitySpec<any, any>;
|
|
42
|
+
|
|
43
|
+
/** What `context.get` reports about one capability. spec R5.9 */
|
|
44
|
+
export interface CapabilityDescriptor {
|
|
45
|
+
readonly method: string;
|
|
46
|
+
readonly effect: EffectClass;
|
|
47
|
+
readonly confirmation: "none" | "required";
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createRegistry } from "./registry.ts";
|
|
2
|
+
import { ALL_CAPABILITIES } from "./specs.ts";
|
|
3
|
+
|
|
4
|
+
export * from "./contract.ts";
|
|
5
|
+
export * from "./protocol.ts";
|
|
6
|
+
export * from "./registry.ts";
|
|
7
|
+
export * from "./specs.ts";
|
|
8
|
+
|
|
9
|
+
/** The registry the host serves: every spec, with `implemented` deciding the roster. */
|
|
10
|
+
export const capabilityRegistry = createRegistry(ALL_CAPABILITIES);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { boundedMessage, isBridgeErrorCode, PageError, type BridgeErrorCode } from "../errors.ts";
|
|
2
|
+
import { isMethodName, isRequestId, isRevision } from "../ids.ts";
|
|
3
|
+
import { isJsonObject, validateJson, type JsonValue } from "../json/strict-json.ts";
|
|
4
|
+
import { LIMITS } from "../limits.ts";
|
|
5
|
+
import type { CapabilityRegistry } from "./registry.ts";
|
|
6
|
+
import type { AnyCapabilitySpec } from "./contract.ts";
|
|
7
|
+
import { unknownMethodMessage } from "./renamed.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The bridge protocol: what a page sends over the port and what it gets back.
|
|
11
|
+
* Version 1. spec R3.9, R4.28, R5.38
|
|
12
|
+
*/
|
|
13
|
+
export const BRIDGE_PROTOCOL_VERSION = 1 as const;
|
|
14
|
+
|
|
15
|
+
export interface BridgeRequest {
|
|
16
|
+
readonly v: 1;
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly method: string;
|
|
19
|
+
readonly params: JsonValue;
|
|
20
|
+
readonly pageRevision: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BridgeSuccess {
|
|
24
|
+
readonly v: 1;
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly ok: true;
|
|
27
|
+
readonly result: JsonValue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BridgeFailure {
|
|
31
|
+
readonly v: 1;
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly ok: false;
|
|
34
|
+
readonly error: { readonly code: BridgeErrorCode; readonly message: string };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type BridgeResponse = BridgeSuccess | BridgeFailure;
|
|
38
|
+
|
|
39
|
+
/** What the shell receives from `POST /bridge`. */
|
|
40
|
+
export type BridgeTransport =
|
|
41
|
+
| { readonly response: BridgeResponse; readonly navigate?: NavigationDirective }
|
|
42
|
+
| { readonly confirm: { readonly requestId: string; readonly summary: string; readonly challenge: string } };
|
|
43
|
+
|
|
44
|
+
/** A host-validated destination the trusted shell navigates to. spec R5.29–R5.34 */
|
|
45
|
+
export type NavigationDirective =
|
|
46
|
+
| { readonly kind: "page"; readonly url: string }
|
|
47
|
+
| { readonly kind: "host"; readonly url: string }
|
|
48
|
+
| { readonly kind: "external"; readonly url: string };
|
|
49
|
+
|
|
50
|
+
export function decodeBridgeRequest(input: unknown): BridgeRequest {
|
|
51
|
+
const checked = validateJson(input);
|
|
52
|
+
if (!checked.ok) {
|
|
53
|
+
const tooLarge = checked.issues.some((issue) => issue.code === "too_large");
|
|
54
|
+
throw new PageError(tooLarge ? "request_too_large" : "invalid_request", tooLarge ? "Bridge request is too large" : "Bridge request is not strict JSON");
|
|
55
|
+
}
|
|
56
|
+
const value = checked.value;
|
|
57
|
+
if (!isJsonObject(value)) throw new PageError("invalid_request", "Bridge request must be an object");
|
|
58
|
+
const keys = Object.keys(value).sort().join(",");
|
|
59
|
+
if (keys !== "id,method,pageRevision,params,v") throw new PageError("invalid_request", "Bridge request has the wrong shape");
|
|
60
|
+
if (value.v !== BRIDGE_PROTOCOL_VERSION) throw new PageError("unsupported_version", "Unsupported bridge protocol version");
|
|
61
|
+
if (!isRequestId(value.id)) throw new PageError("invalid_request", "Invalid request id");
|
|
62
|
+
if (!isMethodName(value.method)) throw new PageError("invalid_request", "Invalid method name");
|
|
63
|
+
if (!isRevision(value.pageRevision)) throw new PageError("invalid_request", "Invalid page revision");
|
|
64
|
+
return { v: 1, id: value.id, method: value.method, params: value.params as JsonValue, pageRevision: value.pageRevision };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function safeRequestId(value: unknown): string {
|
|
68
|
+
return isRequestId(value) ? value : "invalid";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function failure(id: unknown, code: BridgeErrorCode, message: string): BridgeFailure {
|
|
72
|
+
return { v: 1, id: safeRequestId(id), ok: false, error: { code, message: boundedMessage(message) } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function failureFromError(id: unknown, error: unknown): BridgeFailure {
|
|
76
|
+
if (PageError.is(error) && isBridgeErrorCode(error.code)) return failure(id, error.code, error.message);
|
|
77
|
+
return failure(id, "handler_error", "Could not execute the page action.");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ResolvedInvocation<Params = unknown> {
|
|
81
|
+
readonly request: BridgeRequest;
|
|
82
|
+
readonly spec: AnyCapabilitySpec;
|
|
83
|
+
readonly params: Params;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Looks a request up in the registry and validates its parameters. Stale
|
|
88
|
+
* revisions are refused before the method is even looked at. spec R2.13
|
|
89
|
+
*/
|
|
90
|
+
export function resolveInvocation(request: BridgeRequest, registry: CapabilityRegistry, currentRevision: string): ResolvedInvocation {
|
|
91
|
+
if (request.pageRevision !== currentRevision) throw new PageError("stale_page", "This page changed; reload it before responding.");
|
|
92
|
+
const spec = registry.get(request.method);
|
|
93
|
+
if (!spec || !spec.implemented) throw new PageError("unknown_method", unknownMethodMessage(request.method));
|
|
94
|
+
const params = spec.validateParams(request.params);
|
|
95
|
+
if (!params.ok) {
|
|
96
|
+
const first = params.issues[0];
|
|
97
|
+
throw new PageError("invalid_params", `Invalid parameters for ${spec.method}${first ? ` at ${first.path}: ${first.message}` : ""}`);
|
|
98
|
+
}
|
|
99
|
+
return { request, spec, params: params.value };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Projects a handler result through the spec's validator into a response. spec R5.3 */
|
|
103
|
+
export function completeInvocation(invocation: ResolvedInvocation, result: unknown): BridgeResponse {
|
|
104
|
+
const projected = invocation.spec.validateResult(result);
|
|
105
|
+
if (!projected.ok) return failure(invocation.request.id, "invalid_result", `Invalid result for ${invocation.spec.method}`);
|
|
106
|
+
const json = validateJson(projected.value);
|
|
107
|
+
if (!json.ok) return failure(invocation.request.id, "invalid_result", `Result for ${invocation.spec.method} is not strict JSON`);
|
|
108
|
+
const response: BridgeSuccess = { v: 1, id: invocation.request.id, ok: true, result: json.value };
|
|
109
|
+
if (Buffer.byteLength(JSON.stringify(response), "utf8") > LIMITS.capabilityPayloadBytes) {
|
|
110
|
+
return failure(invocation.request.id, "response_too_large", "Bridge response is too large");
|
|
111
|
+
}
|
|
112
|
+
return response;
|
|
113
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isMethodName } from "../ids.ts";
|
|
2
|
+
import { CONFIRMED_EFFECTS, EFFECT_CLASSES, type AnyCapabilitySpec, type CapabilityDescriptor } from "./contract.ts";
|
|
3
|
+
|
|
4
|
+
export interface CapabilityRegistry {
|
|
5
|
+
get(method: string): AnyCapabilitySpec | undefined;
|
|
6
|
+
list(): readonly AnyCapabilitySpec[];
|
|
7
|
+
/** The roster a page may discover: implemented capabilities only. spec R5.9 */
|
|
8
|
+
descriptors(): readonly CapabilityDescriptor[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a registry and enforces the invariants no capability may break:
|
|
13
|
+
* unique, well-formed names; a known effect class; confirmation exactly where
|
|
14
|
+
* the effect class demands it (navigation may go either way). spec R5.7, R5.8
|
|
15
|
+
*/
|
|
16
|
+
export function createRegistry(specs: readonly AnyCapabilitySpec[]): CapabilityRegistry {
|
|
17
|
+
const byMethod = new Map<string, AnyCapabilitySpec>();
|
|
18
|
+
for (const spec of specs) {
|
|
19
|
+
if (!isMethodName(spec.method)) throw new TypeError(`Invalid capability name: ${spec.method}`);
|
|
20
|
+
if (byMethod.has(spec.method)) throw new TypeError(`Duplicate capability: ${spec.method}`);
|
|
21
|
+
if (!EFFECT_CLASSES.includes(spec.effect)) throw new TypeError(`Invalid effect for ${spec.method}`);
|
|
22
|
+
if (CONFIRMED_EFFECTS.has(spec.effect) && !spec.confirmed) {
|
|
23
|
+
throw new TypeError(`${spec.method} has a ${spec.effect} effect and must be confirmed`);
|
|
24
|
+
}
|
|
25
|
+
if ((spec.effect === "read" || spec.effect === "own-session-write" || spec.effect === "reader-state") && spec.confirmed) {
|
|
26
|
+
throw new TypeError(`${spec.method} is a ${spec.effect} and must not be confirmed`);
|
|
27
|
+
}
|
|
28
|
+
if (typeof spec.description !== "string" || spec.description.trim().length === 0 || spec.description.length > 240) {
|
|
29
|
+
throw new TypeError(`Invalid description for ${spec.method}`);
|
|
30
|
+
}
|
|
31
|
+
byMethod.set(spec.method, Object.freeze({ ...spec }));
|
|
32
|
+
}
|
|
33
|
+
const list = Object.freeze([...byMethod.values()]);
|
|
34
|
+
const descriptors = Object.freeze(
|
|
35
|
+
list
|
|
36
|
+
.filter((spec) => spec.implemented)
|
|
37
|
+
.map((spec): CapabilityDescriptor => ({
|
|
38
|
+
method: spec.method,
|
|
39
|
+
effect: spec.effect,
|
|
40
|
+
confirmation: spec.confirmed ? "required" : "none",
|
|
41
|
+
})),
|
|
42
|
+
);
|
|
43
|
+
return Object.freeze({
|
|
44
|
+
get: (method: string) => byMethod.get(method),
|
|
45
|
+
list: () => list,
|
|
46
|
+
descriptors: () => descriptors,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names the 0.3.x API answered for, when a page still calls it. spec R5.38–R5.41
|
|
3
|
+
*
|
|
4
|
+
* 1.0.0 renamed the whole page-facing surface with no migration path, and the
|
|
5
|
+
* only signal a page got back was `unknown_method: threads.spawn` — which
|
|
6
|
+
* reads as "this host cannot do that" rather than "this is called something
|
|
7
|
+
* else now". Naming the replacement turns a debugging session into a one-line
|
|
8
|
+
* fix. There are no aliases and there will be none: the old name still fails.
|
|
9
|
+
*/
|
|
10
|
+
export const RENAMED_METHODS: Readonly<Record<string, string>> = Object.freeze({
|
|
11
|
+
"threads.spawn": "sessions.start",
|
|
12
|
+
"threads.snapshot": "sessions.snapshot",
|
|
13
|
+
"threads.send": "sessions.send",
|
|
14
|
+
"threads.stop": "sessions.stop",
|
|
15
|
+
"threads.archive": "sessions.archive",
|
|
16
|
+
"threads.activity": "session.activity",
|
|
17
|
+
"threads.reply": "session.reply",
|
|
18
|
+
"threads.openPage": "pages.open",
|
|
19
|
+
"threads.openBb": "sessions.openHost",
|
|
20
|
+
"thread.get": "context.get",
|
|
21
|
+
"thread.reply": "session.reply",
|
|
22
|
+
"thread.activity": "session.activity",
|
|
23
|
+
"page.storage.get": "storage.get",
|
|
24
|
+
"page.storage.set": "storage.set",
|
|
25
|
+
"navigation.open": "navigation.openExternal",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** The `unknown_method` message for a method, naming its replacement when there is one. */
|
|
29
|
+
export function unknownMethodMessage(method: string): string {
|
|
30
|
+
const replacement = RENAMED_METHODS[method];
|
|
31
|
+
return replacement
|
|
32
|
+
? `Unknown capability: ${method} (renamed to ${replacement} in 1.0; there is no alias)`
|
|
33
|
+
: `Unknown capability: ${method}`;
|
|
34
|
+
}
|