toolroll 0.9.53 → 0.9.55

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 (254) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +5 -4
  3. package/dist/agent-onboard.js +4 -1
  4. package/dist/approval-rules-ui.d.ts +3 -2
  5. package/dist/approval-rules-ui.js +15 -20
  6. package/dist/assignment-ui.d.ts +3 -2
  7. package/dist/assignment-ui.js +4 -11
  8. package/dist/backup-ui.d.ts +2 -1
  9. package/dist/backup-ui.js +16 -27
  10. package/dist/brand-mark.d.ts +3 -2
  11. package/dist/brand-mark.js +4 -4
  12. package/dist/browser/workspace.css +1 -1
  13. package/dist/browser/workspace.js +25 -25
  14. package/dist/browser-shell.d.ts +2 -1
  15. package/dist/browser-shell.js +9 -9
  16. package/dist/browser-workspace.d.ts +2 -3
  17. package/dist/browser-workspace.js +0 -3
  18. package/dist/chat-ask.js +8 -8
  19. package/dist/chat-core.d.ts +171 -0
  20. package/dist/chat-core.js +288 -0
  21. package/dist/chat-decide.d.ts +10 -8
  22. package/dist/chat-decide.js +26 -21
  23. package/dist/chat-delivery-state.d.ts +51 -75
  24. package/dist/chat-delivery-state.js +140 -170
  25. package/dist/chat-delivery.d.ts +21 -2
  26. package/dist/chat-delivery.js +313 -194
  27. package/dist/chat-flow.d.ts +29 -1
  28. package/dist/chat-flow.js +24 -50
  29. package/dist/chat-messages.d.ts +319 -0
  30. package/dist/chat-messages.js +841 -0
  31. package/dist/chat-migration.d.ts +30 -0
  32. package/dist/chat-migration.js +802 -0
  33. package/dist/chat-polish.d.ts +5 -4
  34. package/dist/chat-polish.js +9 -10
  35. package/dist/chat-quiet.d.ts +3 -0
  36. package/dist/chat-quiet.js +5 -0
  37. package/dist/check-levels-ui.d.ts +2 -1
  38. package/dist/check-levels-ui.js +26 -53
  39. package/dist/cli.d.ts +3 -1
  40. package/dist/cli.js +9 -3
  41. package/dist/coding-shipping-ui.d.ts +2 -1
  42. package/dist/coding-shipping-ui.js +6 -6
  43. package/dist/coding-ui.d.ts +3 -1
  44. package/dist/coding-ui.js +30 -29
  45. package/dist/contracts/chat-content.d.ts +71 -2
  46. package/dist/contracts/chat-content.js +22 -3
  47. package/dist/contracts/chat-tables.d.ts +11 -4
  48. package/dist/contracts/chat-tables.js +165 -87
  49. package/dist/contracts/cli.d.ts +1 -3
  50. package/dist/contracts/console-api.d.ts +1 -1
  51. package/dist/contracts/console-api.js +6 -5
  52. package/dist/contracts/discord-callback.js +2 -1
  53. package/dist/contracts/health.d.ts +1 -3
  54. package/dist/contracts/slack-callback.js +3 -2
  55. package/dist/contracts/store-json.d.ts +1 -5
  56. package/dist/contracts/store-json.js +1 -4
  57. package/dist/contracts/teams-callback.js +2 -1
  58. package/dist/control-ui.d.ts +5 -4
  59. package/dist/control-ui.js +9 -34
  60. package/dist/credentials-ui.d.ts +5 -4
  61. package/dist/credentials-ui.js +23 -24
  62. package/dist/demo-chat.d.ts +3 -3
  63. package/dist/demo-chat.js +29 -76
  64. package/dist/discord-chat.js +19 -38
  65. package/dist/discord-settings.d.ts +3 -2
  66. package/dist/discord-settings.js +21 -55
  67. package/dist/discord.js +4 -14
  68. package/dist/envelope.js +10 -0
  69. package/dist/evidence-pack.d.ts +2 -1
  70. package/dist/evidence-pack.js +45 -49
  71. package/dist/export-ui.d.ts +7 -2
  72. package/dist/export-ui.js +4 -10
  73. package/dist/export.js +4 -4
  74. package/dist/flow-gallery-ui.d.ts +4 -3
  75. package/dist/flow-gallery-ui.js +28 -43
  76. package/dist/flow-starters-ui.d.ts +2 -1
  77. package/dist/flow-starters-ui.js +12 -15
  78. package/dist/flow-triggers.d.ts +1 -0
  79. package/dist/flow-triggers.js +16 -14
  80. package/dist/flows-ui.d.ts +4 -3
  81. package/dist/flows-ui.js +19 -30
  82. package/dist/flows.js +2 -1
  83. package/dist/guides.js +108 -3
  84. package/dist/html.d.ts +76 -0
  85. package/dist/html.js +173 -0
  86. package/dist/integrations-ui.d.ts +2 -1
  87. package/dist/integrations-ui.js +19 -23
  88. package/dist/kits-ui.d.ts +5 -4
  89. package/dist/kits-ui.js +18 -20
  90. package/dist/knowledge-ui.d.ts +6 -5
  91. package/dist/knowledge-ui.js +26 -25
  92. package/dist/lead-context.d.ts +11 -1
  93. package/dist/lead-context.js +6 -8
  94. package/dist/ledger-view.d.ts +2 -1
  95. package/dist/ledger-view.js +27 -29
  96. package/dist/limits-ui.d.ts +2 -1
  97. package/dist/limits-ui.js +7 -7
  98. package/dist/live-bus.d.ts +50 -8
  99. package/dist/live-bus.js +133 -50
  100. package/dist/mate-tools.js +3 -2
  101. package/dist/mcp-oauth.js +35 -36
  102. package/dist/models-ui.d.ts +2 -1
  103. package/dist/models-ui.js +25 -35
  104. package/dist/modes.d.ts +13 -1
  105. package/dist/modes.js +17 -1
  106. package/dist/monitoring-ui.d.ts +4 -3
  107. package/dist/monitoring-ui.js +13 -26
  108. package/dist/notification-rows.d.ts +30 -0
  109. package/dist/notification-rows.js +102 -0
  110. package/dist/openrouter-models.d.ts +2 -1
  111. package/dist/openrouter-models.js +5 -9
  112. package/dist/operate.js +16 -9
  113. package/dist/people-audit-ui.d.ts +2 -1
  114. package/dist/people-audit-ui.js +13 -13
  115. package/dist/plane-review.js +5 -11
  116. package/dist/policy-ui.d.ts +3 -2
  117. package/dist/policy-ui.js +17 -23
  118. package/dist/project-delete-ui.d.ts +4 -3
  119. package/dist/project-delete-ui.js +15 -27
  120. package/dist/project-delete.js +21 -26
  121. package/dist/pull-request-ui.d.ts +2 -1
  122. package/dist/pull-request-ui.js +17 -32
  123. package/dist/recipe-ui.d.ts +8 -7
  124. package/dist/recipe-ui.js +39 -32
  125. package/dist/release-adapters.d.ts +60 -0
  126. package/dist/release-adapters.js +435 -0
  127. package/dist/release-cli.d.ts +24 -0
  128. package/dist/release-cli.js +181 -0
  129. package/dist/release-gate.d.ts +80 -0
  130. package/dist/release-gate.js +150 -0
  131. package/dist/release-links.d.ts +29 -0
  132. package/dist/release-links.js +102 -0
  133. package/dist/release.d.ts +331 -0
  134. package/dist/release.js +550 -0
  135. package/dist/reply-shape.d.ts +2 -1
  136. package/dist/reply-shape.js +5 -5
  137. package/dist/repository-context-ui.d.ts +2 -1
  138. package/dist/repository-context-ui.js +2 -7
  139. package/dist/request-budget-ui.d.ts +2 -1
  140. package/dist/request-budget-ui.js +16 -16
  141. package/dist/result-review.d.ts +2 -3
  142. package/dist/result-review.js +3 -9
  143. package/dist/result-shots.js +2 -2
  144. package/dist/retention-ui.d.ts +7 -1
  145. package/dist/retention-ui.js +7 -11
  146. package/dist/retention.js +1 -1
  147. package/dist/serve.d.ts +11 -2
  148. package/dist/serve.js +69 -58
  149. package/dist/server/chat.d.ts +3 -6
  150. package/dist/server/chat.js +969 -1006
  151. package/dist/server/chrome.d.ts +291 -0
  152. package/dist/server/chrome.js +747 -0
  153. package/dist/server/console.css +2819 -0
  154. package/dist/server/flows.d.ts +2 -6
  155. package/dist/server/flows.js +840 -766
  156. package/dist/server/guards.d.ts +1 -1
  157. package/dist/server/guards.js +5 -2
  158. package/dist/server/handler-context.d.ts +1 -1
  159. package/dist/server/handler-context.js +1 -1
  160. package/dist/server/handler-registry.d.ts +8 -6
  161. package/dist/server/handler-registry.js +14 -2
  162. package/dist/server/http.d.ts +274 -0
  163. package/dist/server/http.js +239 -0
  164. package/dist/server/live.d.ts +34 -0
  165. package/dist/server/live.js +309 -0
  166. package/dist/server/pages.d.ts +2 -6
  167. package/dist/server/pages.js +808 -789
  168. package/dist/server/people-tokens.d.ts +1 -6
  169. package/dist/server/people-tokens.js +580 -572
  170. package/dist/server/remote.d.ts +4 -7
  171. package/dist/server/remote.js +37 -70
  172. package/dist/server/render-chat.d.ts +402 -0
  173. package/dist/server/render-chat.js +927 -0
  174. package/dist/server/render-pages.d.ts +535 -0
  175. package/dist/server/render-pages.js +1277 -0
  176. package/dist/server/render-people.d.ts +32 -0
  177. package/dist/server/render-people.js +121 -0
  178. package/dist/server/render-results.d.ts +744 -0
  179. package/dist/server/render-results.js +2237 -0
  180. package/dist/server/render-settings.d.ts +147 -0
  181. package/dist/server/render-settings.js +577 -0
  182. package/dist/server/render-tasks.d.ts +632 -0
  183. package/dist/server/render-tasks.js +2345 -0
  184. package/dist/server/request-context.d.ts +46 -0
  185. package/dist/server/request-context.js +17 -0
  186. package/dist/server/route-table.d.ts +25 -3
  187. package/dist/server/route-table.js +163 -75
  188. package/dist/server/runtime.d.ts +15 -7
  189. package/dist/server/runtime.js +7 -1
  190. package/dist/server/session.d.ts +138 -0
  191. package/dist/server/session.js +94 -0
  192. package/dist/server/settings.d.ts +1 -5
  193. package/dist/server/settings.js +2182 -2117
  194. package/dist/server/task-views.d.ts +11 -4
  195. package/dist/server/task-views.js +26 -22
  196. package/dist/server/tasks.d.ts +9 -9
  197. package/dist/server/tasks.js +1486 -1418
  198. package/dist/server-telemetry.d.ts +4 -3
  199. package/dist/server-telemetry.js +4 -8
  200. package/dist/skills-ui.d.ts +4 -3
  201. package/dist/skills-ui.js +14 -17
  202. package/dist/skills.d.ts +1 -1
  203. package/dist/skills.js +2 -1
  204. package/dist/slack-api.d.ts +5 -1
  205. package/dist/slack-api.js +2 -2
  206. package/dist/slack-chat.d.ts +9 -4
  207. package/dist/slack-chat.js +29 -52
  208. package/dist/slack-settings.d.ts +3 -2
  209. package/dist/slack-settings.js +23 -57
  210. package/dist/slack.js +6 -16
  211. package/dist/spend-ui.d.ts +2 -1
  212. package/dist/spend-ui.js +9 -32
  213. package/dist/sso-ui.d.ts +3 -2
  214. package/dist/sso-ui.js +26 -27
  215. package/dist/storage-ui.d.ts +2 -1
  216. package/dist/storage-ui.js +14 -33
  217. package/dist/store.d.ts +97 -364
  218. package/dist/store.js +207 -1531
  219. package/dist/surface.js +14 -3
  220. package/dist/task-composer.d.ts +2 -1
  221. package/dist/task-composer.js +7 -7
  222. package/dist/task-status.d.ts +4 -3
  223. package/dist/task-status.js +15 -16
  224. package/dist/team-http.d.ts +5 -9
  225. package/dist/team-http.js +5 -63
  226. package/dist/team-ui.d.ts +2 -1
  227. package/dist/team-ui.js +2 -5
  228. package/dist/teammate-question.js +12 -12
  229. package/dist/teammates-ui.d.ts +5 -4
  230. package/dist/teammates-ui.js +61 -90
  231. package/dist/teams-chat.js +12 -9
  232. package/dist/teams-settings.d.ts +3 -2
  233. package/dist/teams-settings.js +22 -33
  234. package/dist/teams.js +4 -5
  235. package/dist/telegram-flow.js +5 -5
  236. package/dist/telegram-settings.d.ts +3 -2
  237. package/dist/telegram-settings.js +14 -25
  238. package/dist/telegram.js +4 -4
  239. package/dist/toolroll-update-ui.d.ts +3 -2
  240. package/dist/toolroll-update-ui.js +35 -39
  241. package/dist/toolroll-update.d.ts +10 -0
  242. package/dist/toolroll-update.js +50 -3
  243. package/dist/tools-ui.d.ts +5 -2
  244. package/dist/tools-ui.js +31 -31
  245. package/dist/when-html.d.ts +11 -3
  246. package/dist/when-html.js +5 -6
  247. package/dist/workspace-revision.js +3 -1
  248. package/dist/workspace-ui.d.ts +4 -2
  249. package/dist/workspace-ui.js +10 -10
  250. package/package.json +1 -1
  251. package/dist/server/shared.d.ts +0 -3151
  252. package/dist/server/shared.js +0 -12001
  253. package/dist/slack-state.d.ts +0 -13
  254. package/dist/slack-state.js +0 -10
