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
package/dist/mcp-oauth.js CHANGED
@@ -4,6 +4,8 @@ import { hashSecret, mintApiToken } from "./api-tokens.js";
4
4
  import { limitWords } from "./request-budget.js";
5
5
  import { sourceKey } from "./source-key.js";
6
6
  import { PKCE_CHALLENGE, readOAuthRegistration, readOAuthTokenRequest } from "./contracts/mcp-oauth.js";
7
+ import { html, htmlString, joinHtml, postForm, styleElement } from "./html.js";
8
+ import { withFormToken } from "./server/request-context.js";
7
9
  /** How long each piece lives. A code is spent at once; a grant ends after 30 days and the person consents again. */
8
10
  export const OAUTH_TIMES = { requestMs: 10 * 60_000, codeMs: 60_000, accessSeconds: 3600, grantDays: 30 };
9
11
  /** The most sign-ins waiting at once, and registered clients kept. */
@@ -33,7 +35,6 @@ export function oauthTokenAllowed(store, tokenId, path, now) {
33
35
  export const oauthProjects = (store, tokenId) => store.oauthGrant(tokenId)?.projects ?? null;
34
36
  const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
35
37
  const same = (a, b) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); };
36
- const escape = (text) => text.replace(/[&<>"']/g, one => `&#${one.charCodeAt(0)};`);
37
38
  const clean = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]+/g, " ").replace(/\s+/g, " ").trim();
38
39
  /**
39
40
  * A redirect a client may register: https anywhere, or http on 127.0.0.1 / [::1] (RFC 8252 §7.3), with no
@@ -80,10 +81,10 @@ export function createOAuthHttp(options) {
80
81
  };
81
82
  const oauthError = (response, status, error, description) => json(response, status, { error, error_description: description });
82
83
  /** A page of this sign-in: script-free, never framed, posting only to this console. */
83
- const page = (response, status, html) => {
84
+ const page = (response, status, document) => {
84
85
  response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", "x-frame-options": "DENY",
85
86
  "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'" });
86
- response.end(html);
87
+ response.end(htmlString(document));
87
88
  };
88
89
  /**
89
90
  * On to an address by a page that moves on by itself, never a redirect: a redirect answering a form is held to
@@ -93,9 +94,9 @@ export function createOAuthHttp(options) {
93
94
  const moveOn = (response, to, words) => {
94
95
  response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", "x-frame-options": "DENY",
95
96
  "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" });
96
- response.end(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="0;url=${escape(to)}"><title>Toolroll</title>${STYLE}<main><p>${escape(words)} <a href="${escape(to)}">Continue</a></p></main></html>`);
97
+ response.end(htmlString(html `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="0;url=${to}"><title>Toolroll</title>${STYLE}<main><p>${words} <a href="${to}">Continue</a></p></main></html>`));
97
98
  };
98
- const problemPage = (response, status, title, said) => page(response, status, shell(title, `<h1>${escape(title)}</h1><p>${escape(said)}</p>`));
99
+ const problemPage = (response, status, title, said) => page(response, status, shell(title, html `<h1>${title}</h1><p>${said}</p>`));
99
100
  /** Back to the client with an answer: the code, or an error, and its state; `iss` names this console (RFC 9207). */
100
101
  const backToClient = (response, origin, redirectUri, state, answer, words) => {
101
102
  const to = new URL(redirectUri);
@@ -234,34 +235,34 @@ export function createOAuthHttp(options) {
234
235
  const title = "Connect to Toolroll";
235
236
  const projectList = projects.length === 0 ? "" : projects.map(path => {
236
237
  const name = path.split("/").filter(part => part !== "").pop() ?? path;
237
- return `<label class="pick"><input type="checkbox" name="project" value="${escape(path)}"${checked.has(path) ? " checked" : ""}><span><b>${escape(name)}</b><small>${escape(path)}</small></span></label>`;
238
- }).join("");
238
+ return html `<label class="pick"><input type="checkbox" name="project" value="${path}"${checked.has(path) ? " checked" : ""}><span><b>${name}</b><small>${path}</small></span></label>`;
239
+ });
239
240
  const step = session.sso?.fresh === true
