@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
@@ -1,15 +1,8 @@
1
1
  /**
2
- * The Thread Pages design system.
3
- *
4
- * Five worlds, each declaring both palettes at once as --l-* and --d-*, with one
5
- * resolver publishing the live half onto the tokens the rest of the sheet uses.
6
- * A page picks one with data-theme on <html> and needs no class names: every
7
- * rule keys off semantic structure (fieldset/legend, a wrapping label, small,
8
- * button), so plain HTML is already styled.
9
- *
10
- * This is carried into each new page by the seed rather than injected at render
11
- * time, so a page owns its own look and a plugin update never restyles work the
12
- * user already read.
2
+ * The design system carried by the default seed: five worlds, each with a
3
+ * light and a dark palette, keyed off semantic HTML. Reviewed from the
4
+ * prototype, not redesigned (RW-16). A page owns its copy and may change
5
+ * any rule. spec R4.36, R4.37
13
6
  */
14
7
  export const THEME_CSS = String.raw`
15
8
  :root {
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The starter home page: the `<main>` an agent drops into its page to get a
3
+ * working session hub. It is the guide's worked example (§The home page) and
4
+ * the shape a person gets when they ask for a home page.
5
+ *
6
+ * Design: dense and attention-first. A sticky bar with search and a view
7
+ * toggle; projects that need the reader first; per project the sessions that
8
+ * need attention plus the five most recent, with "Show 10 more" — the same
9
+ * rule the host's own sidebar uses; one line per session with the state, the
10
+ * title (opens the page, or the session in bb when there is no page), how
11
+ * long ago, and the actions that matter: bb, Stop, Archive.
12
+ */
13
+ export const STARTER_HUB_MAIN = String.raw`<div class="hub-bar">
14
+ <input type="search" data-search placeholder="Find a session… (press /)" aria-label="Find a session" autocomplete="off">
15
+ <span class="hub-views" role="group" aria-label="View">
16
+ <button type="button" data-view="attention" aria-pressed="true">Needs you</button>
17
+ <button type="button" data-view="all" aria-pressed="false">All</button>
18
+ </span>
19
+ <button type="button" data-refresh title="Refresh">↻</button>
20
+ <span class="hub-meta" data-meta></span>
21
+ </div>
22
+ <p data-error class="needs-you" hidden></p>
23
+ <div data-groups aria-live="polite"></div>
24
+ <p class="hub-foot">Rows follow bb: no archived sessions, sub-agents hidden, five per project then “more”. This page belongs to the session that built it; ask that session to change it.</p>
25
+
26
+ <style>
27
+ /* The hub is a list, so it takes the width the seed's prose does not need. */
28
+ .wrap { max-width: 68rem; }
29
+ @scope (main) {
30
+ .hub-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; padding: .6rem 0; background: var(--bg); border-bottom: var(--rule-w) solid var(--rule); }
31
+ .hub-bar input[type="search"] { flex: 1 1 16rem; min-width: 0; margin: 0; padding: .45rem .6rem; font: inherit; font-size: .92rem; color: var(--ink); background: var(--surface); border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * .7); }
32
+ .hub-views { display: inline-flex; border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * .7); overflow: hidden; }
33
+ .hub-views button, .hub-bar > button { margin: 0; padding: .4rem .7rem; font: inherit; font-size: .85rem; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 0; border-radius: 0; box-shadow: none; cursor: pointer; }
34
+ .hub-views button[aria-pressed="true"] { color: var(--bg); background: var(--accent); }
35
+ .hub-bar > button[data-refresh] { border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * .7); }
36
+ .hub-meta { margin-left: auto; font-size: .78rem; color: var(--ink-3); font-variant-numeric: tabular-nums; }
37
+ .group { margin-top: 1rem; }
38
+ .group-head { display: flex; align-items: baseline; gap: .6rem; padding: .3rem 0; border-bottom: var(--rule-w) solid var(--rule-soft); }
39
+ .group-head h2 { margin: 0; font-size: .95rem; cursor: pointer; }
40
+ .group-head .counts { font-size: .75rem; color: var(--ink-3); font-variant-numeric: tabular-nums; }
41
+ .group-head .counts b { color: var(--ok); font-weight: 600; }
42
+ .group-head .counts i { color: var(--accent); font-style: normal; font-weight: 600; }
43
+ .group-head .counts s { color: var(--flag); text-decoration: none; font-weight: 600; }
44
+ .group-head button { margin: 0 0 0 auto; padding: .15rem .55rem; font: inherit; font-size: .78rem; font-weight: 600; color: var(--accent); background: transparent; border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * .6); box-shadow: none; cursor: pointer; }
45
+ .row { display: grid; grid-template-columns: .6rem minmax(0, 1fr) auto auto; gap: .6rem; align-items: center; min-height: 2.1rem; padding: .1rem 0; border-bottom: var(--rule-w) solid var(--rule-soft); }
46
+ .row .dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--rule); }
47
+ .row[data-status="working"] .dot { background: var(--ok); }
48
+ .row[data-status="waiting"] .dot { background: var(--flag); }
49
+ .row[data-status="failed"] .dot { background: var(--flag); box-shadow: 0 0 0 2px color-mix(in srgb, var(--flag) 35%, transparent); }
50
+ .row[data-unread="true"] .dot { outline: 2px solid var(--accent); outline-offset: 1px; }
51
+ @media (prefers-reduced-motion: no-preference) { .row[data-status="working"] .dot { animation: hub-pulse 1.4s ease-in-out infinite; } }
52
+ @keyframes hub-pulse { 0%, 100% { opacity: 1 } 50% { opacity: .3 } }
53
+ .row .title { margin: 0; padding: .2rem 0; font: inherit; font-size: .92rem; text-align: left; color: var(--ink); background: transparent; border: 0; border-radius: 0; box-shadow: none; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
54
+ .row[data-unread="true"] .title { font-weight: 700; }
55
+ .row .title:hover, .row .title:focus-visible { color: var(--accent); }
56
+ .row .when { font-size: .75rem; color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap; }
57
+ .row .when em { font-style: normal; color: var(--flag); }
58
+ .row .acts { display: flex; gap: .3rem; }
59
+ .row .acts button { margin: 0; padding: .1rem .5rem; font: inherit; font-size: .75rem; font-weight: 600; color: var(--ink-2); background: transparent; border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * .6); box-shadow: none; cursor: pointer; }
60
+ .row .acts button:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); }
61
+ .row .acts button[data-danger]:hover:not(:disabled) { color: var(--flag); border-color: var(--flag); }
62
+ .more { margin: .3rem 0 0; padding: .2rem 0; font: inherit; font-size: .78rem; color: var(--accent); background: transparent; border: 0; box-shadow: none; cursor: pointer; }
63
+ .starter { margin: .5rem 0 0; display: grid; gap: .4rem; }
64
+ .starter textarea { width: 100%; min-height: 3.2rem; margin: 0; padding: .5rem .65rem; font: inherit; font-size: .88rem; line-height: 1.5; color: var(--ink); background: var(--surface); border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * .7); resize: vertical; }
65
+ .starter textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
66
+ .starter button { justify-self: start; margin: 0; padding: .4rem .9rem; font: inherit; font-size: .85rem; font-weight: 600; color: var(--bg); background: var(--accent); border: var(--rule-w) solid var(--accent); border-radius: calc(var(--radius) * .7); box-shadow: none; cursor: pointer; }
67
+ .starter button:disabled { opacity: .6; cursor: default; }
68
+ .starter .say { font-size: .78rem; color: var(--ink-3); }
69
+ .empty { margin: .6rem 0; font-size: .85rem; color: var(--ink-3); }
70
+ .hub-foot { margin-top: 1.5rem; font-size: .75rem; color: var(--ink-3); }
71
+ @media (max-width: 40rem) { .row { grid-template-columns: .6rem minmax(0, 1fr) auto; } .row .when { display: none; } }
72
+ }
73
+ </style>
74
+
75
+ <script>
76
+ (async () => {
77
+ const tp = window.threadPage;
78
+ const $ = (s) => document.querySelector(s);
79
+ const groupsEl = $("[data-groups]"), errorEl = $("[data-error]"), metaEl = $("[data-meta]"), searchEl = $("[data-search]");
80
+ const PER_PROJECT = 5, MORE = 10;
81
+ const el = (tag, text, cls) => { const n = document.createElement(tag); if (text !== undefined) n.textContent = text; if (cls) n.className = cls; return n; };
82
+ const fail = (m) => { errorEl.hidden = false; errorEl.textContent = m; };
83
+ // Needs you: running, waiting on you, or unread — a failed session only until you have looked at it.
84
+ const needsYou = (s) => s.status === "working" || s.status === "waiting" || s.unread;
85
+ const recency = (s) => Math.max(s.attentionAtMs || 0, s.updatedAtMs || 0);
86
+ const ago = (ms) => { const d = Date.now() - ms; if (d < 60e3) return "now"; if (d < 3600e3) return Math.round(d / 60e3) + "m"; if (d < 86400e3) return Math.round(d / 3600e3) + "h"; return Math.round(d / 86400e3) + "d"; };
87
+
88
+ let projects = [], sessions = [], prefs = { view: "attention", collapsed: {} };
89
+ const expanded = {}, starters = new Set();
90
+
91
+ async function loadPrefs() {
92
+ try { const r = await tp.invoke("storage.get", { key: "home.prefs" }); if (r.found && r.value && typeof r.value === "object") prefs = { view: "attention", collapsed: {}, ...r.value }; } catch {}
93
+ }
94
+ const savePrefs = () => tp.invoke("storage.set", { key: "home.prefs", value: prefs }).catch(() => {});
95
+
96
+ async function fetchAll() {
97
+ const out = []; let cursor = null;
98
+ for (let page = 0; page < 5; page += 1) {
99
+ const r = await tp.invoke("sessions.snapshot", cursor ? { limit: 200, cursor } : { limit: 200 });
100
+ out.push(...r.sessions); cursor = r.nextCursor; if (!cursor) break;
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function act(label, run, danger) {
106
+ const b = el("button", label); b.type = "button"; if (danger) b.dataset.danger = "";
107
+ b.addEventListener("click", async (e) => { e.stopPropagation(); b.disabled = true;
108
+ try { await run(); } catch (err) { if (err.code !== "cancelled") fail((err.code ? err.code + ": " : "") + err.message); }
109
+ finally { b.disabled = false; } });
110
+ return b;
111
+ }
112
+
113
+ function row(s) {
114
+ const r = el("div", undefined, "row"); r.dataset.status = s.status; r.dataset.unread = String(!!s.unread);
115
+ const title = el("button", s.title || "(untitled)", "title"); title.type = "button";
116
+ title.title = s.page.available ? "Open its page" : "No page yet — opens the session in bb";
117
+ title.addEventListener("click", () => tp.invoke(s.page.available ? "pages.open" : "sessions.openHost", { sessionId: s.id }).catch((e) => fail(e.message)));
118
+ const when = el("span", undefined, "when");
119
+ if (s.status === "failed") when.append(el("em", "failed · ")); else if (s.status === "waiting") when.append(el("em", "needs you · ")); else if (s.status === "working") when.append(el("em", "working · "));
120
+ when.append(document.createTextNode(ago(recency(s))));
121
+ const acts = el("span", undefined, "acts");
122
+ if (s.page.available) acts.append(act("bb", () => tp.invoke("sessions.openHost", { sessionId: s.id })));
123
+ if (s.status === "working") acts.append(act("Stop", async () => { await tp.invoke("sessions.stop", { sessionId: s.id }); await load(); }, true));
124
+ acts.append(act(s.unread ? "Read" : "Unread", async () => { const r = await tp.invoke("sessions.markRead", { sessionId: s.id, read: !!s.unread }); s.unread = r.unread; render(); }));
125
+ acts.append(act("Archive", async () => { await tp.invoke("sessions.archive", { sessionId: s.id }); sessions = sessions.filter((x) => x.id !== s.id); render(); }, true));
126
+ r.append(el("span", undefined, "dot"), title, when, acts);
127
+ return r;
128
+ }
129
+
130
+ function starter(project) {
131
+ const box = el("div", undefined, "starter");
132
+ const text = el("textarea"); text.placeholder = "What should the new session in " + project.name + " do? Say what to report and what not to change."; text.setAttribute("aria-label", text.placeholder);
133
+ const say = el("span", undefined, "say");
134
+ const go = act("Start session", async () => {
135
+ const prompt = text.value.trim(); if (!prompt) { say.textContent = "Say what it should do."; return; }
136
+ say.textContent = "Waiting for your confirmation…";
137
+ try { await tp.invoke("sessions.start", { projectId: project.id, prompt }); say.textContent = "Started."; text.value = ""; starters.delete(project.id); await load(); }
138
+ catch (e) { say.textContent = e.code === "cancelled" ? "Nothing started." : e.message; }
139
+ });
140
+ box.append(text, go, say);
141
+ return box;
142
+ }
143
+
144
+ function render() {
145
+ const q = searchEl.value.trim().toLowerCase();
146
+ const view = q ? "all" : prefs.view;
147
+ groupsEl.textContent = "";
148
+ let shown = 0, attention = 0;
149
+ const byProject = new Map(projects.map((p) => [p.id, []]));
150
+ for (const s of sessions) { if (byProject.has(s.projectId)) byProject.get(s.projectId).push(s); }
151
+ const order = projects.slice().sort((a, b) => {
152
+ const A = byProject.get(a.id), B = byProject.get(b.id);
153
+ const na = A.filter(needsYou).length, nb = B.filter(needsYou).length;
154
+ if ((na > 0) !== (nb > 0)) return na > 0 ? -1 : 1;
155
+ return Math.max(0, ...B.map(recency)) - Math.max(0, ...A.map(recency));
156
+ });
157
+ for (const project of order) {
158
+ const all = byProject.get(project.id).sort((a, b) => recency(b) - recency(a));
159
+ attention += all.filter(needsYou).length;
160
+ let list = q ? all.filter((s) => (s.title || "").toLowerCase().includes(q) || project.name.toLowerCase().includes(q)) : view === "attention" ? all.filter(needsYou) : all;
161
+ if (view === "attention" && !q && list.length === 0) continue;
162
+ const group = el("section", undefined, "group");
163
+ const head = el("div", undefined, "group-head");
164
+ const h = el("h2", project.name); h.title = "Collapse or expand";
165
+ h.addEventListener("click", () => { prefs.collapsed[project.id] = !prefs.collapsed[project.id]; savePrefs(); render(); });
166
+ const counts = el("span", undefined, "counts");
167
+ const w = all.filter((s) => s.status === "working").length, u = all.filter((s) => s.unread).length, f = all.filter((s) => s.status === "waiting" || (s.status === "failed" && s.unread)).length;
168
+ counts.append(document.createTextNode(all.length + " "));
169
+ if (w) counts.append(el("b", w + " working ")); if (u) counts.append(el("i", u + " unread ")); if (f) counts.append(el("s", f + " need you"));
170
+ const add = act("+ New", async () => { if (starters.has(project.id)) starters.delete(project.id); else starters.add(project.id); render(); });
171
+ head.append(h, counts, add);
172
+ group.append(head);
173
+ if (!prefs.collapsed[project.id]) {
174
+ let visible = list;
175
+ if (view === "all" && !q) { const limit = PER_PROJECT + (expanded[project.id] || 0); visible = list.filter((s, i) => i < limit || needsYou(s)); }
176
+ for (const s of visible) group.append(row(s));
177
+ if (visible.length < list.length) { const more = el("button", "Show " + Math.min(MORE, list.length - visible.length) + " more of " + list.length, "more"); more.type = "button"; more.addEventListener("click", () => { expanded[project.id] = (expanded[project.id] || 0) + MORE; render(); }); group.append(more); }
178
+ if (list.length === 0) group.append(el("p", q ? "No match." : "Nothing here.", "empty"));
179
+ shown += visible.length;
180
+ }
181
+ if (starters.has(project.id)) group.append(starter(project));
182
+ groupsEl.append(group);
183
+ }
184
+ if (!groupsEl.children.length) groupsEl.append(el("p", view === "attention" ? "Nothing needs you right now." : "No sessions.", "empty"));
185
+ for (const b of document.querySelectorAll("[data-view]")) b.setAttribute("aria-pressed", String(b.dataset.view === prefs.view));
186
+ metaEl.textContent = shown + " shown · " + attention + " need you · " + sessions.length + " total";
187
+ }
188
+
189
+ async function load() {
190
+ try {
191
+ errorEl.hidden = true;
192
+ const [p, s] = await Promise.all([tp.invoke("projects.list"), fetchAll()]);
193
+ projects = p.projects; sessions = s; render();
194
+ } catch (e) { fail((e.code ? e.code + ": " : "") + e.message); }
195
+ }
196
+
197
+ for (const b of document.querySelectorAll("[data-view]")) b.addEventListener("click", () => { prefs.view = b.dataset.view; savePrefs(); render(); });
198
+ $("[data-refresh]").addEventListener("click", () => load());
199
+ searchEl.addEventListener("input", render);
200
+ document.addEventListener("keydown", (e) => {
201
+ if (e.key === "/" && document.activeElement !== searchEl && !/^(INPUT|TEXTAREA)$/.test(document.activeElement?.tagName)) { e.preventDefault(); searchEl.focus(); }
202
+ if (e.key === "Escape" && document.activeElement === searchEl) { searchEl.value = ""; render(); searchEl.blur(); }
203
+ });
204
+ document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") load(); });
205
+
206
+ await loadPrefs();
207
+ await load();
208
+ tp.watch("session.activity", { limit: 1 }, () => load(), { intervalMs: 15000 });
209
+ })();
210
+ </script>`;
211
+
212
+ /** The starter hub indented for a Markdown code block. */
213
+ export function starterHubForGuide(): string {
214
+ return STARTER_HUB_MAIN.split("\n")
215
+ .map((line) => (line.length ? ` ${line}` : ""))
216
+ .join("\n");
217
+ }
@@ -0,0 +1,59 @@
1
+ import type { SessionState } from "../domain/capabilities/specs.ts";
2
+ import type { ActivityItem } from "../host/types.ts";
3
+
4
+ /**
5
+ * bb's thread vocabulary → the product's session state. RW-6
6
+ *
7
+ * active, starting, provisioning, stopping → working
8
+ * error → failed
9
+ * a pending interaction → waiting
10
+ * everything else → idle
11
+ */
12
+ export function sessionStateOf(thread: { status?: unknown; runtime?: unknown }, hasPendingInteraction: boolean): SessionState {
13
+ const runtime = asRecord(thread.runtime);
14
+ const display = typeof runtime?.displayStatus === "string" ? runtime.displayStatus : typeof thread.status === "string" ? thread.status : "idle";
15
+ if (["active", "starting", "provisioning", "stopping"].includes(display)) return "working";
16
+ if (display === "error") return "failed";
17
+ if (hasPendingInteraction) return "waiting";
18
+ return "idle";
19
+ }
20
+
21
+ const FALLBACK_LABELS: Record<string, readonly [string, string]> = {
22
+ agentMessage: ["Writing", "Wrote"],
23
+ reasoning: ["Thinking", "Thought"],
24
+ };
25
+
26
+ /** Thread events (`item/started`, `item/completed`) → presented activity, newest last. */
27
+ export function activityItemsOf(events: readonly unknown[], limit: number): ActivityItem[] {
28
+ const out: ActivityItem[] = [];
29
+ for (const raw of events) {
30
+ const event = asRecord(raw);
31
+ if (!event) continue;
32
+ if (event.type !== "item/started" && event.type !== "item/completed") continue;
33
+ const done = event.type === "item/completed";
34
+ const data = asRecord(event.data);
35
+ const item = asRecord(data?.item) ?? data;
36
+ if (!item || typeof item.type !== "string") continue;
37
+ const presentation = asRecord(item.presentation);
38
+ const labels = asRecord(presentation?.label);
39
+ const presented = labels?.[done ? "completed" : "pending"];
40
+ const fallback = FALLBACK_LABELS[item.type];
41
+ const label = typeof presented === "string" ? presented : fallback ? fallback[done ? 1 : 0] : null;
42
+ if (!label) continue;
43
+ const detail = presentation?.title ?? item.command ?? item.text ?? item.name ?? "";
44
+ const atMs = typeof event.createdAt === "number" && Number.isFinite(event.createdAt) ? Math.max(0, Math.trunc(event.createdAt)) : 0;
45
+ out.push({
46
+ kind: item.type.slice(0, 80),
47
+ done,
48
+ atMs,
49
+ label: label.trim().slice(0, 80) || (done ? "Completed" : "Working"),
50
+ text: String(detail).replace(/\s+/g, " ").trim().slice(0, 200),
51
+ });
52
+ }
53
+ out.reverse();
54
+ return out.slice(-limit);
55
+ }
56
+
57
+ export function asRecord(value: unknown): Record<string, unknown> | null {
58
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
59
+ }
@@ -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
+ }