@@ -1,3 +1,13 @@
1
+ /** Bounded DB memory shared by browser and terminal lead turns. No provider,
2
+ * repository scan, mutation, or hidden approval happens while catching up.
3
+ *
4
+ * The bundle is ordered by importance and built fresh each turn: who the lead
5
+ * is (name, persona), who it is talking to (first name, time zone, today), what
6
+ * it knows about them (their confirmed lines), the people, AI teammates and team
7
+ * chats it works with (one line each), the channel, what needs them now,
8
+ * projects by name with their active decisions, then the rest. Over 8 KB, the
9
+ * least important goes first: the rest, then people, then decisions. */
10
+ import { type Html } from './html.js';
1
11
  import type { Store } from './store.js';
2
12
  import { type LeadChannel } from './contracts/lead-context.js';
3
13
  import { type AssignmentCatchUp } from './assignment-brief.js';
@@ -31,5 +41,5 @@ export declare function firstNameOf(account: string): string;
31
41
  export declare function leadChannelOf(label: string): LeadChannel | undefined;
32
42
  export declare function leadContext(store: Store, repos: readonly string[], now: Date, options?: LeadContextOptions): string;
33
43
  /** The same saved brief is useful before chat spending is authorized. */
34
- export declare function leadBriefHtml(brief: AssignmentCatchUp): string;
44
+ export declare function leadBriefHtml(brief: AssignmentCatchUp): Html;
35
45
  export declare const LEAD_CONTEXT_CSS = ".lead-promise-list{list-style:none;margin:0;padding:0}.lead-promise-list li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.5rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-promise-list li:last-child{border-bottom:0}.lead-promise-list p{margin:0;overflow-wrap:anywhere}.lead-promise-list form{grid-column:2;grid-row:1/span 2;margin:0}.lead-promise-list button{min-height:44px;width:auto;white-space:nowrap}.lead-promise-list .nowrap{white-space:nowrap}.lead-brief{margin:1rem 0;min-width:0}.lead-brief h2{font-size:1.1rem;margin:0 0 .8rem}.repository-context input[name=q]{display:block;box-sizing:border-box;min-height:44px;width:100%;margin:.4rem 0 .75rem;padding:.6rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:inherit;font:inherit}.repository-context form{margin:.5rem 0 1rem}.repository-context summary{min-height:44px;padding:.75rem 0;overflow-wrap:anywhere}.lead-brief h3{font-size:.85rem;margin:1rem 0 .4rem;color:var(--muted-foreground)}.lead-brief ul{list-style:none;margin:0;padding:0}.lead-brief li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.35rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-brief li a{display:flex;min-height:44px;align-items:center;font-weight:500;overflow-wrap:anywhere;text-decoration:none}.lead-brief li a:hover{text-decoration:underline}.lead-brief-state{font-size:.75rem;font-weight:600;padding:.15rem .5rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink);white-space:nowrap}.lead-brief-state--needs-decision,.lead-brief-state--ready-to-check{background:var(--so-attention-soft);color:var(--so-attention)}.lead-brief-state--working,.lead-brief-state--checking{background:var(--so-info-soft);color:var(--so-info)}.lead-brief-state--complete{background:var(--so-success-soft);color:var(--so-success)}.lead-brief li a.lead-brief-act{grid-column:1/-1;justify-self:start;display:inline-flex;min-height:40px;margin:.15rem 0 .4rem;font-weight:600}@media(max-width:760px){.lead-brief li a.lead-brief-act{justify-self:stretch;justify-content:center;min-height:44px}}.lead-brief-detail{grid-column:1/-1;font-size:.85rem;color:var(--muted-foreground);overflow-wrap:anywhere;padding-bottom:.35rem}@media(max-width:760px){.lead-brief{margin:.5rem 0}.lead-brief h2{margin:0 0 .25rem}.lead-brief h3{margin:.5rem 0 0}.lead-brief li{padding:0 0 .375rem}.lead-brief-detail{margin-top:-.375rem;padding-bottom:0;line-height:1.35;pointer-events:none}}";