240
- ? `<p class="meta">Confirmed with ${escape(session.sso.label)}.</p><input type="hidden" name="password" value="">`
241
- : `<label class="field">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>${session.sso === null ? "" : `<p class="meta"><a href="/login/sso?reauth=1&amp;return=${escape(encodeURIComponent(`/oauth/authorize?request=${id}`))}">Confirm with ${escape(session.sso.label)}</a> instead.</p>`}`;
242
- const body = [
243
- `<h1>${escape(title)}</h1>`,
244
- `<p class="client"><b>${escape(client.name)}</b> <span class="tag">unverified</span> <span class="meta">returns to ${escape(host)}</span></p>`,
245
- problem === null ? "" : `<div class="problem" role="alert">${escape(problem)}</div>`,
241
+ ? html `<p class="meta">Confirmed with ${session.sso.label}.</p><input type="hidden" name="password" value="">`
242
+ : html `<label class="field">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>${session.sso === null ? "" : html `<p class="meta"><a href="/login/sso?reauth=1&amp;return=${encodeURIComponent(`/oauth/authorize?request=${id}`)}">Confirm with ${session.sso.label}</a> instead.</p>`}`;
243
+ // The consent form carries the console session's own CSRF token (checked against session.csrf on the post).
244
+ const consentForm = (body) => withFormToken(session.csrf, () => postForm("/oauth/authorize", body, { hidden: { request: id } }));
245
+ const body = joinHtml([
246
+ html `<h1>${title}</h1>`,
247
+ html `<p class="client"><b>${client.name}</b> <span class="tag">unverified</span> <span class="meta">returns to ${host}</span></p>`,
248
+ problem === null ? "" : html `<div class="problem" role="alert">${problem}</div>`,
246
249
  projects.length === 0
247
- ? `<p>You don't have access to any projects. Ask your Toolroll administrator to add you to one.</p>`
248
- + `<form method="post" action="/oauth/authorize"><input type="hidden" name="request" value="${escape(id)}"><input type="hidden" name="csrf" value="${escape(session.csrf)}"><button name="decision" value="deny" class="secondary">Cancel</button></form>`
249
- : [
250
- `<p>Connects as ${escape(session.name)} in your selected projects. It can't approve work or change people or policy.</p>`,
251
- `<form method="post" action="/oauth/authorize">`,
252
- `<input type="hidden" name="request" value="${escape(id)}"><input type="hidden" name="csrf" value="${escape(session.csrf)}">`,
253
- `<fieldset><legend>Access</legend>`,
254
- `<label class="pick"><input type="radio" name="access" value="read"${access === "read" ? " checked" : ""}><span><b>Read</b><small>See tasks, results and status.</small></span></label>`,
255
- mayAct ? `<label class="pick"><input type="radio" name="access" value="act"${access === "act" ? " checked" : ""}><span><b>Act</b><small>Also file tasks under your name.</small></span></label>` : "",
256
- `</fieldset>`,
257
- `<fieldset><legend>Projects</legend>${projectList}</fieldset>`,
258
- `<p class="meta">Access lasts up to 30 days. Revoke it anytime in Settings → Sessions &amp; tokens.</p>`,
259
- step,
260
- `<div class="actions"><button name="decision" value="allow">Allow access</button><button name="decision" value="deny" class="secondary" formnovalidate>Cancel</button></div>`,
261
- `</form>`,
262
- ].join(""),
263
- `<details><summary>Client details</summary><p class="meta">Client ID <code>${escape(client.id)}</code><br>Returns to <code>${escape(one.redirectUri)}</code><br>Toolroll doesn't know who made this client; its name is what it says about itself.</p></details>`,
264
- ].join("");
250
+ ? html `<p>You don't have access to any projects. Ask your Toolroll administrator to add you to one.</p>${consentForm(html `<button name="decision" value="deny" class="secondary">Cancel</button>`)}`
251
+ : joinHtml([
252
+ html `<p>Connects as ${session.name} in your selected projects. It can't approve work or change people or policy.</p>`,
253
+ consentForm(joinHtml([
254
+ html `<fieldset><legend>Access</legend>`,
255
+ html `<label class="pick"><input type="radio" name="access" value="read"${access === "read" ? " checked" : ""}><span><b>Read</b><small>See tasks, results and status.</small></span></label>`,
256
+ mayAct ? html `<label class="pick"><input type="radio" name="access" value="act"${access === "act" ? " checked" : ""}><span><b>Act</b><small>Also file tasks under your name.</small></span></label>` : "",
257
+ html `</fieldset>`,
258
+ html `<fieldset><legend>Projects</legend>${projectList}</fieldset>`,
259
+ html `<p class="meta">Access lasts up to 30 days. Revoke it anytime in Settings → Sessions &amp; tokens.</p>`,
260
+ step,
261
+ html `<div class="actions"><button name="decision" value="allow">Allow access</button><button name="decision" value="deny" class="secondary" formnovalidate>Cancel</button></div>`,
262
+ ])),
263
+ ]),
264
+ html `<details><summary>Client details</summary><p class="meta">Client ID <code>${client.id}</code><br>Returns to <code>${one.redirectUri}</code><br>Toolroll doesn't know who made this client; its name is what it says about itself.</p></details>`,
265
+ ]);
265
266
  return shell(title, body);
266
267
  };
267
268
  async function authorizeConsent(request, response, url, origin) {
@@ -449,8 +450,7 @@ export function createOAuthHttp(options) {
449
450
  };
450
451
  }
451
452
  /** The sign-in's pages stand alone: no stylesheet, script or font loads here, so the palette rides inline. */
