toolroll 0.9.54 → 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 (199) hide show
  1. package/CHANGELOG.md +9 -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-polish.d.ts +5 -4
  19. package/dist/chat-polish.js +9 -10
  20. package/dist/check-levels-ui.d.ts +2 -1
  21. package/dist/check-levels-ui.js +26 -53
  22. package/dist/cli.d.ts +3 -1
  23. package/dist/cli.js +9 -3
  24. package/dist/coding-shipping-ui.d.ts +2 -1
  25. package/dist/coding-shipping-ui.js +6 -6
  26. package/dist/coding-ui.d.ts +3 -1
  27. package/dist/coding-ui.js +30 -29
  28. package/dist/contracts/cli.d.ts +1 -3
  29. package/dist/contracts/console-api.d.ts +1 -1
  30. package/dist/contracts/console-api.js +6 -5
  31. package/dist/contracts/health.d.ts +1 -3
  32. package/dist/control-ui.d.ts +5 -4
  33. package/dist/control-ui.js +9 -34
  34. package/dist/credentials-ui.d.ts +5 -4
  35. package/dist/credentials-ui.js +23 -24
  36. package/dist/demo-chat.d.ts +3 -3
  37. package/dist/demo-chat.js +29 -76
  38. package/dist/discord-settings.d.ts +3 -2
  39. package/dist/discord-settings.js +17 -42
  40. package/dist/envelope.js +10 -0
  41. package/dist/evidence-pack.d.ts +2 -1
  42. package/dist/evidence-pack.js +45 -49
  43. package/dist/export-ui.d.ts +7 -2
  44. package/dist/export-ui.js +4 -10
  45. package/dist/flow-gallery-ui.d.ts +4 -3
  46. package/dist/flow-gallery-ui.js +28 -43
  47. package/dist/flow-starters-ui.d.ts +2 -1
  48. package/dist/flow-starters-ui.js +12 -15
  49. package/dist/flow-triggers.d.ts +1 -0
  50. package/dist/flow-triggers.js +16 -14
  51. package/dist/flows-ui.d.ts +4 -3
  52. package/dist/flows-ui.js +19 -30
  53. package/dist/flows.js +2 -1
  54. package/dist/guides.js +108 -3
  55. package/dist/html.d.ts +76 -0
  56. package/dist/html.js +173 -0
  57. package/dist/integrations-ui.d.ts +2 -1
  58. package/dist/integrations-ui.js +19 -23
  59. package/dist/kits-ui.d.ts +5 -4
  60. package/dist/kits-ui.js +18 -20
  61. package/dist/knowledge-ui.d.ts +6 -5
  62. package/dist/knowledge-ui.js +26 -25
  63. package/dist/lead-context.d.ts +11 -1
  64. package/dist/lead-context.js +6 -8
  65. package/dist/ledger-view.d.ts +2 -1
  66. package/dist/ledger-view.js +27 -29
  67. package/dist/limits-ui.d.ts +2 -1
  68. package/dist/limits-ui.js +7 -7
  69. package/dist/live-bus.d.ts +50 -8
  70. package/dist/live-bus.js +133 -50
  71. package/dist/mcp-oauth.js +35 -36
  72. package/dist/models-ui.d.ts +2 -1
  73. package/dist/models-ui.js +25 -35
  74. package/dist/monitoring-ui.d.ts +4 -3
  75. package/dist/monitoring-ui.js +13 -26
  76. package/dist/openrouter-models.d.ts +2 -1
  77. package/dist/openrouter-models.js +5 -9
  78. package/dist/operate.js +11 -7
  79. package/dist/people-audit-ui.d.ts +2 -1
  80. package/dist/people-audit-ui.js +13 -13
  81. package/dist/policy-ui.d.ts +3 -2
  82. package/dist/policy-ui.js +17 -23
  83. package/dist/project-delete-ui.d.ts +4 -3
  84. package/dist/project-delete-ui.js +15 -27
  85. package/dist/pull-request-ui.d.ts +2 -1
  86. package/dist/pull-request-ui.js +17 -32
  87. package/dist/recipe-ui.d.ts +8 -7
  88. package/dist/recipe-ui.js +39 -32
  89. package/dist/release-adapters.d.ts +60 -0
  90. package/dist/release-adapters.js +435 -0
  91. package/dist/release-cli.d.ts +24 -0
  92. package/dist/release-cli.js +181 -0
  93. package/dist/release-gate.d.ts +80 -0
  94. package/dist/release-gate.js +150 -0
  95. package/dist/release-links.d.ts +29 -0
  96. package/dist/release-links.js +102 -0
  97. package/dist/release.d.ts +331 -0
  98. package/dist/release.js +550 -0
  99. package/dist/reply-shape.d.ts +2 -1
  100. package/dist/reply-shape.js +5 -5
  101. package/dist/repository-context-ui.d.ts +2 -1
  102. package/dist/repository-context-ui.js +2 -7
  103. package/dist/request-budget-ui.d.ts +2 -1
  104. package/dist/request-budget-ui.js +16 -16
  105. package/dist/result-review.d.ts +2 -3
  106. package/dist/result-review.js +3 -9
  107. package/dist/retention-ui.d.ts +7 -1
  108. package/dist/retention-ui.js +7 -11
  109. package/dist/serve.d.ts +11 -2
  110. package/dist/serve.js +69 -58
  111. package/dist/server/chat.d.ts +3 -6
  112. package/dist/server/chat.js +969 -1006
  113. package/dist/server/chrome.d.ts +291 -0
  114. package/dist/server/chrome.js +747 -0
  115. package/dist/server/console.css +2819 -0
  116. package/dist/server/flows.d.ts +2 -6
  117. package/dist/server/flows.js +840 -766
  118. package/dist/server/guards.d.ts +1 -1
  119. package/dist/server/guards.js +5 -2
  120. package/dist/server/handler-context.d.ts +1 -1
  121. package/dist/server/handler-context.js +1 -1
  122. package/dist/server/handler-registry.d.ts +8 -6
  123. package/dist/server/handler-registry.js +14 -2
  124. package/dist/server/http.d.ts +274 -0
  125. package/dist/server/http.js +239 -0
  126. package/dist/server/live.d.ts +34 -0
  127. package/dist/server/live.js +309 -0
  128. package/dist/server/pages.d.ts +2 -6
  129. package/dist/server/pages.js +808 -789
  130. package/dist/server/people-tokens.d.ts +1 -6
  131. package/dist/server/people-tokens.js +580 -572
  132. package/dist/server/remote.d.ts +4 -7
  133. package/dist/server/remote.js +37 -70
  134. package/dist/server/render-chat.d.ts +402 -0
  135. package/dist/server/render-chat.js +927 -0
  136. package/dist/server/render-pages.d.ts +535 -0
  137. package/dist/server/render-pages.js +1277 -0
  138. package/dist/server/render-people.d.ts +32 -0
  139. package/dist/server/render-people.js +121 -0
  140. package/dist/server/render-results.d.ts +744 -0
  141. package/dist/server/render-results.js +2237 -0
  142. package/dist/server/render-settings.d.ts +147 -0
  143. package/dist/server/render-settings.js +577 -0
  144. package/dist/server/render-tasks.d.ts +632 -0
  145. package/dist/server/render-tasks.js +2345 -0
  146. package/dist/server/request-context.d.ts +46 -0
  147. package/dist/server/request-context.js +17 -0
  148. package/dist/server/route-table.d.ts +25 -3
  149. package/dist/server/route-table.js +163 -75
  150. package/dist/server/runtime.d.ts +15 -7
  151. package/dist/server/runtime.js +7 -1
  152. package/dist/server/session.d.ts +138 -0
  153. package/dist/server/session.js +94 -0
  154. package/dist/server/settings.d.ts +1 -5
  155. package/dist/server/settings.js +2181 -2117
  156. package/dist/server/task-views.d.ts +11 -4
  157. package/dist/server/task-views.js +26 -22
  158. package/dist/server/tasks.d.ts +9 -9
  159. package/dist/server/tasks.js +1486 -1418
  160. package/dist/server-telemetry.d.ts +4 -3
  161. package/dist/server-telemetry.js +4 -8
  162. package/dist/skills-ui.d.ts +4 -3
  163. package/dist/skills-ui.js +14 -17
  164. package/dist/skills.d.ts +1 -1
  165. package/dist/skills.js +2 -1
  166. package/dist/slack-settings.d.ts +3 -2
  167. package/dist/slack-settings.js +17 -42
  168. package/dist/spend-ui.d.ts +2 -1
  169. package/dist/spend-ui.js +9 -32
  170. package/dist/sso-ui.d.ts +3 -2
  171. package/dist/sso-ui.js +26 -27
  172. package/dist/storage-ui.d.ts +2 -1
  173. package/dist/storage-ui.js +14 -33
  174. package/dist/surface.js +14 -3
  175. package/dist/task-composer.d.ts +2 -1
  176. package/dist/task-composer.js +7 -7
  177. package/dist/task-status.d.ts +4 -3
  178. package/dist/task-status.js +15 -16
  179. package/dist/team-http.d.ts +5 -9
  180. package/dist/team-http.js +5 -63
  181. package/dist/team-ui.d.ts +2 -1
  182. package/dist/team-ui.js +2 -5
  183. package/dist/teammates-ui.d.ts +5 -4
  184. package/dist/teammates-ui.js +61 -90
  185. package/dist/teams-settings.d.ts +3 -2
  186. package/dist/teams-settings.js +20 -31
  187. package/dist/telegram-settings.d.ts +3 -2
  188. package/dist/telegram-settings.js +14 -25
  189. package/dist/toolroll-update-ui.d.ts +3 -2
  190. package/dist/toolroll-update-ui.js +35 -39
  191. package/dist/tools-ui.d.ts +5 -2
  192. package/dist/tools-ui.js +31 -31
  193. package/dist/when-html.d.ts +11 -3
  194. package/dist/when-html.js +5 -6
  195. package/dist/workspace-ui.d.ts +4 -2
  196. package/dist/workspace-ui.js +10 -10
  197. package/package.json +1 -1
  198. package/dist/server/shared.d.ts +0 -3151
  199. package/dist/server/shared.js +0 -12001
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() {
@@ -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
  }