@@ -7,6 +7,7 @@
7
7
  * chats it works with (one line each), the channel, what needs them now,
8
8
  * projects by name with their active decisions, then the rest. Over 8 KB, the
9
9
  * least important goes first: the rest, then people, then decisions. */
10
+ import { html } from './html.js';
10
11
  import { PLATFORM_LIMITS, TEXT_LIMITS } from './text-limits.js';
11
12
  import { LEAD_CONTEXT_COUNTS, leadContextSchema } from './contracts/lead-context.js';
12
13
  import { contractError } from './contracts/contract.js';
@@ -186,7 +187,6 @@ export function leadContext(store, repos, now, options = {}) {
186
187
  document = JSON.stringify(data);
187
188
  return document;
188
189
  }
189
- const escape = (value) => value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
190
190
  const STATE_WORDS = { 'needs-decision': 'Needs you', 'ready-to-check': 'Ready', working: 'Working', checking: 'Checking', complete: 'Complete', cancelled: 'Cancelled' };
191
191
  /** The same saved brief is useful before chat spending is authorized. */
192
192
  export function leadBriefHtml(brief) {
@@ -208,14 +208,12 @@ export function leadBriefHtml(brief) {
208
208
  const stateChip = (state, detail) => {
209
209
  // Replaced, never "Cancelled".
210
210
  const words = state === 'cancelled' && /^Replaced by \S+/.test(detail ?? '') ? detail.replace(/\.$/, '') : STATE_WORDS[state] ?? state;
211
- return words === group.title ? '' : `<span class="lead-brief-state lead-brief-state--${state}">${escape(words)}</span>`;
211
+ return words === group.title ? '' : html `<span class="lead-brief-state lead-brief-state--${state}">${words}</span>`;
212
212
  };
213
- return [`<section><h3>${group.title}</h3><ul>${entries.map(one => `<li><a href="/chat?task=${encodeURIComponent(one.rootId)}">${escape(one.title)}</a>` +
214
- stateChip(one.state, one.detail) +
215
- (quiet ? '' : `<span class="lead-brief-detail">${escape(publicChatText(one.detail || one.outcome || '', 160))}</span>`) +
216
- // Needs you: the one action that resolves it, under its sentence.
217
- (one.state === 'needs-decision' && one.nextAction !== null && one.nextHref ? `<a class="lead-brief-act button-link" href="${escape(one.nextHref)}" data-catch-up-action>${escape(one.nextAction.label)}</a>` : '') + `</li>`).join('')}</ul></section>`];
213
+ return [html `<section><h3>${group.title}</h3><ul>${entries.map(one => html `<li><a href="/chat?task=${encodeURIComponent(one.rootId)}">${one.title}</a>${stateChip(one.state, one.detail)}${quiet ? '' : html `<span class="lead-brief-detail">${publicChatText(one.detail || one.outcome || '', 160)}</span>`}${
214
+ // Needs you: the one action that resolves it, under its sentence.
215
+ one.state === 'needs-decision' && one.nextAction !== null && one.nextHref ? html `<a class="lead-brief-act button-link" href="${one.nextHref}" data-catch-up-action>${one.nextAction.label}</a>` : ''}</li>`)}</ul></section>`];
218
216
  });
219
- return `<section class="lead-brief" aria-label="Project catch-up"><h2>Catch up</h2>${sections.length ? sections.join('') : '<p class="meta">Nothing needs you right now.</p>'}${brief.assignments.length > 3 || brief.omissions.candidateScanLimited || brief.omissions.assignments > 0 ? '<a href="/work">See all tasks</a>' : ''}</section>`;
217
+ return html `<section class="lead-brief" aria-label="Project catch-up"><h2>Catch up</h2>${sections.length ? sections : html `<p class="meta">Nothing needs you right now.</p>`}${brief.assignments.length > 3 || brief.omissions.candidateScanLimited || brief.omissions.assignments > 0 ? html `<a href="/work">See all tasks</a>` : ''}</section>`;
220
218
  }
221
219
  export const LEAD_CONTEXT_CSS = '.lead-promise-list{list-style:none;margin:0;padding:0}.lead-promise-list li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.5rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-promise-list li:last-child{border-bottom:0}.lead-promise-list p{margin:0;overflow-wrap:anywhere}.lead-promise-list form{grid-column:2;grid-row:1/span 2;margin:0}.lead-promise-list button{min-height:44px;width:auto;white-space:nowrap}.lead-promise-list .nowrap{white-space:nowrap}.lead-brief{margin:1rem 0;min-width:0}.lead-brief h2{font-size:1.1rem;margin:0 0 .8rem}.repository-context input[name=q]{display:block;box-sizing:border-box;min-height:44px;width:100%;margin:.4rem 0 .75rem;padding:.6rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:inherit;font:inherit}.repository-context form{margin:.5rem 0 1rem}.repository-context summary{min-height:44px;padding:.75rem 0;overflow-wrap:anywhere}.lead-brief h3{font-size:.85rem;margin:1rem 0 .4rem;color:var(--muted-foreground)}.lead-brief ul{list-style:none;margin:0;padding:0}.lead-brief li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.35rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-brief li a{display:flex;min-height:44px;align-items:center;font-weight:500;overflow-wrap:anywhere;text-decoration:none}.lead-brief li a:hover{text-decoration:underline}.lead-brief-state{font-size:.75rem;font-weight:600;padding:.15rem .5rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink);white-space:nowrap}.lead-brief-state--needs-decision,.lead-brief-state--ready-to-check{background:var(--so-attention-soft);color:var(--so-attention)}.lead-brief-state--working,.lead-brief-state--checking{background:var(--so-info-soft);color:var(--so-info)}.lead-brief-state--complete{background:var(--so-success-soft);color:var(--so-success)}.lead-brief li a.lead-brief-act{grid-column:1/-1;justify-self:start;display:inline-flex;min-height:40px;margin:.15rem 0 .4rem;font-weight:600}@media(max-width:760px){.lead-brief li a.lead-brief-act{justify-self:stretch;justify-content:center;min-height:44px}}.lead-brief-detail{grid-column:1/-1;font-size:.85rem;color:var(--muted-foreground);overflow-wrap:anywhere;padding-bottom:.35rem}@media(max-width:760px){.lead-brief{margin:.5rem 0}.lead-brief h2{margin:0 0 .25rem}.lead-brief h3{margin:.5rem 0 0}.lead-brief li{padding:0 0 .375rem}.lead-brief-detail{margin-top:-.375rem;padding-bottom:0;line-height:1.35;pointer-events:none}}';