452
- const STYLE = [
453
- `<style>`,
453
+ const STYLE = styleElement([
454
454
  `:root{color-scheme:light dark;--ground:#efefef;--paper:#fff;--ink:#171717;--muted:#666;--line:#e6e6e6;--soft:#f2f2f2;--accent:#171717;--on-accent:#fff;--problem:#b42318}`,
455
455
  `@media (prefers-color-scheme:dark){:root{--ground:#0b0b0b;--paper:#161616;--ink:#ededed;--muted:#a1a1a1;--line:#262626;--soft:#1f1f1f;--accent:#ededed;--on-accent:#0b0b0b;--problem:#ff8a80}}`,
456
456
  `*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px 16px;background:var(--ground);color:var(--ink);font:15px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}`,
@@ -467,6 +467,5 @@ const STYLE = [
467
467
  `.problem{border:1px solid var(--problem);color:var(--problem);border-radius:8px;padding:10px 12px;margin-bottom:14px}a{color:inherit;text-underline-offset:3px}`,
468
468
  `details{border-top:1px solid var(--line)}summary{cursor:pointer;color:var(--muted);font-size:13px;min-height:44px;padding:12px 0}code{font:12.5px/1.5 ui-monospace,"SF Mono",Menlo,monospace;background:var(--soft);border-radius:5px;padding:1px 5px;overflow-wrap:anywhere}`,
469
469
  `@media (max-width:480px){main{padding:20px}.field input{font-size:16px}.actions button{flex:1 1 auto}}`,
470
- `</style>`,
471
- ].join("");
472
- const shell = (title, body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>${escape(title)}</title>${STYLE}</head><body><main>${body}</main></body></html>`;
470
+ ].join(""));
471
+ const shell = (title, body) => html `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>${title}</title>${STYLE}</head><body><main>${body}</main></body></html>`;
@@ -1,5 +1,6 @@
1
1
  /** Settings → Models: the CLIs on this computer, the default agent for each
2
2
  * role picked from live lists, and the models that just arrived. */
3
+ import { type Html } from "./html.js";
3
4
  import type { ModelOption, RuntimeState, SeenModel, WatchState } from "./model-catalog.js";
4
5
  export declare const MODELS_CSS = ".models{max-width:780px;min-width:0;overflow-wrap:anywhere}.models .card{padding:16px 20px;margin:12px 0;min-width:0}.models h2{margin:24px 0 8px}.models form{margin:0}.models label{display:grid;gap:6px}.models select,.models input[type=search]{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.models button,.models summary,.models .button-link{min-height:44px}.models button{white-space:nowrap}.models summary{padding:12px 0;cursor:pointer}.models .tool{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.models .role{display:grid;grid-template-columns:minmax(0,1fr);gap:8px}.models .role-row{display:flex;gap:12px;align-items:end;flex-wrap:wrap}.models .role-row select{flex:1 1 240px;width:auto;min-width:0}.models .role-name{font-weight:600}.models .new{font-weight:600}.models ul{padding-left:20px}.models .meta{font-size:.86rem}@media(max-width:600px){.models .card{padding:14px}}";
5
6
  export type RoleView = {
@@ -29,6 +30,6 @@ export type ModelsView = {
29
30
  said: string | null;
30
31
  problem: string | null;
31
32
  };
32
- export declare function modelsHtml(view: ModelsView): string;
33
+ export declare function modelsHtml(view: ModelsView): Html;
33
34
  /** Filters one long picker as you type; the saved choice always stays listed. */
34
35
  export declare function modelsScript(): string;
package/dist/models-ui.js CHANGED
@@ -1,60 +1,50 @@
1
+ /** Settings → Models: the CLIs on this computer, the default agent for each
2
+ * role picked from live lists, and the models that just arrived. */
3
+ import { html, postForm } from "./html.js";
1
4
  import { priceWords } from "./model-catalog.js";
2
5
  import { whenUtc } from "./when-html.js";
3
- const e = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
4
- const hidden = (data) => Object.entries(data).map(([k, v]) => `<input type="hidden" name="${e(k)}" value="${e(v)}">`).join("");
5
6
  const when = (iso) => iso === null ? "never" : whenUtc(iso);
6
7
  export const MODELS_CSS = `.models{max-width:780px;min-width:0;overflow-wrap:anywhere}.models .card{padding:16px 20px;margin:12px 0;min-width:0}.models h2{margin:24px 0 8px}.models form{margin:0}.models label{display:grid;gap:6px}.models select,.models input[type=search]{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.models button,.models summary,.models .button-link{min-height:44px}.models button{white-space:nowrap}.models summary{padding:12px 0;cursor:pointer}.models .tool{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.models .role{display:grid;grid-template-columns:minmax(0,1fr);gap:8px}.models .role-row{display:flex;gap:12px;align-items:end;flex-wrap:wrap}.models .role-row select{flex:1 1 240px;width:auto;min-width:0}.models .role-name{font-weight:600}.models .new{font-weight:600}.models ul{padding-left:20px}.models .meta{font-size:.86rem}@media(max-width:600px){.models .card{padding:14px}}`;
7
- function toolRow(one, manage, csrf) {
8
+ function toolRow(one, manage) {
8
9
  const state = one.installed === null ? "Version unknown"
9
- : one.behind ? `${e(one.installed)} · <strong>${e(one.latest)} available</strong>`
10
- : `${e(one.installed)} · Up to date`;
10
+ : one.behind ? html `${one.installed} · <strong>${one.latest} available</strong>`
11
+ : `${one.installed} · Up to date`;
11
12
  const action = manage && one.behind && one.updateCommand !== null
12
- ? `<form method="post" action="/settings/models/update">${hidden({ csrf, tool: one.tool })}<button type="submit">Update ${e(one.name)}</button></form>`
13
- : one.behind ? `<span class="meta">Update it the way you installed it</span>` : "";
14
- return `<div class="card tool"><span><strong>${e(one.name)}</strong> ${state}${one.problem === null ? "" : `<br><span class="meta">${e(one.problem)}</span>`}</span>${action}</div>`;
13
+ ? postForm("/settings/models/update", html `<button type="submit">Update ${one.name}</button>`, { hidden: { tool: one.tool } })
14
+ : one.behind ? html `<span class="meta">Update it the way you installed it</span>` : "";
15
+ return html `<div class="card tool"><span><strong>${one.name}</strong> ${state}${one.problem === null ? "" : html `<br><span class="meta">${one.problem}</span>`}</span>${action}</div>`;
15
16
  }
16
- function roleForm(role, manage, csrf) {
17
+ function roleForm(role, manage) {
17
18
  if (!manage)
18
- return `<div class="card role"><strong>${e(role.label)}</strong><span>${e(role.words)}</span></div>`;
19
+ return html `<div class="card role"><strong>${role.label}</strong><span>${role.words}</span></div>`;
19
20
  const known = role.groups.some(group => group.options.some(option => `${group.provider}|${option.value}` === role.current));
20
- const saved = !known && role.current !== "inherit" && role.current !== "" ? `<option value="${e(role.current)}" selected>${e(role.words)} · current</option>` : "";
21
- const groups = role.groups.map(group => `<optgroup label="${e(group.label)}">${group.options.map(option => {
21
+ const saved = !known && role.current !== "inherit" && role.current !== "" ? html `<option value="${role.current}" selected>${role.words} · current</option>` : "";
22
+ const groups = role.groups.map(group => html `<optgroup label="${group.label}">${group.options.map(option => {
22
23
  const value = `${group.provider}|${option.value}`;
23
- return `<option value="${e(value)}"${value === role.current ? " selected" : ""}>${e(option.label)}</option>`;
24
- }).join("")}</optgroup>`).join("");
25
- const inherit = role.inherit ? `<option value="inherit"${role.current === "inherit" ? " selected" : ""}>Same as the builder</option>` : "";
24
+ return html `<option value="${value}"${value === role.current ? html ` selected` : ""}>${option.label}</option>`;
25
+ })}</optgroup>`);
26
+ const inherit = role.inherit ? html `<option value="inherit"${role.current === "inherit" ? html ` selected` : ""}>Same as the builder</option>` : "";
26
27
  const long = role.groups.reduce((sum, group) => sum + group.options.length, 0) > 40;
27
28
  const count = role.groups.reduce((sum, group) => sum + group.options.length, 0);
28
29
  const id = `role-${role.phase}`;
29
- return `<form method="post" action="/settings/models/agent" class="card role">${hidden({ csrf, phase: role.phase })}` +
30
- `<label class="role-name" for="${id}">${e(role.label)}</label>` +
31
- (long ? `<input type="search" data-model-filter hidden aria-label="Search ${e(role.label.toLowerCase())} models" placeholder="Search ${count} models" autocomplete="off">` : "") +
32
- `<div class="role-row"><select id="${id}" name="agent">${inherit}${saved}${groups}</select><button type="submit" class="secondary">Save</button></div></form>`;
30
+ return postForm("/settings/models/agent", html `<label class="role-name" for="${id}">${role.label}</label>${long ? html `<input type="search" data-model-filter hidden aria-label="Search ${role.label.toLowerCase()} models" placeholder="Search ${count} models" autocomplete="off">` : ""}<div class="role-row"><select id="${id}" name="agent">${inherit}${saved}${groups}</select><button type="submit" class="secondary">Save</button></div>`, { attrs: { class: "card role" }, hidden: { phase: role.phase } });
33
31
  }
34
32
  export function modelsHtml(view) {
35
33
  const manage = view.canManage && view.csrf !== "";
36
- const status = view.said === null ? "" : `<p role="status">${e(view.said)}</p>`;
37
- const problem = view.problem === null ? "" : `<p class="problem" role="alert">${e(view.problem)}</p>`;
34
+ const status = view.said === null ? "" : html `<p role="status">${view.said}</p>`;
35
+ const problem = view.problem === null ? "" : html `<p class="problem" role="alert">${view.problem}</p>`;
38
36
  const tools = view.runtimes.length === 0
39
- ? `<p class="meta">No Claude, Codex or Gemini CLI found on this computer yet.</p>`
40
- : view.runtimes.map(one => toolRow(one, manage, view.csrf)).join("");
37
+ ? html `<p class="meta">No Claude, Codex or Gemini CLI found on this computer yet.</p>`
38
+ : view.runtimes.map(one => toolRow(one, manage));
41
39
  const check = manage
42
- ? `<form method="post" action="/settings/models/check" class="inline">${hidden({ csrf: view.csrf })}<button type="submit" class="secondary">Check now</button></form>`
40
+ ? postForm("/settings/models/check", html `<button type="submit" class="secondary">Check now</button>`, { attrs: { class: "inline" } })
43
41
  : "";
44
42
  const fresh = view.fresh.length === 0 ? "" :
45
- `<h2>New models</h2><ul>${view.fresh.map(model => `<li><span class="new">${e(model.name)}</span> <span class="meta">${e(priceWords(model))}${model.releasedAt === null ? "" : ` · released ${e(model.releasedAt.slice(0, 10))}`}</span></li>`).join("")}</ul>`;
43
+ html `<h2>New models</h2><ul>${view.fresh.map(model => html `<li><span class="new">${model.name}</span> <span class="meta">${priceWords(model)}${model.releasedAt === null ? "" : ` · released ${model.releasedAt.slice(0, 10)}`}</span></li>`)}</ul>`;
46
44
  const watch = manage
47
- ? `<details><summary>Automatic checks · ${view.watch.enabled ? "On" : "Off"}</summary><form method="post" action="/settings/models/watch">${hidden({ csrf: view.csrf, enabled: view.watch.enabled ? "0" : "1" })}` +
48
- `<p>Check every 6 hours and send a message when a new model or CLI update appears. It uses public lists and sends nothing about you or your projects.</p>` +
49
- `<button type="submit" class="secondary">${view.watch.enabled ? "Turn off" : "Turn on"}</button></form></details>`
45
+ ? html `<details><summary>Automatic checks · ${view.watch.enabled ? "On" : "Off"}</summary>${postForm("/settings/models/watch", html `<p>Check every 6 hours and send a message when a new model or CLI update appears. It uses public lists and sends nothing about you or your projects.</p><button type="submit" class="secondary">${view.watch.enabled ? "Turn off" : "Turn on"}</button>`, { hidden: { enabled: view.watch.enabled ? "0" : "1" } })}</details>`
50
46
  : "";
51
- return `<section class="models">${status}${problem}` +
52
- `<h2>AI tools</h2>${tools}<p class="meta">Checked ${when(view.watch.checkedAt)} ${check}</p>` +
53
- fresh +
54
- `<h2>Default agents</h2><p class="meta">New tasks use these. Approved tasks keep the agents they were approved with.</p>` +
55
- view.roles.map(role => roleForm(role, manage, view.csrf)).join("") +
56
- (view.chat === null ? "" : `<h2>Chat</h2><p>${e(view.chat.words)} · <a href="/settings/lead">Change</a></p>`) +
57
- watch + `</section>`;
47
+ return html `<section class="models">${status}${problem}<h2>AI tools</h2>${tools}<p class="meta">Checked ${when(view.watch.checkedAt)} ${check}</p>${fresh}<h2>Default agents</h2><p class="meta">New tasks use these. Approved tasks keep the agents they were approved with.</p>${view.roles.map(role => roleForm(role, manage))}${view.chat === null ? "" : html `<h2>Chat</h2><p>${view.chat.words} · <a href="/settings/lead">Change</a></p>`}${watch}</section>`;
58
48
  }
59
49
  /** Filters one long picker as you type; the saved choice always stays listed. */
60
50
  export function modelsScript() {
package/dist/modes.d.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * owns the transactions; the tick owns the rails; serve owns the
14
14
  * ceremony screens.
15
15
  */
16
+ import { type ChatProvider } from "./contracts/chat-tables.js";
16
17
  export type ModeName = "standard" | "hands-off";
17
18
  export type ModeTerms = {
18
19
  name: ModeName;
@@ -64,12 +65,16 @@ export type ModeTerms = {
64
65
  * `repairAuto` is also true — a mode may sign a cap without signing the
65
66
  * authority, but never the reverse. */
66
67
  repairMaxAttempts: number;
67
- /** Explicit opt-in: the signer's paired Telegram chat may approve this
68
+ /** Explicit opt-in: the signer's paired chat may approve this
68
69
  * repository's plans and merge its ready pull requests with two taps,
69
70
  * for the mode's lifetime. A plan that widens permissions, exceeds the
70
71
  * per-attempt budget or touches protected paths still opens Toolroll.
71
72
  * Legacy modes read FALSE — never inherited, only freshly signed. */
72
73
  chatApprove: boolean;
74
+ /** The chat apps `chatApprove` names, signed with it. Absent on a mode signed before chat apps other than Telegram
75
+ * could approve: its words said "your paired Telegram chat", so it stays Telegram only (chatApproveChatsOf). A wider
76
+ * grant is only ever a fresh signature naming the apps. */
77
+ chatApproveChats?: readonly ChatProvider[];
73
78
  absoluteExpiry: string;
74
79
  };
75
80
  export declare const MODE_MAX_DAYS = 7;
@@ -82,6 +87,13 @@ export declare function modeTermsJson(terms: ModeTerms): string;
82
87
  export declare function modeDigestOf(terms: ModeTerms): string;
83
88
  /** Strict rehydration — anything unexpected is null, never a guess. */
84
89
  export declare function modeTermsFromJson(json: string | null): ModeTerms | null;
90
+ /** The chat apps a mode lets its signer approve from: none without chatApprove; Telegram alone for a grant signed
91
+ * before it named its apps (that is what its words said); otherwise exactly the apps it names. */
92
+ export declare function chatApproveChatsOf(terms: Pick<ModeTerms, "chatApprove" | "chatApproveChats">): readonly ChatProvider[];
93
+ /** What a new chatApprove signature names: every chat app, said in its words. */
94
+ export declare const CHAT_APPROVE_ALL: readonly ChatProvider[];
95
+ /** "Telegram", "Telegram or Slack", "Telegram, Slack, Discord or Teams". */
96
+ export declare const chatNames: (chats: readonly ChatProvider[]) => string;
85
97
  /** Every term in words — what the ceremony renders and the password
86
98
  * signs. The reversal sentence is verbatim from the chain (C1). */
87
99
  export declare function modeWords(terms: ModeTerms): string[];
package/dist/modes.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * ceremony screens.
15
15
  */
16
16
  import { createHash } from "node:crypto";
17
+ import { CHAT_PROVIDERS, isChatProvider } from "./contracts/chat-tables.js";
17
18
  export const MODE_MAX_DAYS = 7;
18
19
  /** The presets are STARTING POINTS the ceremony renders in full — the
19
20
  * signature always covers the resolved terms, never a label. */
@@ -120,6 +121,10 @@ export function modeTermsFromJson(json) {
120
121
  (t["repairMaxAttempts"] === undefined ||
121
122
  (typeof t["repairMaxAttempts"] === "number" && Number.isInteger(t["repairMaxAttempts"]) && t["repairMaxAttempts"] >= 0 && t["repairMaxAttempts"] <= 3)) &&
122
123
  (t["chatApprove"] === undefined || typeof t["chatApprove"] === "boolean") &&
124
+ // chatApproveChats: absent (a Telegram-only grant, or none), or the apps a chatApprove term names, each once.
125
+ (t["chatApproveChats"] === undefined ||
126
+ (t["chatApprove"] === true && Array.isArray(t["chatApproveChats"]) && t["chatApproveChats"].length > 0 &&
127
+ t["chatApproveChats"].every(isChatProvider) && new Set(t["chatApproveChats"]).size === t["chatApproveChats"].length)) &&
123
128
  typeof t["absoluteExpiry"] === "string" &&
124
129
  !Number.isNaN(Date.parse(t["absoluteExpiry"]))) {
125
130
  return {
@@ -138,11 +143,22 @@ export function modeTermsFromJson(json) {
138
143
  repairAuto: t["repairAuto"] === true,
139
144
  repairMaxAttempts: typeof t["repairMaxAttempts"] === "number" ? t["repairMaxAttempts"] : 0,
140
145
  chatApprove: t["chatApprove"] === true,
146
+ ...(Array.isArray(t["chatApproveChats"]) ? { chatApproveChats: [...t["chatApproveChats"]] } : {}),
141
147
  absoluteExpiry: t["absoluteExpiry"],
142
148
  };
143
149
  }
144
150
  return null;
145
151
  }
152
+ /** The chat apps a mode lets its signer approve from: none without chatApprove; Telegram alone for a grant signed
153
+ * before it named its apps (that is what its words said); otherwise exactly the apps it names. */
154
+ export function chatApproveChatsOf(terms) {
155
+ return !terms.chatApprove ? [] : terms.chatApproveChats ?? ["telegram"];
156
+ }
157
+ /** What a new chatApprove signature names: every chat app, said in its words. */
158
+ export const CHAT_APPROVE_ALL = CHAT_PROVIDERS;
159
+ const CHAT_NAMES = { telegram: "Telegram", slack: "Slack", discord: "Discord", teams: "Teams" };
160
+ /** "Telegram", "Telegram or Slack", "Telegram, Slack, Discord or Teams". */
161
+ export const chatNames = (chats) => chats.length <= 1 ? chats.map(one => CHAT_NAMES[one]).join("") : `${chats.slice(0, -1).map(one => CHAT_NAMES[one]).join(", ")} or ${CHAT_NAMES[chats[chats.length - 1]]}`;
146
162
  /** Every term in words — what the ceremony renders and the password
147
163
  * signs. The reversal sentence is verbatim from the chain (C1). */
148
164
  export function modeWords(terms) {
@@ -178,7 +194,7 @@ export function modeWords(terms) {
178
194
  ...(terms.repairAuto
179
195
  ? ["historical automatic repair grants are retained on record but no longer schedule work"] : []),
180
196
  ...(terms.chatApprove
181
- ? ["your paired Telegram chat may approve this repository's plans and merge its ready pull requests, two taps each, without your password; a plan that widens permissions, exceeds the per-attempt cap above or touches protected paths still opens Toolroll"] : []),
197
+ ? [`your paired ${chatNames(chatApproveChatsOf(terms))} chat may approve this repository's plans and merge its ready pull requests, two taps each, without your password; a plan that widens permissions, exceeds the per-attempt cap above or touches protected paths still opens Toolroll`] : []),
182
198
  `everything above ends at ${terms.absoluteExpiry.slice(0, 16).replace("T", " ")} — revoking it earlier is one click, and every act it covered falls back to its own ceremony`,
183
199
  ];
184
200
  }
@@ -3,6 +3,7 @@
3
3
  * the company's tools. One form, the status of each destination beside it,
4
4
  * and how Prometheus reads `/metrics`. The signing secret is shown once.
5
5
  */
6
+ import { type Html } from "./html.js";
6
7
  import type { MonitoringStatus } from "./store.js";
7
8
  import type { MonitoringSettings } from "./monitoring-settings.js";
8
9
  export declare const MONITORING_CSS: string;
@@ -12,9 +13,9 @@ export type MonitoringView = {
12
13
  head: number;
13
14
  origin: string | null;
14
15
  };
15
- export declare function monitoringHtml(view: MonitoringView, csrf: string, notice: {
16
+ export declare function monitoringHtml(view: MonitoringView, notice: {
16
17
  said?: string | null;
17
18
  problem?: string | null;
18
- }): string;
19
+ }): Html;
19
20
  /** The webhook's signing secret, once: a focused page with no script. */
20
- export declare function signingSecretHtml(url: string, secret: string): string;
21
+ export declare function signingSecretHtml(url: string, secret: string): Html;
@@ -1,6 +1,11 @@
1
+ /**
2
+ * Settings → Monitoring (v104): an instance operator points Toolroll at
3
+ * the company's tools. One form, the status of each destination beside it,
4
+ * and how Prometheus reads `/metrics`. The signing secret is shown once.
5
+ */
6
+ import { html, postForm } from "./html.js";
1
7
  import { targetOf } from "./monitoring.js";
2
8
  import { whenUtc } from "./when-html.js";
3
- const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
4
9
  const when = (at) => whenUtc(at);
5
10
  export const MONITORING_CSS = `.monitoring{max-width:720px;min-width:0}.monitoring fieldset{border:0;padding:0;margin:20px 0 0;min-width:0}` +
6
11
  `.monitoring legend{font-weight:600;font-size:.9375rem;margin-bottom:6px}.monitoring label{display:grid;gap:4px;margin:10px 0 0;font-size:.875rem}` +
@@ -15,38 +20,20 @@ function statusLine(view, sink, address) {
15
20
  // A status for another address (it just changed) says nothing about this one yet.
16
21
  const one = view.status.find(row => row.sink === sink && row.target === targetOf(address));
17
22
  if (one === undefined || (one.lastOkAt === null && one.lastError === null))
18
- return `<p class="status meta" data-monitoring="${sink}" data-state="starting">Starting: the first delivery goes out within a few seconds.</p>`;
23
+ return html `<p class="status meta" data-monitoring="${sink}" data-state="starting">Starting: the first delivery goes out within a few seconds.</p>`;
19
24
  if (one.failures > 0 && one.lastError !== null) {
20
- return `<p class="status problem" role="alert" data-monitoring="${sink}" data-state="failing">Failing: ${e(one.lastError)}${one.nextTryAt === null ? "" : ` · trying again ${when(one.nextTryAt)}`}</p>`;
25
+ return html `<p class="status problem" role="alert" data-monitoring="${sink}" data-state="failing">Failing: ${one.lastError}${one.nextTryAt === null ? "" : html ` · trying again ${when(one.nextTryAt)}`}</p>`;
21
26
  }
22
27
  const behind = Math.max(0, view.head - one.through);
23
- return `<p class="status meta" data-monitoring="${sink}" data-state="ok">${sink === "traces" ? "Sending" : `Sent through entry #${one.through}`}${one.lastOkAt === null ? "" : ` · last ${when(one.lastOkAt)}`}${behind > 0 && sink !== "traces" ? ` · ${behind} to go` : ""}</p>`;
28
+ return html `<p class="status meta" data-monitoring="${sink}" data-state="ok">${sink === "traces" ? "Sending" : `Sent through entry #${one.through}`}${one.lastOkAt === null ? "" : html ` · last ${when(one.lastOkAt)}`}${behind > 0 && sink !== "traces" ? ` · ${behind} to go` : ""}</p>`;
24
29
  }
25
- export function monitoringHtml(view, csrf, notice) {
30
+ export function monitoringHtml(view, notice) {
26
31
  const s = view.settings;
27
- const note = notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
32
+ const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
28
33
  const metrics = view.origin === null ? "/metrics" : `${view.origin}/metrics`;
29
- return `<section class="monitoring">${note}` +
30
- `<form method="post" action="/settings/monitoring"><input type="hidden" name="csrf" value="${e(csrf)}">` +
31
- `<fieldset><legend>Audit stream</legend><p class="meta">Every ledger entry with its seal, as it happens, to your log system.</p>` +
32
- `<label>Webhook<input type="url" name="webhook" value="${e(s.webhook?.url ?? "")}" placeholder="https://logs.example.com/standing-orders" spellcheck="false"></label>` +
33
- statusLine(view, "webhook", s.webhook?.url ?? null) +
34
- (s.webhook === null ? "" : `<label class="choice"><input type="checkbox" name="rotate" value="1"> Make a new signing secret</label>`) +
35
- `<label>Folder (JSON Lines, a file per day)<input type="text" name="folder" value="${e(s.folder?.path ?? "")}" placeholder="/var/log/standing-orders" spellcheck="false"></label>` +
36
- statusLine(view, "folder", s.folder?.path ?? null) + `</fieldset>` +
37
- `<fieldset><legend>Traces</legend><p class="meta">Each run as an OpenTelemetry span: timings, model, tokens and cost. Never a prompt or code.</p>` +
38
- `<label>Collector (OTLP over HTTP)<input type="url" name="traces" value="${e(s.traces?.endpoint ?? "")}" placeholder="https://otel.example.com:4318" spellcheck="false"></label>` +
39
- statusLine(view, "traces", s.traces?.endpoint ?? null) +
40
- `<label>Header name (optional)<input type="text" name="header-name" value="${e(s.traces?.header?.name ?? "")}" placeholder="x-honeycomb-team" spellcheck="false"></label>` +
41
- `<label>Header value<input type="password" name="header-value" autocomplete="off" placeholder="${s.traces?.header ? "Saved; leave blank to keep" : "API key"}"></label></fieldset>` +
42
- `<div class="step-up"><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label></div>` +
43
- `<button type="submit">Save</button></form>` +
44
- `<fieldset><legend>Metrics</legend><p class="meta">Prometheus reads <code>${e(metrics)}</code> with an instance operator's API token (<a href="/settings/sessions">Sessions &amp; tokens</a>).</p>` +
45
- `<details><summary>Example</summary><pre>curl -H "Authorization: Bearer so_…" ${e(metrics)}</pre></details></fieldset></section>`;
34
+ return html `<section class="monitoring">${note}${postForm("/settings/monitoring", html `<fieldset><legend>Audit stream</legend><p class="meta">Every ledger entry with its seal, as it happens, to your log system.</p><label>Webhook<input type="url" name="webhook" value="${s.webhook?.url ?? ""}" placeholder="https://logs.example.com/standing-orders" spellcheck="false"></label>${statusLine(view, "webhook", s.webhook?.url ?? null)}${s.webhook === null ? "" : html `<label class="choice"><input type="checkbox" name="rotate" value="1"> Make a new signing secret</label>`}<label>Folder (JSON Lines, a file per day)<input type="text" name="folder" value="${s.folder?.path ?? ""}" placeholder="/var/log/standing-orders" spellcheck="false"></label>${statusLine(view, "folder", s.folder?.path ?? null)}</fieldset><fieldset><legend>Traces</legend><p class="meta">Each run as an OpenTelemetry span: timings, model, tokens and cost. Never a prompt or code.</p><label>Collector (OTLP over HTTP)<input type="url" name="traces" value="${s.traces?.endpoint ?? ""}" placeholder="https://otel.example.com:4318" spellcheck="false"></label>${statusLine(view, "traces", s.traces?.endpoint ?? null)}<label>Header name (optional)<input type="text" name="header-name" value="${s.traces?.header?.name ?? ""}" placeholder="x-honeycomb-team" spellcheck="false"></label><label>Header value<input type="password" name="header-value" autocomplete="off" placeholder="${s.traces?.header ? "Saved; leave blank to keep" : "API key"}"></label></fieldset><div class="step-up"><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label></div><button type="submit">Save</button>`)}<fieldset><legend>Metrics</legend><p class="meta">Prometheus reads <code>${metrics}</code> with an instance operator's API token (<a href="/settings/sessions">Sessions &amp; tokens</a>).</p><details><summary>Example</summary><pre>curl -H "Authorization: Bearer so_…" ${metrics}</pre></details></fieldset></section>`;
46
35
  }
47
36
  /** The webhook's signing secret, once: a focused page with no script. */
48
37
  export function signingSecretHtml(url, secret) {
49
- return `<h1>Signing secret</h1><div class="card"><p>Give this to the receiver at <span class="mono">${e(url)}</span>. It's shown once.</p><p class="mono secret-value">${e(secret)}</p>` +
50
- `<p class="meta">Each request carries <code>x-standing-orders-signature: t=&lt;time&gt;,v1=&lt;HMAC-SHA256 of "time.body"&gt;</code>. Check it, and that the time is recent.</p></div>` +
51
- `<p class="meta"><a href="/settings/monitoring">Back to Monitoring</a></p>`;
38
+ return html `<h1>Signing secret</h1><div class="card"><p>Give this to the receiver at <span class="mono">${url}</span>. It's shown once.</p><p class="mono secret-value">${secret}</p><p class="meta">Each request carries <code>x-standing-orders-signature: t=&lt;time&gt;,v1=&lt;HMAC-SHA256 of "time.body"&gt;</code>. Check it, and that the time is recent.</p></div><p class="meta"><a href="/settings/monitoring">Back to Monitoring</a></p>`;
52
39
  }
@@ -0,0 +1,30 @@
1
+ /** Saved notifications, read and told apart without a store: the row readers, the lifecycle key and the predicates
2
+ * the store and the chat core (chat-messages.ts) share. A leaf: it imports types only. */
3
+ import type { MateProposal, Notification, NotificationReceipt } from "./store.js";
4
+ /** The one task a card is about: a task its confirmation created (a new
5
+ * task, a revision), else the task it names (and, for feedback on a result,
6
+ * that result). Null for a card about no single task. A pointer for
7
+ * replies, never an authority. */
8
+ export declare function proposalTaskOf(proposal: MateProposal | null): {
9
+ task: string;
10
+ run: number | null;
11
+ } | null;
12
+ /** Every lifecycle dedupe key starts here: `life:<kind>:<identity>:<ordinal>`. */
13
+ export declare const LIFECYCLE_KEY_PREFIX = "life:";
14
+ /**
15
+ * Task lifecycle updates (Telegram task updates, 2026-09-16): the CLOSED
16
+ * vocabulary of routine progress facts the shared mutations record beside
17
+ * the change that made them true. None carries a push class — they are
18
+ * digest-eligible progress, never a page — and every one is project-bound
19
+ * through the task it names. A decision, incident, gap, stall, publication
20
+ * or merge page keeps its own producer; nothing here repeats one.
21
+ */
22
+ export declare const LIFECYCLE_KINDS: readonly ["task-filed", "scope-approved", "approval-withdrawn", "task-held", "task-released", "task-queued", "task-requeued", "task-cancelled", "run-started", "run-phase", "check-progress", "run-finished", "run-stopping", "run-stopped", "run-resumed", "review-requested", "review-finished", "acceptance-evidence", "acceptance-ready"];
23
+ export type LifecycleKind = (typeof LIFECYCLE_KINDS)[number];
24
+ /** A progress fact, told apart by its durable key — never by display text. */
25
+ export declare function isLifecycleNotification(row: Pick<Notification, "dedupeKey">): boolean;
26
+ /** Only known progress producers can repaint a card. Decisions and urgent
27
+ * incidents keep their own alerts; a routine retry updates its saved attempt. */
28
+ export declare function isTelegramProgressNotification(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "run">): boolean;
29
+ export declare function readNotification(row: Record<string, unknown>): Notification;
30
+ export declare function readNotificationReceipt(row: Record<string, unknown>): NotificationReceipt;
@@ -0,0 +1,102 @@
1
+ /** The one task a card is about: a task its confirmation created (a new
2
+ * task, a revision), else the task it names (and, for feedback on a result,
3
+ * that result). Null for a card about no single task. A pointer for
4
+ * replies, never an authority. */
5
+ export function proposalTaskOf(proposal) {
6
+ if (proposal === null)
7
+ return null;
8
+ const named = typeof proposal.payload["task"] === "string" && proposal.payload["task"] !== "" ? proposal.payload["task"] : null;
9
+ const creates = proposal.kind === "task" || (proposal.kind === "review" && proposal.payload["operation"] === "revise");
10
+ const made = creates && proposal.state === "confirmed" ? proposal.outcome?.["taskId"] : undefined;
11
+ if (typeof made === "string" && made !== "" && made !== named)
12
+ return { task: made, run: null };
13
+ if (named === null)
14
+ return null;
15
+ const run = proposal.kind === "review" ? proposal.payload["run"] : null;
16
+ return { task: named, run: typeof run === "number" && Number.isSafeInteger(run) ? run : null };
17
+ }
18
+ /** Every lifecycle dedupe key starts here: `life:<kind>:<identity>:<ordinal>`. */
19
+ export const LIFECYCLE_KEY_PREFIX = "life:";
20
+ /**
21
+ * Task lifecycle updates (Telegram task updates, 2026-09-16): the CLOSED
22
+ * vocabulary of routine progress facts the shared mutations record beside
23
+ * the change that made them true. None carries a push class — they are
24
+ * digest-eligible progress, never a page — and every one is project-bound
25
+ * through the task it names. A decision, incident, gap, stall, publication
26
+ * or merge page keeps its own producer; nothing here repeats one.
27
+ */
28
+ export const LIFECYCLE_KINDS = [
29
+ "task-filed",
30
+ "scope-approved",
31
+ "approval-withdrawn",
32
+ "task-held",
33
+ "task-released",
34
+ "task-queued",
35
+ "task-requeued",
36
+ "task-cancelled",
37
+ "run-started",
38
+ "run-phase",
39
+ "check-progress",
40
+ "run-finished",
41
+ "run-stopping",
42
+ "run-stopped",
43
+ "run-resumed",
44
+ "review-requested",
45
+ "review-finished",
46
+ "acceptance-evidence",
47
+ "acceptance-ready",
48
+ ];
49
+ /** A progress fact, told apart by its durable key — never by display text. */
50
+ export function isLifecycleNotification(row) {
51
+ return row.dedupeKey.startsWith(LIFECYCLE_KEY_PREFIX);
52
+ }
53
+ /** Only known progress producers can repaint a card. Decisions and urgent
54
+ * incidents keep their own alerts; a routine retry updates its saved attempt. */
55
+ export function isTelegramProgressNotification(row) {
56
+ if (row.kind === "check-progress" && row.pushClass === "progress" && row.run !== null)
57
+ return isLifecycleNotification(row);
58
+ if (row.pushClass !== null)
59
+ return false;
60
+ if (row.kind === "build-failed" && row.run !== null && row.dedupeKey === `run:${row.run}:failed`)
61
+ return true;
62
+ // The owner's lead took the attempt on, or let it lapse (lead-voice.ts): its card repaints in place.
63
+ if ((row.kind === "lead-on-it" || row.kind === "lead-lapsed") && row.run !== null)
64
+ return true;
65
+ return isLifecycleNotification(row) &&
66
+ ["run-started", "run-phase", "run-finished", "review-requested", "review-finished", "run-stopping", "run-stopped", "run-resumed", "task-held", "task-released"].includes(row.kind);
67
+ }
68
+ export function readNotification(row) {
69
+ return {
70
+ id: Number(row["id"]),
71
+ recipient: row["recipient"] == null ? null : String(row["recipient"]),
72
+ dedupeKey: String(row["dedupe_key"]),
73
+ scope: row["provenance_scope"],
74
+ project: row["project"] == null ? null : String(row["project"]),
75
+ taskRef: row["task_ref"] == null ? null : Number(row["task_ref"]),
76
+ taskId: row["task_id"] == null ? null : String(row["task_id"]),
77
+ run: row["source_run"] == null ? null : Number(row["source_run"]),
78
+ kind: String(row["kind"]),
79
+ subject: String(row["subject"]),
80
+ body: String(row["body"]),
81
+ createdAt: String(row["created_at"]),
82
+ resolvedAt: row["resolved_at"] === null || row["resolved_at"] === undefined
83
+ ? null
84
+ : String(row["resolved_at"]),
85
+ pushClass: (row["push_class"] === null || row["push_class"] === undefined) && String(row["kind"]) === "check-progress"
86
+ ? "progress"
87
+ : row["push_class"] === null || row["push_class"] === undefined
88
+ ? null
89
+ : String(row["push_class"]),
90
+ link: row["link"] === null || row["link"] === undefined ? null : String(row["link"]),
91
+ };
92
+ }
93
+ export function readNotificationReceipt(row) {
94
+ return {
95
+ ...readNotification(row),
96
+ attempts: Number(row["attempts"]),
97
+ lastAttemptAt: row["last_attempt_at"] == null ? null : String(row["last_attempt_at"]),
98
+ lastError: row["last_error"] == null ? null : String(row["last_error"]),
99
+ deliveredAt: row["delivered_at"] == null ? null : String(row["delivered_at"]),
100
+ receipt: row["receipt"] == null ? null : String(row["receipt"]),
101
+ };
102
+ }
@@ -1,3 +1,4 @@
1
+ import { type Html } from "./html.js";
1
2
  export type OpenRouterModel = {
2
3
  id: string;
3
4
  name: string;
@@ -25,6 +26,6 @@ export declare function fetchOpenRouterModels(key: string | null, fetcher?: type
25
26
  /** One bounded cache per console. A changed/removed key cannot reuse another account's list. */
26
27
  export declare function openRouterModelsCache(fetcher?: typeof fetch): (key: string | null, refresh?: boolean) => Promise<OpenRouterModels>;
27
28
  export declare function modelDollars(amount: number | null): string;
28
- export declare function openRouterPicker(catalog: OpenRouterModels, selected: string | null, refreshUrl: string): string;
29
+ export declare function openRouterPicker(catalog: OpenRouterModels, selected: string | null, refreshUrl: string): Html;
29
30
  /** Only filters already-rendered options; credentials never enter this script. */
30
31
  export declare function openRouterPickerScript(): string;