@@ -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;
@@ -1,5 +1,6 @@
1
1
  /** Live discovery for the UI. Prices are USD, separate from rounded budget pins. */
2
2
  import { createHash } from "node:crypto";
3
+ import { html } from "./html.js";
3
4
  import { readCappedBody } from "./converse.js";
4
5
  import { validModelId } from "./provider.js";
5
6
  const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
@@ -76,7 +77,6 @@ export function openRouterModelsCache(fetcher = fetch) {
76
77
  return value;
77
78
  };
78
79
  }
79
- const escape = (s) => s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
80
80
  export function modelDollars(amount) {
81
81
  if (amount === null)
82
82
  return "Not reported";
@@ -96,14 +96,10 @@ function details(model) {
96
96
  export function openRouterPicker(catalog, selected, refreshUrl) {
97
97
  const rows = catalog.ok ? catalog.models : [];
98
98
  const current = rows.find(row => row.id === selected);
99
- const manual = selected && !current ? `<option selected value="${escape(selected)}" data-detail="This saved model is not in the loaded catalog. Availability and pricing are unverified.">${escape(selected)} · Saved choice</option>` : "";
100
- return `<div data-openrouter-picker><p class="meta">${catalog.ok
101
- ? `${rows.length} text models · ${catalog.source === "account" ? "Filtered for your OpenRouter account" : "Public catalog · Connect a key to check your account"} · Updated ${escape(catalog.checkedAt.slice(11, 16))} UTC`
102
- : escape(catalog.problem)} <a href="${escape(refreshUrl)}">Refresh models</a></p>` +
103
- `<label data-model-search hidden>Search models<input type="search" placeholder="Search by name or provider" autocomplete="off"></label>` +
104
- `<label>Model<select name="model" aria-describedby="openrouter-price"><option value="__custom__"${selected === null ? " selected" : ""}>Choose a model</option>${manual}${rows.map(row => `<option value="${escape(row.id)}" data-detail="${escape(details(row))}"${row.id === selected ? " selected" : ""}>${escape(row.name)} · ${modelDollars(row.input)} in / ${modelDollars(row.output)} out per 1M</option>`).join("")}</select></label>` +
105
- `<p id="openrouter-price" class="meta" aria-live="polite">${current ? escape(details(current)) : selected ? "This saved model is not in the loaded catalog. Availability and pricing are unverified." : "Choose a model to see its token prices and context size."}</p>` +
106
- `<p data-model-count class="meta" aria-live="polite"></p><p class="meta"><a href="https://openrouter.ai/models" target="_blank" rel="noreferrer">Compare models on OpenRouter ↗</a> · Prices in USD; catalog browsing uses no model tokens.</p></div>`;
99
+ const manual = selected && !current ? html `<option selected value="${selected}" data-detail="This saved model is not in the loaded catalog. Availability and pricing are unverified.">${selected} · Saved choice</option>` : "";
100
+ return html `<div data-openrouter-picker><p class="meta">${catalog.ok
101
+ ? `${rows.length} text models · ${catalog.source === "account" ? "Filtered for your OpenRouter account" : "Public catalog · Connect a key to check your account"} · Updated ${catalog.checkedAt.slice(11, 16)} UTC`
102
+ : catalog.problem} <a href="${refreshUrl}">Refresh models</a></p><label data-model-search hidden>Search models<input type="search" placeholder="Search by name or provider" autocomplete="off"></label><label>Model<select name="model" aria-describedby="openrouter-price"><option value="__custom__"${selected === null ? html ` selected` : ""}>Choose a model</option>${manual}${rows.map(row => html `<option value="${row.id}" data-detail="${details(row)}"${row.id === selected ? html ` selected` : ""}>${row.name} · ${modelDollars(row.input)} in / ${modelDollars(row.output)} out per 1M</option>`)}</select></label><p id="openrouter-price" class="meta" aria-live="polite">${current ? details(current) : selected ? "This saved model is not in the loaded catalog. Availability and pricing are unverified." : "Choose a model to see its token prices and context size."}</p><p data-model-count class="meta" aria-live="polite"></p><p class="meta"><a href="https://openrouter.ai/models" target="_blank" rel="noreferrer">Compare models on OpenRouter ↗</a> · Prices in USD; catalog browsing uses no model tokens.</p></div>`;
107
103
  }
108
104
  /** Only filters already-rendered options; credentials never enter this script. */
109
105
  export function openRouterPickerScript() {
package/dist/operate.js CHANGED
@@ -370,7 +370,7 @@ External trackers — build what a tracker nominates, under local approvals
370
370
 
371
371
  toolroll claim <id> --runner <name> [--ttl <seconds>]
372
372
  toolroll heartbeat <lease> still working; extends the lease
373
- toolroll release <lease> done with it; fenced if superseded
373
+ toolroll worker release <lease> done with it; fenced if superseded
374
374
  toolroll reap release every lease that ran out
375
375
 
376
376
  toolroll tick --runner <name> --token <t> --repo <path>
@@ -1143,8 +1143,10 @@ async function dispatch(command, positional, flags, context) {
1143
1143
  return claimCommand(positional, flags, context);
1144
1144
  case "heartbeat":
1145
1145
  return leaseCommand("heartbeat", positional, flags, context);
1146
- case "release":
1147
- return leaseCommand("release", positional, flags, context);
1146
+ case "worker":
1147
+ if (positional[0] === "release")
1148
+ return leaseCommand("release", positional.slice(1), flags, context);
1149
+ return fail(context.write, context.json, "worker", "usage", "`toolroll worker release <lease>` returns a lease.", EXIT.usage);
1148
1150
  case "reap":
1149
1151
  return reapCommand(context);
1150
1152
  case "runner":
@@ -1476,18 +1478,20 @@ function claimCommand(positional, flags, context) {
1476
1478
  */
1477
1479
  function leaseCommand(command, positional, flags, context) {
1478
1480
  const { store, write, json, now } = context;
1481
+ // `release` answers as the worker verb it now is (the bare `toolroll release <lease>` form is deprecated).
1482
+ const name = command === "release" ? "worker release" : command;
1479
1483
  const lease = positional[0];
1480
1484
  if (lease === undefined)
1481
- return fail(write, json, command, "usage", `which lease? \`toolroll ${command} <lease>\``, EXIT.usage);
1485
+ return fail(write, json, name, "usage", `which lease? \`toolroll ${name} <lease>\``, EXIT.usage);
1482
1486
  const ttl = readTtl(flags);
1483
1487
  if (ttl === null)
1484
- return fail(write, json, command, "usage", "--ttl takes whole seconds", EXIT.usage);
1488
+ return fail(write, json, name, "usage", "--ttl takes whole seconds", EXIT.usage);
1485
1489
  const result = command === "heartbeat" ? heartbeat(store, lease, now, ttl) : release(store, lease, now);
1486
1490
  if (!result.ok) {
1487
1491
  const message = result.reason === "fenced"
1488
1492
  ? "superseded — another runner holds this task now; stop rather than retry"
1489
1493
  : "no such lease";
1490
- return fail(write, json, command, result.reason, message, EXIT.refused);
1494
+ return fail(write, json, name, result.reason, message, EXIT.refused);
1491
1495
  }
1492
1496
  if (command === "release") {
1493
1497
  const task = store.getTask(String(refExternalId(store, result.claim.taskRef)));
@@ -1496,7 +1500,7 @@ function leaseCommand(command, positional, flags, context) {
1496
1500
  if (task !== null && task.state === "running")
1497
1501
  store.setTaskState(task.id, "queued", now);
1498
1502
  }
1499
- return succeed(write, json, command, { lease: result.claim }, () => [
1503
+ return succeed(write, json, name, { lease: result.claim }, () => [
1500
1504
  command === "heartbeat"
1501
1505
  ? `Still yours until ${result.claim.expiresAt}.`
1502
1506
  : `Released ${result.claim.leaseId}.`,
@@ -1,5 +1,6 @@
1
1
  import { type AuditAction } from "./remote-audit.js";
2
2
  import type { ApiTokenRow } from "./store.js";
3
+ import { type Html } from "./html.js";
3
4
  export declare const PEOPLE_AUDIT_CSS: string;
4
5
  export type PersonAuditView = {
5
6
  name: string;
@@ -11,4 +12,4 @@ export type PersonAuditView = {
11
12
  now: Date;
12
13
  };
13
14
  export declare const personHref: (name: string, more?: Record<string, string>) => string;
14
- export declare function personAuditHtml(view: PersonAuditView): string;
15
+ export declare function personAuditHtml(view: PersonAuditView): Html;
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { basename } from "node:path";
7
7
  import { tokenStatus } from "./remote-audit.js";
8
- const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
8
+ import { html } from "./html.js";
9
9
  const stamp = (value) => `${value.slice(0, 16).replace("T", " ")} UTC`;
10
10
  export const PEOPLE_AUDIT_CSS = `.person-audit{max-width:820px;min-width:0;overflow-wrap:anywhere}.person-audit h2{font-size:1.05rem;margin:1.25rem 0 8px}` +
11
11
  `.person-audit .rows{display:grid;border:1px solid var(--so-line);border-radius:10px;overflow:hidden}.person-audit .row-item{padding:10px 14px;border-bottom:1px solid var(--so-line);min-width:0}` +
@@ -17,22 +17,22 @@ export const PEOPLE_AUDIT_CSS = `.person-audit{max-width:820px;min-width:0;overf
17
17
  `@media(max-width:600px){.person-audit .row-item a{min-height:44px}}`;
18
18
  export const personHref = (name, more = {}) => `/people?${new URLSearchParams({ person: name, ...more }).toString()}`;
19
19
  export function personAuditHtml(view) {
20
- const tokens = view.tokens.length === 0 ? `<p class="meta">No API tokens.</p>` : `<div class="rows">${view.tokens.map(one => {
20
+ const tokens = view.tokens.length === 0 ? html `<p class="meta">No API tokens.</p>` : html `<div class="rows">${view.tokens.map(one => {
21
21
  const status = tokenStatus(one, view.now);
22
- return `<div class="row-item" data-token-name="${e(one.name)}"><p><a href="${e(personHref(view.name, { "token-name": one.name }))}">${e(one.name)}</a></p>` +
23
- `<p class="meta">${status === "active" ? "Active" : status === "expired" ? "Expired" : "Revoked"} · ${one.access === "act" ? "Can act" : "Read only"} · ` +
24
- `last used ${one.lastUsedAt === null ? "never" : e(stamp(one.lastUsedAt))}</p></div>`;
25
- }).join("")}</div>`;
26
- const filtered = view.token === null ? "" : `<p class="meta">With ${e(view.token)}</p><p><a href="${e(personHref(view.name))}">All tokens</a></p>`;
27
- const actions = view.actions.length === 0 ? `<p class="meta">No remote actions${view.token === null ? "" : " with this token"}.</p>` : `<div class="rows">${view.actions.map(actionHtml).join("")}</div>`;
28
- const older = view.nextCursor === null ? "" : `<p><a href="${e(personHref(view.name, { ...(view.token === null ? {} : { "token-name": view.token }), before: view.nextCursor }))}">Older actions</a></p>`;
29
- return `<p><a href="/people">People</a></p><h1>${e(view.name)}</h1><section class="person-audit"><h2>API tokens</h2>${tokens}` +
30
- `<h2>Remote activity</h2>${filtered}${actions}${older}</section>`;
22
+ return html `<div class="row-item" data-token-name="${one.name}"><p><a href="${personHref(view.name, { "token-name": one.name })}">${one.name}</a></p>\
23
+ <p class="meta">${status === "active" ? "Active" : status === "expired" ? "Expired" : "Revoked"} · ${one.access === "act" ? "Can act" : "Read only"} · \
24
+ last used ${one.lastUsedAt === null ? "never" : stamp(one.lastUsedAt)}</p></div>`;
25
+ })}</div>`;
26
+ const filtered = view.token === null ? "" : html `<p class="meta">With ${view.token}</p><p><a href="${personHref(view.name)}">All tokens</a></p>`;
27
+ const actions = view.actions.length === 0 ? html `<p class="meta">No remote actions${view.token === null ? "" : " with this token"}.</p>` : html `<div class="rows">${view.actions.map(actionHtml)}</div>`;
28
+ const older = view.nextCursor === null ? "" : html `<p><a href="${personHref(view.name, { ...(view.token === null ? {} : { "token-name": view.token }), before: view.nextCursor })}">Older actions</a></p>`;
29
+ return html `<p><a href="/people">People</a></p><h1>${view.name}</h1><section class="person-audit"><h2>API tokens</h2>${tokens}\
30
+ <h2>Remote activity</h2>${filtered}${actions}${older}</section>`;
31
31
  }
32
32
  function actionHtml(action) {
33
33
  const label = action.outcome === "ok" ? "OK" : action.outcome === "refused" ? "Refused" : "Error";
34
34
  const detail = [stamp(action.at), action.token ?? "unknown token", action.source === "mcp" ? "Agent (MCP)" : "CLI",
35
35
  ...(action.repo === null ? [] : [basename(action.repo)]), ...(action.reason === null ? [] : [action.reason])];
36
- return `<div class="row-item" data-action="${action.id}"><p><span class="outcome outcome-${action.outcome}">${label}</span>${e(action.name)}` +
37
- `${action.taskId === null ? "" : ` · <a href="/t/${encodeURIComponent(action.taskId)}">${e(action.taskId)}</a>`}</p><p class="meta">${detail.map(e).join(" · ")}</p></div>`;
36
+ return html `<div class="row-item" data-action="${action.id}"><p><span class="outcome outcome-${action.outcome}">${label}</span>${action.name}\
37
+ ${action.taskId === null ? "" : html ` · <a href="/t/${encodeURIComponent(action.taskId)}">${action.taskId}</a>`}</p><p class="meta">${detail.join(" · ")}</p></div>`;
38
38
  }
@@ -4,6 +4,7 @@
4
4
  * action ledger. Anyone signed in reads it; an instance operator changes it
5
5
  * with their password.
6
6
  */
7
+ import { type Html } from "./html.js";
7
8
  import type { LedgerEntry } from "./action-ledger.js";
8
9
  import { type SavedPolicy } from "./policy.js";
9
10
  export declare const POLICY_CSS: string;
@@ -14,7 +15,7 @@ export type PolicyView = {
14
15
  /** The project tools here, as a hint for the tools list. */
15
16
  toolNames: string[];
16
17
  };
17
- export declare function policyHtml(view: PolicyView, csrf: string, notice: {
18
+ export declare function policyHtml(view: PolicyView, notice: {
18
19
  said?: string | null;
19
20
  problem?: string | null;
20
- }): string;
21
+ }): Html;