@@ -1,4 +1,5 @@
1
1
  import type { LedgerEntry } from "./action-ledger.js";
2
+ import { type Html } from "./html.js";
2
3
  /** v103: the chain's state in one line, and (for an instance operator) a checkpoint to copy off the machine. */
3
4
  export type LedgerChainView = {
4
5
  ok: boolean;
@@ -21,4 +22,4 @@ export type LedgerChainView = {
21
22
  /** Today (UTC, YYYY-MM-DD): the audit export's default range ends here. */
22
23
  today: string;
23
24
  };
24
- export declare function ledgerBody(rows: LedgerEntry[], projects: readonly string[], params: URLSearchParams, chain?: LedgerChainView): string;
25
+ export declare function ledgerBody(rows: LedgerEntry[], projects: readonly string[], params: URLSearchParams, chain?: LedgerChainView): Html;
@@ -1,23 +1,28 @@
1
1
  import { projectName } from "./project.js";
2
2
  import { exactWhenHtml } from "./when-html.js";
3
- const html = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
3
+ import { html, postForm, styleElement } from "./html.js";
4
4
  /** The ledger's kinds of event, as the filter names them. */
5
5
  const EVENT_KINDS = [["work", "Work"], ["request", "Requests"], ["access", "Access"], ["sign-in", "Sign-ins"], ["policy", "Policy changes"]];
6
6
  function chainHtml(chain) {
7
- const html = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
8
7
  const status = chain.ok
9
- ? `<p class="ledger-chain ok" data-ledger-chain="ok"><strong>Chain verified</strong> · ${chain.entries} entries through #${chain.through ?? 0} · head <code>${html(chain.head.slice(0, 16))}…</code>${chain.checkedAt === null ? "" : ` · checked in full ${html(chain.checkedAt.slice(11, 16))} UTC`}</p>`
10
- : `<p class="ledger-chain problem" role="alert" data-ledger-chain="broken"><strong>Chain broken</strong> · ${html(chain.problem?.what ?? "the chain doesn't verify")}</p>`;
11
- const latest = chain.latest === null ? "" : `<p class="meta ledger-checkpoint">Latest checkpoint (${html(chain.latest.at.slice(0, 16).replace("T", " "))} UTC), copy it somewhere outside this machine: <code class="ledger-checkpoint-value">${chain.latest.through}:${html(chain.latest.hash)}</code></p>`;
12
- const make = chain.csrf === null ? "" : `<form method="post" action="/ledger/checkpoint" class="ledger-checkpoint-form"><input type="hidden" name="csrf" value="${html(chain.csrf)}"><button type="submit">Make a checkpoint</button></form>`;
8
+ ? html `<p class="ledger-chain ok" data-ledger-chain="ok"><strong>Chain verified</strong> · ${chain.entries} entries through #${chain.through ?? 0} · head <code>${chain.head.slice(0, 16)}…</code>${chain.checkedAt === null ? "" : ` · checked in full ${chain.checkedAt.slice(11, 16)} UTC`}</p>`
9
+ : html `<p class="ledger-chain problem" role="alert" data-ledger-chain="broken"><strong>Chain broken</strong> · ${chain.problem?.what ?? "the chain doesn't verify"}</p>`;
10
+ const latest = chain.latest === null ? "" : html `<p class="meta ledger-checkpoint">Latest checkpoint (${chain.latest.at.slice(0, 16).replace("T", " ")} UTC), copy it somewhere outside this machine: <code class="ledger-checkpoint-value">${chain.latest.through}:${chain.latest.hash}</code></p>`;
11
+ const make = chain.csrf === null ? "" : postForm("/ledger/checkpoint", html `<button type="submit">Make a checkpoint</button>`, { attrs: { class: "ledger-checkpoint-form" } });
13
12
  const start = new Date(Date.parse(`${chain.today}T00:00:00Z`) - 29 * 86_400_000).toISOString().slice(0, 10);
14
- const exportForm = `<details class="ledger-filter-panel"><summary>Audit export</summary><form method="get" action="/ledger/export" class="ledger-filters ledger-export" aria-label="Audit export">` +
15
- `<label>From<input type="date" name="from" value="${html(start)}" required></label><label>To<input type="date" name="to" value="${html(chain.today)}" required></label>` +
16
- `<button type="submit">Download</button></form><p class="meta">Every entry in the range with its seal, and an evidence pack for each task it names. JSON.</p></details>`;
17
- return `<section class="ledger-chain-panel" aria-label="Ledger integrity">${status}${latest}${make}${exportForm}</section>`;
13
+ const exportForm = html `<details class="ledger-filter-panel"><summary>Audit export</summary><form method="get" action="/ledger/export" class="ledger-filters ledger-export" aria-label="Audit export"><label>From<input type="date" name="from" value="${start}" required></label><label>To<input type="date" name="to" value="${chain.today}" required></label><button type="submit">Download</button></form><p class="meta">Every entry in the range with its seal, and an evidence pack for each task it names. JSON.</p></details>`;
14
+ return html `<section class="ledger-chain-panel" aria-label="Ledger integrity">${status}${latest}${make}${exportForm}</section>`;
18
15
  }
16
+ const LEDGER_CSS = `
17
+ .ledger-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.ledger-heading h1{margin:0}.ledger-caption{margin:8px 0 12px}
18
+ .ledger-filter-panel{margin-bottom:12px}.ledger-filter-panel summary{font-size:13px;padding:6px 0;cursor:pointer}.ledger-filters{display:grid;grid-template-columns:repeat(5,minmax(0,1fr)) auto auto;gap:8px;align-items:end;margin:0 0 12px}.ledger-filters label{display:grid;gap:3px;min-width:0;font-size:12px}.ledger-filters input,.ledger-filters select{width:100%;min-width:0;box-sizing:border-box;margin:0;padding:6px 8px;font-size:13px}.ledger-filters button{padding:7px 12px}.ledger-filters>a{padding:8px 0;font-size:13px}
19
+ .ledger-detail{display:block;color:var(--muted-foreground);font-size:12px}.ledger-list{border:1px solid var(--border);border-radius:8px;overflow:hidden}.ledger-event{display:grid;grid-template-columns:140px minmax(90px,1fr) minmax(160px,2fr) minmax(100px,1fr) minmax(100px,1fr);gap:8px;align-items:baseline;padding:7px 10px;border-bottom:1px solid var(--border);font-size:13px;line-height:1.4;overflow-wrap:anywhere}.ledger-event:last-child{border-bottom:0}.ledger-event .actor{color:inherit;font-weight:600;text-decoration:none}.ledger-time,.ledger-place,.ledger-result small{font-size:11px;color:var(--muted-foreground)}.ledger-time time{font-variant-numeric:tabular-nums}.ledger-place a{display:block}.ledger-result small{display:block}.ledger-labels{font-size:11px;color:var(--muted-foreground);font-weight:600}.ledger-empty{padding:12px}
20
+ @media(max-width:1000px){.ledger-filters{grid-template-columns:repeat(3,minmax(0,1fr)) auto}.ledger-event{grid-template-columns:122px minmax(80px,1fr) minmax(130px,2fr) minmax(85px,1fr)}.ledger-place{grid-column:2/4}.ledger-result{grid-column:4;grid-row:1/3}.ledger-labels{display:none}}
21
+ @media(max-width:600px){.ledger-filters{grid-template-columns:repeat(2,minmax(0,1fr))}.ledger-event{grid-template-columns:minmax(0,1fr) auto;gap:2px 8px;padding:8px 10px}.ledger-actor{grid-column:1;grid-row:1}.ledger-action{grid-column:1;grid-row:2}.ledger-result{grid-column:2;grid-row:1/3;text-align:right}.ledger-time{grid-column:1;grid-row:3}.ledger-place{grid-column:2;grid-row:3;text-align:right}.ledger-place a{display:inline}.ledger-heading>a{font-size:13px}}
22
+ .ledger-chain-panel{margin:0 0 12px}.ledger-chain{margin:0 0 4px;font-size:13px}.ledger-chain.ok strong{color:var(--success)}.ledger-checkpoint code{overflow-wrap:anywhere;user-select:all}.ledger-checkpoint-form{margin:6px 0 0}.ledger-export{grid-template-columns:repeat(2,minmax(0,180px)) auto;margin-bottom:4px}
23
+ `;
19
24
  export function ledgerBody(rows, projects, params, chain) {
20
- const selected = (key, value) => params.get(key) === value ? " selected" : "";
25
+ const selected = (key, value) => params.get(key) === value ? html ` selected` : "";
21
26
  const more = rows.length > 50;
22
27
  const shown = rows.slice(0, 50);
23
28
  const fresh = new URLSearchParams();
@@ -31,29 +36,22 @@ export function ledgerBody(rows, projects, params, chain) {
31
36
  next.set("before", String(shown.at(-1).id));
32
37
  const csv = new URLSearchParams(fresh);
33
38
  csv.set("format", "csv");
34
- return `<style>
35
- .ledger-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.ledger-heading h1{margin:0}.ledger-caption{margin:8px 0 12px}
36
- .ledger-filter-panel{margin-bottom:12px}.ledger-filter-panel summary{font-size:13px;padding:6px 0;cursor:pointer}.ledger-filters{display:grid;grid-template-columns:repeat(5,minmax(0,1fr)) auto auto;gap:8px;align-items:end;margin:0 0 12px}.ledger-filters label{display:grid;gap:3px;min-width:0;font-size:12px}.ledger-filters input,.ledger-filters select{width:100%;min-width:0;box-sizing:border-box;margin:0;padding:6px 8px;font-size:13px}.ledger-filters button{padding:7px 12px}.ledger-filters>a{padding:8px 0;font-size:13px}
37
- .ledger-detail{display:block;color:var(--muted-foreground);font-size:12px}.ledger-list{border:1px solid var(--border);border-radius:8px;overflow:hidden}.ledger-event{display:grid;grid-template-columns:140px minmax(90px,1fr) minmax(160px,2fr) minmax(100px,1fr) minmax(100px,1fr);gap:8px;align-items:baseline;padding:7px 10px;border-bottom:1px solid var(--border);font-size:13px;line-height:1.4;overflow-wrap:anywhere}.ledger-event:last-child{border-bottom:0}.ledger-event .actor{color:inherit;font-weight:600;text-decoration:none}.ledger-time,.ledger-place,.ledger-result small{font-size:11px;color:var(--muted-foreground)}.ledger-time time{font-variant-numeric:tabular-nums}.ledger-place a{display:block}.ledger-result small{display:block}.ledger-labels{font-size:11px;color:var(--muted-foreground);font-weight:600}.ledger-empty{padding:12px}
38
- @media(max-width:1000px){.ledger-filters{grid-template-columns:repeat(3,minmax(0,1fr)) auto}.ledger-event{grid-template-columns:122px minmax(80px,1fr) minmax(130px,2fr) minmax(85px,1fr)}.ledger-place{grid-column:2/4}.ledger-result{grid-column:4;grid-row:1/3}.ledger-labels{display:none}}
39
- @media(max-width:600px){.ledger-filters{grid-template-columns:repeat(2,minmax(0,1fr))}.ledger-event{grid-template-columns:minmax(0,1fr) auto;gap:2px 8px;padding:8px 10px}.ledger-actor{grid-column:1;grid-row:1}.ledger-action{grid-column:1;grid-row:2}.ledger-result{grid-column:2;grid-row:1/3;text-align:right}.ledger-time{grid-column:1;grid-row:3}.ledger-place{grid-column:2;grid-row:3;text-align:right}.ledger-place a{display:inline}.ledger-heading>a{font-size:13px}}
40
- .ledger-chain-panel{margin:0 0 12px}.ledger-chain{margin:0 0 4px;font-size:13px}.ledger-chain.ok strong{color:var(--success)}.ledger-checkpoint code{overflow-wrap:anywhere;user-select:all}.ledger-checkpoint-form{margin:6px 0 0}.ledger-export{grid-template-columns:repeat(2,minmax(0,180px)) auto;margin-bottom:4px}
41
- </style><div class="ledger-heading"><h1>Action ledger</h1><a href="/ledger?${html(csv.toString())}" download>Export CSV</a></div>${chain === undefined ? "" : chainHtml(chain)}
39
+ return html `${styleElement(LEDGER_CSS)}<div class="ledger-heading"><h1>Action ledger</h1><a href="/ledger?${csv.toString()}" download>Export CSV</a></div>${chain === undefined ? "" : chainHtml(chain)}
42
40
  <p class="meta ledger-caption">${shown.length} actions on this page · CSV includes all matching actions · Times in UTC</p>
43
41
  <details class="ledger-filter-panel"><summary>Filters · ${fresh.size === 0 ? "all actions" : `${fresh.size} active`}</summary>
44
42
  <form method="get" action="/ledger" class="ledger-filters" aria-label="Filter action ledger">
45
- <label>Project<select name="project"><option value="">All accessible projects</option>${projects.map(repo => `<option value="${html(repo)}"${selected("project", repo)}>${html(projectName(repo))}</option>`).join("")}</select></label>
46
- <label>Person or worker<input type="text" name="actor" value="${html(params.get("actor") ?? "")}" placeholder="Any actor"></label>
47
- <label>Events<select name="source"><option value="">All events</option>${EVENT_KINDS.map(([value, label]) => `<option value="${value}"${selected("source", value)}>${label}</option>`).join("")}</select></label>
48
- <label>Outcome<input type="text" name="outcome" value="${html(params.get("outcome") ?? "")}" placeholder="Any outcome"></label>
49
- <label>Task<input type="text" name="task" value="${html(params.get("task") ?? "")}" placeholder="Any task"></label>
43
+ <label>Project<select name="project"><option value="">All accessible projects</option>${projects.map(repo => html `<option value="${repo}"${selected("project", repo)}>${projectName(repo)}</option>`)}</select></label>
44
+ <label>Person or worker<input type="text" name="actor" value="${params.get("actor") ?? ""}" placeholder="Any actor"></label>
45
+ <label>Events<select name="source"><option value="">All events</option>${EVENT_KINDS.map(([value, label]) => html `<option value="${value}"${selected("source", value)}>${label}</option>`)}</select></label>
46
+ <label>Outcome<input type="text" name="outcome" value="${params.get("outcome") ?? ""}" placeholder="Any outcome"></label>
47
+ <label>Task<input type="text" name="task" value="${params.get("task") ?? ""}" placeholder="Any task"></label>
50
48
  <button type="submit">Filter</button><a href="/ledger">Clear</a></form></details>
51
- <div class="ledger-list" aria-label="Action history"><div class="ledger-event ledger-labels" aria-hidden="true"><span>Time</span><span>Actor</span><span>Action</span><span>Project / work</span><span>Outcome / event</span></div>${shown.length === 0 ? `<p class="ledger-empty">No actions match these filters yet.</p>` : shown.map(row => {
52
- const subject = row.runId !== null ? `<a href="/r/${row.runId}">Run #${row.runId}</a>` : row.taskId !== null ? `<a href="/t/${encodeURIComponent(row.taskId)}">${html(row.taskId)}</a>` : "";
49
+ <div class="ledger-list" aria-label="Action history"><div class="ledger-event ledger-labels" aria-hidden="true"><span>Time</span><span>Actor</span><span>Action</span><span>Project / work</span><span>Outcome / event</span></div>${shown.length === 0 ? html `<p class="ledger-empty">No actions match these filters yet.</p>` : shown.map(row => {
50
+ const subject = row.runId !== null ? html `<a href="/r/${row.runId}">Run #${row.runId}</a>` : row.taskId !== null ? html `<a href="/t/${encodeURIComponent(row.taskId)}">${row.taskId}</a>` : "";
53
51
  const byActor = new URLSearchParams(fresh);
54
52
  byActor.set("actor", row.actor);
55
- return `<article class="ledger-event" data-ledger-id="${row.id}"><div class="ledger-time">${exactWhenHtml(row.at)}</div><div class="ledger-actor"><a class="actor" href="/ledger?${html(byActor.toString())}" title="Filter by this actor">${html(row.actor)}</a></div><div class="ledger-action">${html(row.action)}${row.detail === null ? "" : `<small class="ledger-detail">${html(row.detail)}</small>`}</div><div class="ledger-place">${row.repo === null ? "Instance" : html(projectName(row.repo))} ${subject}</div><div class="ledger-result">${html(row.outcome)}<small>${html(row.source)}</small></div></article>`;
56
- }).join("")}</div>
57
- <p class="row"><a href="/ledger?${html(fresh.toString())}">Newest actions</a>${more ? `<a rel="next" href="/ledger?${html(next.toString())}">Older actions</a>` : ""}</p>
53
+ return html `<article class="ledger-event" data-ledger-id="${row.id}"><div class="ledger-time">${exactWhenHtml(row.at)}</div><div class="ledger-actor"><a class="actor" href="/ledger?${byActor.toString()}" title="Filter by this actor">${row.actor}</a></div><div class="ledger-action">${row.action}${row.detail === null ? "" : html `<small class="ledger-detail">${row.detail}</small>`}</div><div class="ledger-place">${row.repo === null ? "Instance" : projectName(row.repo)} ${subject}</div><div class="ledger-result">${row.outcome}<small>${row.source}</small></div></article>`;
54
+ })}</div>
55
+ <p class="row"><a href="/ledger?${fresh.toString()}">Newest actions</a>${more ? html `<a rel="next" href="/ledger?${next.toString()}">Older actions</a>` : ""}</p>
58
56
  <p class="meta">Accepted requests record acceptance; worker events record results. History starts from this upgrade. Review evidence is on each task or run.</p>`;
59
57
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { BrowserLimits } from "./browser-workspace.js";
8
8
  import { type BudgetState } from "./spend.js";
9
+ import { type Html } from "./html.js";
9
10
  /** "in 25 min", "in 2 h 10 min", or a day and hour ("Fri 11 PM") beyond a day. */
10
11
  export declare function resetWords(resetsAt: string, now: Date): string;
11
12
  export declare function limitsView(windows: readonly {
@@ -22,5 +23,5 @@ export declare function limitsView(windows: readonly {
22
23
  teammate: (id: number) => string;
23
24
  }, now: Date): BrowserLimits | null;
24
25
  /** The same tiles for the page without scripts. */
25
- export declare function limitsHtml(limits: BrowserLimits | null): string;
26
+ export declare function limitsHtml(limits: BrowserLimits | null): Html;
26
27
  export declare const LIMITS_CSS: string;
package/dist/limits-ui.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { windowLabel } from "./provider-limits.js";
2
2
  import { usd } from "./spend.js";
3
+ import { html } from "./html.js";
3
4
  const PROVIDER_NAMES = { claude: "Claude", codex: "Codex", gemini: "Gemini" };
4
5
  /** Plan names worth showing; anything else (an internal billing name) is left off. */
5
6
  const PLAN_NAMES = { free: "Free", plus: "Plus", pro: "Pro", max: "Max", team: "Team", business: "Business", enterprise: "Enterprise", edu: "Edu" };
@@ -47,16 +48,15 @@ export function limitsView(windows, budgets, names, now) {
47
48
  }
48
49
  return tiles.length === 0 ? null : { tiles };
49
50
  }
50
- const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
51
51
  /** The same tiles for the page without scripts. */
52
52
  export function limitsHtml(limits) {
53
53
  if (limits === null)
54
- return "";
55
- return `<section class="limits" aria-label="Limits"><ul>${limits.tiles.map(tile => {
56
- const body = `<p class="limit-name"><strong>${e(tile.name)}</strong> ${e(tile.window)}</p><p class="limit-value"><strong>${e(tile.value)}</strong> ${e(tile.unit)}</p>` +
57
- `<span class="limit-bar" aria-hidden="true"><span style="width:${Math.min(100, Math.max(0, tile.percent))}%"></span></span><p class="limit-detail">${e(tile.detail)}</p>`;
58
- return `<li class="limit limit-${tile.tone}" data-limit="${e(tile.key)}"${tile.title === null ? "" : ` title="${e(tile.title)}"`}>${tile.href === null ? body : `<a href="${e(tile.href)}">${body}</a>`}</li>`;
59
- }).join("")}</ul></section>`;
54
+ return html ``;
55
+ return html `<section class="limits" aria-label="Limits"><ul>${limits.tiles.map(tile => {
56
+ const body = html `<p class="limit-name"><strong>${tile.name}</strong> ${tile.window}</p><p class="limit-value"><strong>${tile.value}</strong> ${tile.unit}</p>\
57
+ <span class="limit-bar" aria-hidden="true"><span style="width:${Math.min(100, Math.max(0, tile.percent))}%"></span></span><p class="limit-detail">${tile.detail}</p>`;
58
+ return html `<li class="limit limit-${tile.tone}" data-limit="${tile.key}"${tile.title === null ? "" : html ` title="${tile.title}"`}>${tile.href === null ? body : html `<a href="${tile.href}">${body}</a>`}</li>`;
59
+ })}</ul></section>`;
60
60
  }
61
61
  export const LIMITS_CSS = `.limits ul{list-style:none;margin:0 0 16px;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(176px,1fr));gap:8px}` +
62
62
  `.limit{border:1px solid var(--border);border-radius:10px;background:var(--card);padding:12px 14px;min-width:0}.limit a{color:inherit;text-decoration:none;display:block}` +
@@ -15,7 +15,10 @@
15
15
  * an invalidation, never content. The page reads itself again under its
16
16
  * own checks, so redaction stays the page's.
17
17
  * - The safety net. Every room also checks itself about every 30 s, so a
18
- * missed signal heals, and sends a keep-alive between.
18
+ * missed signal heals; the page's connection sends a keep-alive between,
19
+ * each after every room proves its viewer again.
20
+ * - One connection per page. A page opens one stream (GET /live) and joins
21
+ * every room it shows; each event names its room.
19
22
  * - Back-pressure. Each page's stream is bounded: a page that falls behind
20
23
  * gets one `reload` and ends so its EventSource can reconnect.
21
24
  *
@@ -67,23 +70,62 @@ export declare class LiveStream {
67
70
  send(event: string, data: unknown): void;
68
71
  keepAlive(): void;
69
72
  }
73
+ /**
74
+ * One page's live connection: its bounded stream, one keep-alive, and every
75
+ * room it joined. Each event names its room, so one EventSource serves the
76
+ * whole page. It ends when the page goes, when it falls behind (one
77
+ * `reload`), or when its last room lets it go. A keep-alive is a delivery
78
+ * too: every room proves its viewer again first, so a page whose session
79
+ * lapsed with nothing written hears its rooms go, never another frame.
80
+ */
81
+ export declare class LiveConnection {
82
+ readonly response: ServerResponse;
83
+ readonly stream: LiveStream;
84
+ private readonly leaves;
85
+ private readonly proofs;
86
+ private readonly timer;
87
+ private closed;
88
+ constructor(response: ServerResponse, options?: {
89
+ limitBytes?: number;
90
+ keepAliveMs?: number;
91
+ });
92
+ private keepAlive;
93
+ /** Run before each keep-alive: a room proves its viewer again (and leaves if it no longer passes). Returns the undo. */
94
+ onKeepAlive(prove: () => void): () => void;
95
+ get open(): boolean;
96
+ /** An event for one room: its data says which. */
97
+ send(room: string, event: string, data?: Record<string, unknown>): void;
98
+ /** Run when the connection ends; returns the undo for a room that leaves first. */
99
+ onClose(leave: () => void): () => void;
100
+ close(): void;
101
+ /** Rooms joined and not yet left. */
102
+ get rooms(): number;
103
+ }
104
+ /** One page in one room: who, the room's name as the page asked for it, the page's connection, and its re-proof. */
70
105
  export type LiveViewer = {
71
106
  name: string;
72
- response: ServerResponse;
107
+ room: string;
108
+ connection: LiveConnection;
73
109
  valid: () => boolean;
74
110
  };
75
111
  export type LiveRooms<K, V extends LiveViewer> = {
76
- /** Keep this page's stream on the room until it closes; the first events say where it stands and who's here. */
112
+ /** Keep this page on the room until it leaves; the first events say where the room stands (and who's here). */
77
113
  join: (key: K, viewer: V) => void;
78
- /** Every stream, ended (the server is closing). */
114
+ /** Every page leaves (the server is closing). */
79
115
  close: () => void;
80
116
  size: () => number;
81
117
  };
118
+ /** `minIntervalMs`: a busy room says `change` at most this often — the first at once, the latest after. */
82
119
  export type LiveRoomOptions = {
83
120
  bus?: LiveBus | null;
84
121
  safetyNetMs?: number;
85
- keepAliveMs?: number;
86
- limitBytes?: number;
122
+ minIntervalMs?: number;
87
123
  };
88
- /** Rooms of open pages that hear the bus. `presence` says who else is here, as each page should see it. */
89
- export declare function createLiveRooms<K, V extends LiveViewer>(fingerprint: (key: K) => string | null, presence: (viewers: readonly V[], me: string) => unknown, options?: LiveRoomOptions): LiveRooms<K, V>;
124
+ /**
125
+ * Rooms of open pages that hear the bus. `presence` says who else is here, as
126
+ * each page should see it (null: the room has no presence). A viewer that no
127
+ * longer passes its re-proof hears `gone` and leaves; a room whose subject is
128
+ * gone (fingerprint null) sends everyone `gone`. A connection with no rooms
129
+ * left ends.
130
+ */
131
+ export declare function createLiveRooms<K, V extends LiveViewer>(fingerprint: (key: K) => string | null, presence: ((viewers: readonly V[], me: string) => unknown) | null, options?: LiveRoomOptions): LiveRooms<K, V>;
package/dist/live-bus.js CHANGED
@@ -15,7 +15,10 @@
15
15
  * an invalidation, never content. The page reads itself again under its
16
16
  * own checks, so redaction stays the page's.
17
17
  * - The safety net. Every room also checks itself about every 30 s, so a
18
- * missed signal heals, and sends a keep-alive between.
18
+ * missed signal heals; the page's connection sends a keep-alive between,
19
+ * each after every room proves its viewer again.
20
+ * - One connection per page. A page opens one stream (GET /live) and joins
21
+ * every room it shows; each event names its room.
19
22
  * - Back-pressure. Each page's stream is bounded: a page that falls behind
20
23
  * gets one `reload` and ends so its EventSource can reconnect.
21
24
  *
@@ -183,34 +186,98 @@ export class LiveStream {
183
186
  this.write(": keep-alive\n\n");
184
187
  }
185
188
  }
189
+ /**
190
+ * One page's live connection: its bounded stream, one keep-alive, and every
191
+ * room it joined. Each event names its room, so one EventSource serves the
192
+ * whole page. It ends when the page goes, when it falls behind (one
193
+ * `reload`), or when its last room lets it go. A keep-alive is a delivery
194
+ * too: every room proves its viewer again first, so a page whose session
195
+ * lapsed with nothing written hears its rooms go, never another frame.
196
+ */
197
+ export class LiveConnection {
198
+ response;
199
+ stream;
200
+ leaves = new Set();
201
+ proofs = new Set();
202
+ timer;
203
+ closed = false;
204
+ constructor(response, options = {}) {
205
+ this.response = response;
206
+ this.stream = new LiveStream(response, options.limitBytes, () => this.close());
207
+ this.timer = setInterval(() => this.keepAlive(), options.keepAliveMs ?? KEEP_ALIVE_MS);
208
+ this.timer.unref();
209
+ response.once("close", () => this.close());
210
+ }
211
+ keepAlive() {
212
+ for (const prove of [...this.proofs]) {
213
+ if (!this.open)
214
+ return;
215
+ prove();
216
+ }
217
+ if (this.open)
218
+ this.stream.keepAlive();
219
+ }
220
+ /** Run before each keep-alive: a room proves its viewer again (and leaves if it no longer passes). Returns the undo. */
221
+ onKeepAlive(prove) { this.proofs.add(prove); return () => { this.proofs.delete(prove); }; }
222
+ get open() { return !this.closed && this.stream.open; }
223
+ /** An event for one room: its data says which. */
224
+ send(room, event, data = {}) { this.stream.send(event, { room, ...data }); }
225
+ /** Run when the connection ends; returns the undo for a room that leaves first. */
226
+ onClose(leave) { this.leaves.add(leave); return () => { this.leaves.delete(leave); }; }
227
+ close() {
228
+ if (this.closed)
229
+ return;
230
+ this.closed = true;
231
+ clearInterval(this.timer);
232
+ for (const leave of [...this.leaves])
233
+ leave();
234
+ this.leaves.clear();
235
+ this.proofs.clear();
236
+ if (!this.response.writableEnded)
237
+ this.response.end();
238
+ }
239
+ /** Rooms joined and not yet left. */
240
+ get rooms() { return this.leaves.size; }
241
+ }
186
242
  const PRESENCE_SETTLE_MS = 150;
187
- /** Rooms of open pages that hear the bus. `presence` says who else is here, as each page should see it. */
243
+ /**
244
+ * Rooms of open pages that hear the bus. `presence` says who else is here, as
245
+ * each page should see it (null: the room has no presence). A viewer that no
246
+ * longer passes its re-proof hears `gone` and leaves; a room whose subject is
247
+ * gone (fingerprint null) sends everyone `gone`. A connection with no rooms
248
+ * left ends.
249
+ */
188
250
  export function createLiveRooms(fingerprint, presence, options = {}) {
189
251
  const bus = options.bus ?? null;
190
- const keepAliveMs = options.keepAliveMs ?? KEEP_ALIVE_MS;
191
- const checkEvery = Math.max(1, Math.round((options.safetyNetMs ?? SAFETY_NET_MS) / keepAliveMs));
252
+ const safetyNetMs = options.safetyNetMs ?? SAFETY_NET_MS;
253
+ const minIntervalMs = options.minIntervalMs ?? 0;
192
254
  const rooms = new Map();
193
255
  let unsubscribe = null;
194
- const here = (room, viewer) => presence([...room.viewers.keys()], viewer.name);
195
- const announce = (room) => {
256
+ const announce = (key, room) => {
257
+ if (presence === null || rooms.get(key) !== room)
258
+ return;
196
259
  // A page that moves from one card to the next leaves and joins at once: say it once, after it settles.
197
260
  if (room.settling !== null)
198
261
  clearTimeout(room.settling);
199
262
  room.settling = setTimeout(() => {
200
263
  room.settling = null;
201
- for (const [viewer, stream] of room.viewers)
202
- stream.send("here", { people: here(room, viewer) });
264
+ for (const viewer of [...room.viewers.keys()])
265
+ deliver(key, room, viewer, "here", { people: presence([...room.viewers.keys()], viewer.name) });
203
266
  }, PRESENCE_SETTLE_MS);
204
267
  room.settling.unref();
205
268
  };
206
269
  const leave = (key, viewer) => {
207
270
  const room = rooms.get(key);
208
- if (room === undefined || !room.viewers.delete(viewer))
271
+ const forget = room?.viewers.get(viewer);
272
+ if (room === undefined || forget === undefined)
209
273
  return;
210
- if (!viewer.response.writableEnded)
211
- viewer.response.end();
274
+ room.viewers.delete(viewer);
275
+ forget();
276
+ // The page's last room: its connection has nothing left to say.
277
+ if (viewer.connection.rooms === 0)
278
+ viewer.connection.close();
212
279
  if (room.viewers.size > 0) {
213
- announce(room);
280
+ announce(key, room);
214
281
  return;
215
282
  }
216
283
  clearInterval(room.timer);
@@ -218,30 +285,46 @@ export function createLiveRooms(fingerprint, presence, options = {}) {
218
285
  clearTimeout(room.settling);
219
286
  if (room.retry !== null)
220
287
  clearTimeout(room.retry);
288
+ if (room.holding !== null)
289
+ clearTimeout(room.holding);
221
290
  rooms.delete(key);
222
291
  if (rooms.size === 0 && unsubscribe !== null) {
223
292
  unsubscribe();
224
293
  unsubscribe = null;
225
294
  }
226
295
  };
227
- const drop = (key, viewer) => {
228
- rooms.get(key)?.viewers.get(viewer)?.send("gone", {});
229
- leave(key, viewer);
230
- };
231
- /** Recheck who may still see the room, then whether what it shows moved. */
232
- const refresh = (key, room, revision) => {
233
- // A signed-out or narrowed account stops hearing about it, at the first write after.
234
- for (const viewer of [...room.viewers.keys()]) {
235
- let ok = false;
296
+ /** The final delivery boundary, including queued frames and sends that may overflow. */
297
+ const deliver = (key, room, viewer, event, data = {}) => {
298
+ if (rooms.get(key) !== room || !room.viewers.has(viewer))
299
+ return false;
300
+ if (!viewer.connection.open) {
301
+ leave(key, viewer);
302
+ return false;
303
+ }
304
+ let valid = false;
305
+ try {
306
+ valid = viewer.valid();
307
+ }
308
+ catch { /* A failed proof releases only this room. */ }
309
+ if (!valid || event === "gone") {
310
+ // A failed proof may send only the generic departure, never the pending room data.
236
311
  try {
237
- ok = viewer.valid();
312
+ viewer.connection.send(viewer.room, "gone");
238
313
  }
239
- catch {
240
- ok = false;
314
+ finally {
315
+ leave(key, viewer);
241
316
  }
242
- if (!ok)
243
- drop(key, viewer);
317
+ return false;
244
318
  }
319
+ if (event !== undefined)
320
+ viewer.connection.send(viewer.room, event, data);
321
+ return viewer.connection.open && room.viewers.has(viewer);
322
+ };
323
+ /** Recheck who may still see the room, then whether what it shows moved. */
324
+ const refresh = (key, room, revision) => {
325
+ // A signed-out or narrowed account stops hearing about it, at the first write after.
326
+ for (const viewer of [...room.viewers.keys()])
327
+ deliver(key, room, viewer);
245
328
  if (rooms.get(key) !== room)
246
329
  return;
247
330
  let now;
@@ -259,25 +342,28 @@ export function createLiveRooms(fingerprint, presence, options = {}) {
259
342
  }
260
343
  if (now === null) {
261
344
  for (const viewer of [...room.viewers.keys()])
262
- drop(key, viewer);
345
+ deliver(key, room, viewer, "gone");
263
346
  return;
264
347
  }
265
348
  if (now === room.last)
266
349
  return;
267
350
  room.last = now;
268
- for (const stream of room.viewers.values())
269
- stream.send("change", { at: now, revision: revision ?? bus?.revision() ?? null });
270
- };
271
- const tick = (key, room) => {
272
- room.ticks += 1;
273
- if (room.ticks % checkEvery === 0) {
274
- const before = room.last;
275
- refresh(key, room, null);
276
- if (rooms.get(key) === room && room.last !== before)
277
- return;
351
+ room.revision = revision ?? bus?.revision() ?? null;
352
+ if (room.holding !== null)
353
+ return;
354
+ const wait = room.saidAt + minIntervalMs - Date.now();
355
+ if (wait > 0) {
356
+ room.holding = setTimeout(() => { room.holding = null; if (rooms.get(key) === room)
357
+ say(key, room); }, wait);
358
+ room.holding.unref();
359
+ return;
278
360
  }
279
- for (const stream of room.viewers.values())
280
- stream.keepAlive();
361
+ say(key, room);
362
+ };
363
+ const say = (key, room) => {
364
+ room.saidAt = Date.now();
365
+ for (const viewer of [...room.viewers.keys()])
366
+ deliver(key, room, viewer, "change", { at: room.last, revision: room.revision });
281
367
  };
282
368
  const heard = (change) => {
283
369
  for (const [key, room] of [...rooms])
@@ -292,24 +378,21 @@ export function createLiveRooms(fingerprint, presence, options = {}) {
292
378
  last = fingerprint(key);
293
379
  }
294
380
  catch { /* the next signal or the safety net reads it */ }
295
- const created = { viewers: new Map(), last, timer: setInterval(() => tick(key, created), keepAliveMs), ticks: 0, settling: null, retry: null };
381
+ const created = { viewers: new Map(), last, timer: setInterval(() => refresh(key, created, null), safetyNetMs), settling: null, retry: null, saidAt: 0, holding: null, revision: null };
296
382
  created.timer.unref();
297
383
  room = created;
298
384
  rooms.set(key, room);
299
385
  if (bus !== null && unsubscribe === null)
300
386
  unsubscribe = bus.subscribe(heard);
301
387
  }
302
- // Remove an overflowed page immediately, even while its final bytes
303
- // wait for the client. Peers must not keep seeing stale presence.
304
- const stream = new LiveStream(viewer.response, options.limitBytes, () => leave(key, viewer));
305
- room.viewers.set(viewer, stream);
306
- viewer.response.once("close", () => leave(key, viewer));
307
- stream.send("change", { at: room.last, revision: bus?.revision() ?? null });
308
- if (!stream.open)
388
+ const joined = room;
389
+ const forget = viewer.connection.onClose(() => leave(key, viewer));
390
+ const unprove = viewer.connection.onKeepAlive(() => { deliver(key, joined, viewer); });
391
+ room.viewers.set(viewer, () => { forget(); unprove(); });
392
+ if (!deliver(key, room, viewer, "change", { at: room.last, revision: bus?.revision() ?? null }) || presence === null)
309
393
  return;
310
- stream.send("here", { people: here(room, viewer) });
311
- if (stream.open)
312
- announce(room);
394
+ if (deliver(key, room, viewer, "here", { people: presence([...room.viewers.keys()], viewer.name) }))
395
+ announce(key, room);
313
396
  },
314
397
  close() {
315
398
  for (const [key, room] of [...rooms])
@@ -494,7 +494,7 @@ const MATE_TOOL_HANDLERS = {
494
494
  description: "When the owner asks to approve one thing (a plan waiting on them, or a flow card waiting at a Person decides step), send that item's own Approve and Send back buttons to their paired chat right away. Their tap there is the approval (two taps, as on any decision card); you never approve. Give task for a plan, or card for a flow card (its number from get_flows). When it can't be approved in chat, the answer says why: say that in one line and open show_control approval (a plan) or the flow, never that you can't approve.",
495
495
  handle: (ctx, args) => {
496
496
  const pairedHere = ctx.channel === "telegram";
497
- const paired = ctx.store.handle.prepare("SELECT 1 FROM telegram_binding WHERE approver = ? AND approver_generation = ? AND revoked_at IS NULL LIMIT 1")
497
+ const paired = ctx.store.handle.prepare("SELECT 1 FROM chat_binding WHERE provider = 'telegram' AND approver = ? AND generation = ? AND revoked IS NULL LIMIT 1")
498
498
  .get(ctx.who.name, ctx.store.accountOf(ctx.who.name)?.generation ?? -1) !== undefined;
499
499
  const stamp = ctx.now.getTime();
500
500
  if (args["card"] !== undefined) {
@@ -520,7 +520,8 @@ const MATE_TOOL_HANDLERS = {
520
520
  if (task === null || admittedRef(ctx, task) === null)
521
521
  return notFound();
522
522
  const current = ctx.store.taskFamilyOf(task, ctx.who.repos, false)?.current.id ?? task;
523
- const plan = planInChat(ctx.store, current, ctx.who.name, ctx.now, ctx.evidenceRoot);
523
+ // Its buttons go to the paired Telegram chat (pairedHere), so it is that chat's authority that counts.
524
+ const plan = planInChat(ctx.store, current, ctx.who.name, ctx.now, "telegram", ctx.evidenceRoot);
524
525
  if (!plan.ok)
525
526
  return { ok: true, body: { offered: false, task: current, why: plan.why, control: "approval" } };
526
527
  if (!pairedHere || !paired)