@unifedev/thread-pages 0.3.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +77 -129
  2. package/dist/server.js +11779 -11801
  3. package/dist/server.meta.json +2 -2
  4. package/docs/B1-OWN-FILES.md +133 -0
  5. package/docs/FOR-PAGE-AUTHORS-1.1.md +167 -0
  6. package/docs/UPGRADING.md +53 -0
  7. package/package.json +26 -18
  8. package/server.ts +3 -2175
  9. package/src/agent/cli.ts +193 -0
  10. package/src/agent/guide.ts +450 -0
  11. package/src/agent/instruction.ts +59 -0
  12. package/src/agent/seed/seed.ts +73 -0
  13. package/{theme.ts → src/agent/seed/theme-css.ts} +9 -11
  14. package/src/agent/starter-hub.ts +217 -0
  15. package/src/bb/activity.ts +59 -0
  16. package/src/bb/bb-host.ts +280 -0
  17. package/src/bb/public-origin.ts +45 -0
  18. package/src/config/settings.ts +82 -0
  19. package/src/domain/capabilities/contract.ts +48 -0
  20. package/src/domain/capabilities/index.ts +10 -0
  21. package/src/domain/capabilities/protocol.ts +113 -0
  22. package/src/domain/capabilities/registry.ts +48 -0
  23. package/src/domain/capabilities/renamed.ts +34 -0
  24. package/src/domain/capabilities/schema.ts +198 -0
  25. package/src/domain/capabilities/specs.ts +479 -0
  26. package/src/domain/eligibility.ts +43 -0
  27. package/src/domain/errors.ts +116 -0
  28. package/src/domain/html/document.ts +109 -0
  29. package/src/domain/html/escape.ts +16 -0
  30. package/src/domain/ids.ts +37 -0
  31. package/src/domain/json/canonical.ts +19 -0
  32. package/src/domain/json/strict-json.ts +139 -0
  33. package/src/domain/limits.ts +98 -0
  34. package/src/domain/rate-limit.ts +64 -0
  35. package/src/domain/revision.ts +27 -0
  36. package/src/domain/submissions/idempotency.ts +59 -0
  37. package/src/domain/submissions/message.ts +42 -0
  38. package/src/domain/submissions/parse.ts +105 -0
  39. package/src/domain/tokens/action-token.ts +52 -0
  40. package/src/domain/tokens/confirmation.ts +99 -0
  41. package/src/domain/tokens/mac.ts +50 -0
  42. package/src/generated/kernel-runtime.ts +3 -0
  43. package/src/generated/shell-runtime.ts +3 -0
  44. package/src/host/contract.ts +65 -0
  45. package/src/host/types.ts +89 -0
  46. package/src/pages/inline.ts +277 -0
  47. package/src/pages/layout.ts +65 -0
  48. package/src/pages/page-store.ts +170 -0
  49. package/src/pages/site.ts +36 -0
  50. package/src/plugin.ts +81 -0
  51. package/src/runtime/kernel/anchors.ts +45 -0
  52. package/src/runtime/kernel/api.ts +15 -0
  53. package/src/runtime/kernel/bridge-client.ts +148 -0
  54. package/src/runtime/kernel/dirty.ts +51 -0
  55. package/src/runtime/kernel/forms.ts +114 -0
  56. package/src/runtime/kernel/install.ts +156 -0
  57. package/src/runtime/kernel/labels.ts +98 -0
  58. package/src/runtime/kernel/main.ts +6 -0
  59. package/src/runtime/kernel/readonly.ts +75 -0
  60. package/src/runtime/shared/protocol.ts +125 -0
  61. package/src/runtime/shell/confirm.ts +70 -0
  62. package/src/runtime/shell/install.ts +79 -0
  63. package/src/runtime/shell/main.ts +12 -0
  64. package/src/runtime/shell/navigate.ts +64 -0
  65. package/src/runtime/shell/poll.ts +125 -0
  66. package/src/runtime/shell/relay.ts +185 -0
  67. package/src/serving/action-request.ts +32 -0
  68. package/src/serving/bridge/dispatcher.ts +112 -0
  69. package/src/serving/bridge/handler.ts +37 -0
  70. package/src/serving/bridge/handlers/index.ts +26 -0
  71. package/src/serving/bridge/handlers/navigation.ts +43 -0
  72. package/src/serving/bridge/handlers/reads.ts +186 -0
  73. package/src/serving/bridge/handlers/writes.ts +175 -0
  74. package/src/serving/bridge/selection-store.ts +58 -0
  75. package/src/serving/bridge-route.ts +23 -0
  76. package/src/serving/context.ts +34 -0
  77. package/src/serving/document-route.ts +37 -0
  78. package/src/serving/home-route.ts +23 -0
  79. package/src/serving/responses.ts +81 -0
  80. package/src/serving/routes.ts +26 -0
  81. package/src/serving/session-access.ts +22 -0
  82. package/src/serving/shell-html.ts +77 -0
  83. package/src/serving/shell-route.ts +51 -0
  84. package/src/serving/signing-key.ts +25 -0
  85. package/src/serving/submit-route.ts +47 -0
  86. package/src/serving/upload-route.ts +46 -0
  87. package/tsconfig.json +10 -6
  88. package/ARCHITECTURE.md +0 -230
  89. package/PLUGIN_OVERVIEW.md +0 -83
  90. package/authoring.ts +0 -368
  91. package/bridge.ts +0 -1721
  92. package/docs/MODEL.md +0 -211
  93. package/docs/ROADMAP.md +0 -96
  94. package/home.ts +0 -419
  95. package/page.ts +0 -782
@@ -0,0 +1,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,73 @@
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
+ Everything below is yours: this whole file, including the stylesheet in
28
+ the head. Rewrite it however the task needs. Two things the file cannot
29
+ tell you, because they are behaviour rather than markup:
30
+
31
+ 1. Every form element in this document is captured and delivered to this
32
+ session as a message. That includes a dialog form you only meant as a
33
+ local confirm; put data-thread-page-manual on any form that is not
34
+ meant to answer. Nothing is required and blank is a real answer.
35
+ 2. window.prompt, alert, confirm and window.open do nothing here — the
36
+ sandbox silences them. Build the input into the page instead.
37
+
38
+ The look is three attributes on the html element. data-theme: paper,
39
+ terminal, atrium, volume or bloom. data-mode: system, light or dark.
40
+ data-atmos: on or off.
41
+
42
+ If this page should stay put — a dashboard, a console, a page nobody
43
+ should have to rewrite — delete the reply form below and let its buttons
44
+ start fresh sessions instead. See the guide.
45
+
46
+ Everything else — files beside this one, charts, live session state,
47
+ starting sessions, links, limits — is in: bb thread-page guide
48
+
49
+ This comment deliberately names no HTML tags. An earlier version spelled
50
+ them out, and every agent that edited its page by string surgery found
51
+ tags here that were not in the document. Keep it that way.
52
+ -->
53
+ <main>
54
+ <p>Replace this with what changed and what you need from the reader.</p>
55
+
56
+ <form data-title="{{TITLE}}">
57
+ <label>Reply
58
+ <textarea name="reply" rows="4"></textarea>
59
+ </label>
60
+ <button name="action" value="Reply">Reply</button>
61
+ </form>
62
+ </main>
63
+
64
+ </div>
65
+ </body>
66
+ </html>
67
+ `;
68
+
69
+ /** Applies the seed's substitutions; both are escaped. spec R6.21 */
70
+ export function renderSeed(template: string, title: string, now: Date = new Date()): string {
71
+ const date = now.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" });
72
+ return template.replaceAll("{{TITLE}}", escapeHtml(title)).replaceAll("{{DATE}}", escapeHtml(date));
73
+ }
@@ -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 {
@@ -44,6 +37,11 @@ export const THEME_CSS = String.raw`
44
37
 
45
38
  *, *::before, *::after { box-sizing: border-box; }
46
39
 
40
+ /* A class rule that sets display outranks the user-agent rule for the
41
+ hidden attribute, so a page that styles .banner with display:flex would
42
+ render a hidden banner as an empty bar. This keeps hidden meaning hidden. */
43
+ [hidden] { display: none !important; }
44
+
47
45
  html { -webkit-text-size-adjust: 100%; }
48
46
 
49
47
  body {
@@ -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
+ }