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
@@ -1,5 +1,11 @@
1
+ /**
2
+ * Settings → Updates: the installed version, one of three ways to update
3
+ * (behind the operator's password), the update's steps live while it runs,
4
+ * and a one-time "What's new" card afterwards. The work itself is the
5
+ * `toolroll update` command (toolroll-update.ts), started as its own job.
6
+ */
7
+ import { html, postForm, replaceMarkup } from "./html.js";
1
8
  import { STEP_WORDS, UPDATE_STEPS, runtimeUpdateTerminal } from "./toolroll-update.js";
2
- const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3
9
  const clock = (iso) => { const d = new Date(iso); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; };
4
10
  export const UPDATES_CSS = `.updates{max-width:640px;min-width:0;overflow-wrap:anywhere}.updates .card{margin:0 0 16px}.updates h2{margin:0;font-size:1.0625rem}.updates .card>p{margin:4px 0 0}` +
5
11
  `.updates .update-actions{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0 0}.updates .update-actions button{white-space:nowrap}` +
@@ -23,60 +29,50 @@ export function updateStepsHtml(j, running) {
23
29
  const items = UPDATE_STEPS.map((step, i) => {
24
30
  const state = j.phase === "complete" || i < last ? "done" : i > last ? "waiting" : failed ? "failed" : finished ? "waiting" : "now";
25
31
  // Only where it adds something: which work it waits for, or what went wrong.
26
- const detail = state === "now" && step === "draining" && j.waiting ? `<span class="step-detail">${e(j.waiting.on)}.${j.waiting.action ? ` If nothing of it is running, run <code>${e(j.waiting.action)}</code>.` : ""}</span>`
27
- : (state === "now" && step === "draining") || (state === "failed" && j.phase === "rolling-back") ? `<span class="step-detail">${e(j.detail)}</span>` : "";
28
- return `<li data-step="${step}" data-state="${state}">${e(STEP_WORDS[step])}${detail}</li>`;
29
- }).join("");
30
- const title = j.phase === "scheduled" ? `Update to ${e(j.to.version)} scheduled for ${e(clock(j.at ?? j.startedAt))}`
31
- : j.phase === "rolling-back" ? `Restoring ${e(j.from.version)}`
32
- : j.kind === "rollback" ? `Rolling back to ${e(j.to.version)}` : `Updating to ${e(j.to.version)}`;
33
- const stalled = !running && !finished ? `<p class="update-problem" role="alert">The updater stopped. Run <code>toolroll update --resume</code> to continue it.</p>` : "";
34
- return `<div id="update-live" data-phase="${e(j.phase)}" data-done="${finished ? 1 : 0}"><h2>${title}</h2>` +
35
- (j.phase === "scheduled" ? `<p class="meta">Running work finishes first. You can close this page.</p>` : `<ol class="update-steps" aria-label="Update steps">${items}</ol>`) + stalled + `</div>`;
32
+ const detail = state === "now" && step === "draining" && j.waiting ? html `<span class="step-detail">${j.waiting.on}.${j.waiting.action ? html ` If nothing of it is running, run <code>${j.waiting.action}</code>.` : ""}</span>`
33
+ : (state === "now" && step === "draining") || (state === "failed" && j.phase === "rolling-back") ? html `<span class="step-detail">${j.detail}</span>` : "";
34
+ return html `<li data-step="${step}" data-state="${state}">${STEP_WORDS[step]}${detail}</li>`;
35
+ });
36
+ const title = j.phase === "scheduled" ? `Update to ${j.to.version} scheduled for ${clock(j.at ?? j.startedAt)}`
37
+ : j.phase === "rolling-back" ? `Restoring ${j.from.version}`
38
+ : j.kind === "rollback" ? `Rolling back to ${j.to.version}` : `Updating to ${j.to.version}`;
39
+ const stalled = !running && !finished ? html `<p class="update-problem" role="alert">The updater stopped. Run <code>toolroll update --resume</code> to continue it.</p>` : "";
40
+ return html `<div id="update-live" data-phase="${j.phase}" data-done="${finished ? 1 : 0}"><h2>${title}</h2>${j.phase === "scheduled" ? html `<p class="meta">Running work finishes first. You can close this page.</p>` : html `<ol class="update-steps" aria-label="Update steps">${items}</ol>`}${stalled}</div>`;
36
41
  }
37
42
  function outcomeHtml(j) {
38
43
  if (j.phase === "complete")
39
- return j.kind === "rollback" ? `<div class="card" data-update-outcome="rolled-back"><h2>Back on ${e(j.to.version)}</h2><p class="meta">${e(j.detail)}</p></div>` : "";
44
+ return j.kind === "rollback" ? html `<div class="card" data-update-outcome="rolled-back"><h2>Back on ${j.to.version}</h2><p class="meta">${j.detail}</p></div>` : "";
40
45
  if (j.phase === "cancelled")
41
- return `<div class="card" data-update-outcome="cancelled"><h2>Update cancelled</h2><p class="meta">Nothing changed.</p></div>`;
46
+ return html `<div class="card" data-update-outcome="cancelled"><h2>Update cancelled</h2><p class="meta">Nothing changed.</p></div>`;
42
47
  // Stopped on a finished run it can't show has ended: what is in the way and the one command, nothing more.
43
48
  if (j.phase === "refused" && j.waiting)
44
- return `<div class="card" data-update-outcome="stopped"><h2>Update to ${e(j.to.version)} stopped: run #${e(j.waiting.run)} is in the way</h2>` +
45
- `<p>${e(j.waiting.on)}. New work resumed.</p>` + (j.waiting.action ? `<p class="meta">If nothing of it is running, run <code>${e(j.waiting.action)}</code>, then update again.</p>` : `<p class="meta">Update again once it has stopped.</p>`) + `</div>`;
46
- const title = j.phase === "refused" ? `Didn't update to ${e(j.to.version)}` : j.phase === "restored" ? `Update to ${e(j.to.version)} didn't finish` : "The update needs attention";
47
- return `<div class="card" data-update-outcome="${e(j.phase)}"><h2>${title}</h2><p class="${j.phase === "needs-attention" ? "update-problem" : ""}" role="alert">${e(j.detail)}</p>` +
48
- `<details><summary>Steps</summary>${updateStepsHtml(j, false).replace(/<h2>.*?<\/h2>/, "")}</details></div>`;
49
+ return html `<div class="card" data-update-outcome="stopped"><h2>Update to ${j.to.version} stopped: run #${j.waiting.run} is in the way</h2><p>${j.waiting.on}. New work resumed.</p>${j.waiting.action ? html `<p class="meta">If nothing of it is running, run <code>${j.waiting.action}</code>, then update again.</p>` : html `<p class="meta">Update again once it has stopped.</p>`}</div>`;
50
+ const title = j.phase === "refused" ? html `Didn't update to ${j.to.version}` : j.phase === "restored" ? html `Update to ${j.to.version} didn't finish` : "The update needs attention";
51
+ return html `<div class="card" data-update-outcome="${j.phase}"><h2>${title}</h2><p class="${j.phase === "needs-attention" ? "update-problem" : ""}" role="alert">${j.detail}</p><details><summary>Steps</summary>${replaceMarkup(updateStepsHtml(j, false), /<h2>.*?<\/h2>/, () => html ``)}</details></div>`;
49
52
  }
50
53
  export function updatesHtml(view, notice) {
51
- const note = notice.problem ? `<p class="update-problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
54
+ const note = notice.problem ? html `<p class="update-problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
52
55
  const j = view.journal;
53
56
  const active = j !== null && !runtimeUpdateTerminal(j.phase);
54
- const csrf = `<input type="hidden" name="csrf" value="${e(view.csrf)}">`;
55
- const whatsNew = view.whatsNew === null ? "" : `<div class="card whats-new" data-whats-new="${e(view.whatsNew.version)}"><h2>What’s new in ${e(view.whatsNew.version)}</h2>` +
56
- (view.whatsNew.notes.length > 0 ? `<ul>${view.whatsNew.notes.map(line => `<li>${e(line)}</li>`).join("")}</ul>` : "") +
57
- `<p class="meta"><a href="https://github.com/ap9000/toolroll/releases/tag/v${e(view.whatsNew.version)}" rel="noreferrer">Full release notes</a></p>` +
58
- `<form method="post" action="/settings/updates/seen">${csrf}<button type="submit">Got it</button></form></div>`;
57
+ const whatsNew = view.whatsNew === null ? "" : html `<div class="card whats-new" data-whats-new="${view.whatsNew.version}"><h2>What’s new in ${view.whatsNew.version}</h2>${view.whatsNew.notes.length > 0 ? html `<ul>${view.whatsNew.notes.map(line => html `<li>${line}</li>`)}</ul>` : ""}<p class="meta"><a href="https://github.com/ap9000/toolroll/releases/tag/v${view.whatsNew.version}" rel="noreferrer">Full release notes</a></p>${postForm("/settings/updates/seen", html `<button type="submit">Got it</button>`)}</div>`;
59
58
  if (active) {
60
- const cancel = ["scheduled", "draining"].includes(j.phase) ? `<form method="post" action="/settings/updates/cancel" class="update-actions">${csrf}<button type="submit">Cancel update</button></form>` : "";
61
- return `<section class="updates">${note}<div class="card" id="update-region">${updateStepsHtml(j, view.running)}${cancel}</div><p class="meta stamp" id="update-region-stamp"></p></section>`;
59
+ const cancel = ["scheduled", "draining"].includes(j.phase) ? postForm("/settings/updates/cancel", html `<button type="submit">Cancel update</button>`, { attrs: { class: "update-actions" } }) : "";
60
+ return html `<section class="updates">${note}<div class="card" id="update-region">${updateStepsHtml(j, view.running)}${cancel}</div><p class="meta stamp" id="update-region-stamp"></p></section>`;
62
61
  }
63
62
  const latest = "version" in view.latest ? view.latest.version : null;
64
63
  const newer = latest !== null && newerThan(latest, view.current);
65
- const state = view.method.kind === "npx" ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">npx runs the latest release each time, so this is current.</p>`
66
- : view.method.kind === "source" ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">This runs from a source checkout. Update it with git.</p>`
67
- : newer && view.method.kind === "desktop" ? `<h2>Toolroll ${e(latest)} is available</h2><p class="meta">You have ${e(view.current)}. Update it from the Toolroll app.</p>`
68
- : newer ? `<h2>Toolroll ${e(latest)} is available</h2><p class="meta">You have ${e(view.current)}. Running work finishes first, and your current version is kept so you can go back.</p>`
69
- : "off" in view.latest ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">Update checks are off.</p><form method="get" action="/settings/updates" class="update-actions"><input type="hidden" name="check" value="now"><button type="submit">Check now</button></form>`
70
- : latest === null ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">Couldn’t check for a newer release: ${e("problem" in view.latest ? view.latest.problem : "")}</p>`
71
- : `<h2>Toolroll ${e(view.current)} is up to date</h2>`;
64
+ const state = view.method.kind === "npx" ? html `<h2>Toolroll ${view.current}</h2><p class="meta">npx runs the latest release each time, so this is current.</p>`
65
+ : view.method.kind === "source" ? html `<h2>Toolroll ${view.current}</h2><p class="meta">This runs from a source checkout. Update it with git.</p>`
66
+ : newer && view.method.kind === "desktop" ? html `<h2>Toolroll ${latest} is available</h2><p class="meta">You have ${view.current}. Update it from the Toolroll app.</p>`
67
+ : newer ? html `<h2>Toolroll ${latest} is available</h2><p class="meta">You have ${view.current}. Running work finishes first, and your current version is kept so you can go back.</p>`
68
+ : "off" in view.latest ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Update checks are off.</p><form method="get" action="/settings/updates" class="update-actions"><input type="hidden" name="check" value="now"><button type="submit">Check now</button></form>`
69
+ : latest === null ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Couldn’t check for a newer release: ${"problem" in view.latest ? view.latest.problem : ""}</p>`
70
+ : html `<h2>Toolroll ${view.current} is up to date</h2>`;
72
71
  const form = newer && !["npx", "source", "desktop"].includes(view.method.kind)
73
- ? `<form method="post" action="/settings/updates">${csrf}<input type="hidden" name="version" value="${e(latest)}">` +
74
- `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
75
- `<div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button>` +
76
- `<button type="submit" name="when" value="when-idle">When idle</button><button type="submit" name="when" value="tonight">Tonight (03:00)</button></div></form>`
72
+ ? postForm("/settings/updates", html `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button><button type="submit" name="when" value="when-idle">When idle</button><button type="submit" name="when" value="tonight">Tonight (03:00)</button></div>`, { hidden: { version: latest } })
77
73
  : "";
78
- const rollback = view.rollbackTo !== null ? `<p class="meta">To go back to ${e(view.rollbackTo)}: <code>toolroll update --rollback</code></p>` : "";
79
- return `<section class="updates">${note}${whatsNew}${j ? outcomeHtml(j) : ""}<div class="card" data-update-state="${newer ? "available" : "current"}">${state}${form}${rollback}</div></section>`;
74
+ const rollback = view.rollbackTo !== null ? html `<p class="meta">To go back to ${view.rollbackTo}: <code>toolroll update --rollback</code></p>` : "";
75
+ return html `<section class="updates">${note}${whatsNew}${j ? outcomeHtml(j) : ""}<div class="card" data-update-state="${newer ? "available" : "current"}">${state}${form}${rollback}</div></section>`;
80
76
  }
81
77
  export function newerThan(a, b) {
82
78
  const [x, y] = [a, b].map(v => v.split(".").map(Number));
@@ -1,3 +1,6 @@
1
+ /** Settings → Tools: one project's MCP servers, in plain words. Secrets are
2
+ * entered here and nowhere else, and never shown again. */
3
+ import { type Html } from "./html.js";
1
4
  import { type FoundTool, type ProjectTool, type ToolSpec } from "./project-tools.js";
2
5
  export declare const TOOLS_CSS = ".tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:center;gap:10px}.tools .tool-heading h2{flex:1;min-width:0}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready=\"false\"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools .tool-note{color:var(--so-warning)}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:\"\";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile .brand-mark{grid-row:span 2}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile>span:not(.brand-mark){font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state=\"connected\"]>span:not(.brand-mark){color:var(--so-success)}.tools .connect-local{grid-column:1/-1}.tools .connect-local>span:not(.brand-mark){-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile:disabled{cursor:default;opacity:.6}@media(max-width:600px){.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local)>span:not(.brand-mark){display:none}.tools .connect-local>span:not(.brand-mark){grid-column:2}.tools .connect-tile{min-height:52px;padding:8px 10px}.tools .connect-tile .brand-mark{grid-row:auto}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}}";
3
6
  export type ToolsView = {
@@ -31,10 +34,10 @@ export type ToolsView = {
31
34
  }[];
32
35
  };
33
36
  /** The project chooser: it opens the chosen project at once; without scripts, its button does. */
34
- export declare function toolsProjectPicker(projects: readonly string[], chosen: string, keep?: Record<string, string>): string;
37
+ export declare function toolsProjectPicker(projects: readonly string[], chosen: string, keep?: Record<string, string>): Html | "";
35
38
  /** Choosing another project marks every form on the page as stale before it reloads, so a tap racing the reload is refused. */
36
39
  export declare const TOOLS_PROJECT_SCRIPT = "(function(){var s=document.querySelector('select[data-tools-project]');if(!s)return;s.addEventListener('change',function(){document.querySelectorAll('input[name=shown]').forEach(function(i){i.value=s.value;});});})();";
37
40
  export declare function toolsHtml(view: ToolsView, csrf: string, canManage: boolean, message?: {
38
41
  said?: string | null;
39
42
  problem?: string | null;
40
- }): string;
43
+ }): Html;
package/dist/tools-ui.js CHANGED
@@ -1,66 +1,66 @@
1
1
  /** Settings → Tools: one project's MCP servers, in plain words. Secrets are
2
2
  * entered here and nowhere else, and never shown again. */
3
+ import { html, postForm } from "./html.js";
3
4
  import { brandMarkHtml, TILE_PHONE } from "./brand-mark.js";
4
5
  import { toolCommandLine, toolStanding } from "./project-tools.js";
5
- const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
6
- const hidden = (data) => Object.entries(data).map(([k, v]) => `<input type="hidden" name="${e(k)}" value="${e(v)}">`).join("");
6
+ const hidden = (data) => Object.entries(data).map(([k, v]) => html `<input type="hidden" name="${k}" value="${v}">`);
7
7
  export const TOOLS_CSS = `.tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:center;gap:10px}.tools .tool-heading h2{flex:1;min-width:0}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready="false"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools .tool-note{color:var(--so-warning)}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:"";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile .brand-mark{grid-row:span 2}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile>span:not(.brand-mark){font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state="connected"]>span:not(.brand-mark){color:var(--so-success)}.tools .connect-local{grid-column:1/-1}.tools .connect-local>span:not(.brand-mark){-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile:disabled{cursor:default;opacity:.6}${TILE_PHONE}{.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local)>span:not(.brand-mark){display:none}.tools .connect-local>span:not(.brand-mark){grid-column:2}.tools .connect-tile{min-height:52px;padding:8px 10px}.tools .connect-tile .brand-mark{grid-row:auto}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}}`;
8
8
  /** The project chooser: it opens the chosen project at once; without scripts, its button does. */
9
9
  export function toolsProjectPicker(projects, chosen, keep = {}) {
10
10
  if (projects.length < 2)
11
11
  return "";
12
- const options = projects.map(p => `<option value="${e(p)}"${p === chosen ? " selected" : ""}>${e(p.split(/[\\/]/).filter(Boolean).at(-1) ?? p)}</option>`).join("");
13
- return `<form class="tools" method="get" action="/settings/tools" data-autosave>${hidden(keep)}<label>Project<select name="repo" data-tools-project>${options}</select></label><button type="submit">Show project</button></form>`;
12
+ const options = projects.map(p => html `<option value="${p}"${p === chosen ? html ` selected` : ""}>${p.split(/[\\/]/).filter(Boolean).at(-1) ?? p}</option>`);
13
+ return html `<form class="tools" method="get" action="/settings/tools" data-autosave>${hidden(keep)}<label>Project<select name="repo" data-tools-project>${options}</select></label><button type="submit">Show project</button></form>`;
14
14
  }
15
15
  /** Choosing another project marks every form on the page as stale before it reloads, so a tap racing the reload is refused. */
16
16
  export const TOOLS_PROJECT_SCRIPT = `(function(){var s=document.querySelector('select[data-tools-project]');if(!s)return;s.addEventListener('change',function(){document.querySelectorAll('input[name=shown]').forEach(function(i){i.value=s.value;});});})();`;
17
- const password = '<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>';
17
+ const password = html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`;
18
18
  function toolCard(one, base, manage, project, research = null) {
19
19
  const { tool, secretsSet } = one;
20
20
  const standing = toolStanding(tool, secretsSet);
21
21
  // One line per secret: the disclosure is both where it stands and where it is set.
22
22
  const secrets = manage ? tool.spec.secrets.map(secret => {
23
23
  const set = secretsSet.includes(secret.name);
24
- return `<details><summary>${set ? `Replace ${e(secret.name)}` : `Set ${e(secret.name)}${secret.optional ? " (optional)" : ""}`}</summary><form method="post" action="/settings/tools/change">${hidden({ ...base, action: "secret", name: tool.name, secret: secret.name })}<label>${e(secret.name)}<input type="password" name="value" autocomplete="off" required></label>${password}<button>Save</button></form></details>`;
25
- }).join("") : "";
26
- const tested = tool.lastTest?.ok ? `<p>Tested ${e(tool.lastTest.at.slice(0, 16).replace("T", " "))} UTC: ${e(tool.lastTest.tools.join(", ") || "it lists no tools")}</p>` : "";
27
- const details = `<details><summary>Details</summary><div class="tool-facts"><p>Starts: <code>${e(toolCommandLine(tool.spec))}</code></p><p>From ${e(tool.source)} · added by ${e(tool.createdBy)}</p>${tested}</div></details>`;
28
- const failed = tool.lastTest !== null && !tool.lastTest.ok ? `<p class="problem" role="status">${e(tool.lastTest.problem ?? "The last test failed.")}</p>` : "";
24
+ return html `<details><summary>${set ? `Replace ${secret.name}` : `Set ${secret.name}${secret.optional ? " (optional)" : ""}`}</summary>${postForm("/settings/tools/change", html `<label>${secret.name}<input type="password" name="value" autocomplete="off" required></label>${password}<button>Save</button>`, { hidden: { ...base, action: "secret", name: tool.name, secret: secret.name } })}</details>`;
25
+ }) : "";
26
+ const tested = tool.lastTest?.ok ? html `<p>Tested ${tool.lastTest.at.slice(0, 16).replace("T", " ")} UTC: ${tool.lastTest.tools.join(", ") || "it lists no tools"}</p>` : "";
27
+ const details = html `<details><summary>Details</summary><div class="tool-facts"><p>Starts: <code>${toolCommandLine(tool.spec)}</code></p><p>From ${tool.source} · added by ${tool.createdBy}</p>${tested}</div></details>`;
28
+ const failed = tool.lastTest !== null && !tool.lastTest.ok ? html `<p class="problem" role="status">${tool.lastTest.problem ?? "The last test failed."}</p>` : "";
29
29
  // Connected, but research can't use it (its grant writes): said once, with the one way to fix it.
30
- const reconnect = research === null || !manage ? "" : `<details><summary>Reconnect read-only</summary><form method="post" action="/settings/tools/connect">${hidden({ ...base, service: tool.name, access: "read" })}${password}<button>Reconnect read-only</button></form></details>`;
31
- const limited = research === null ? "" : `<p class="tool-note" role="status">${e(research)}</p>${reconnect}`;
32
- const controls = manage ? `<div class="tool-controls"><form method="post" action="/settings/tools/change">${hidden({ ...base, action: "test", name: tool.name })}<button aria-label="Test ${e(tool.name)} in ${e(project)}">Test</button></form><form method="post" action="/settings/tools/change">${hidden({ ...base, action: "remove", name: tool.name })}<button class="secondary">Remove from ${e(project)}</button></form></div>` : "";
33
- return `<article class="card" id="tool-${e(tool.name)}"><div class="tool-heading">${brandMarkHtml(tool.source === "custom" ? null : tool.name, tool.name, standing.ready)}<h2>${e(tool.name)}</h2><span class="tool-state" data-ready="${standing.ready}">${e(standing.words)}</span></div><p>${e(tool.spec.about)}</p>${failed}${limited}${secrets}${details}${controls}</article>`;
30
+ const reconnect = research === null || !manage ? "" : html `<details><summary>Reconnect read-only</summary>${postForm("/settings/tools/connect", html `${password}<button>Reconnect read-only</button>`, { hidden: { ...base, service: tool.name, access: "read" } })}</details>`;
31
+ const limited = research === null ? "" : html `<p class="tool-note" role="status">${research}</p>${reconnect}`;
32
+ const controls = manage ? html `<div class="tool-controls">${postForm("/settings/tools/change", html `<button aria-label="Test ${tool.name} in ${project}">Test</button>`, { hidden: { ...base, action: "test", name: tool.name } })}${postForm("/settings/tools/change", html `<button class="secondary">Remove from ${project}</button>`, { hidden: { ...base, action: "remove", name: tool.name } })}</div>` : "";
33
+ return html `<article class="card" id="tool-${tool.name}"><div class="tool-heading">${brandMarkHtml(tool.source === "custom" ? null : tool.name, tool.name, standing.ready)}<h2>${tool.name}</h2><span class="tool-state" data-ready="${String(standing.ready)}">${standing.words}</span></div><p>${tool.spec.about}</p>${failed}${limited}${secrets}${details}${controls}</article>`;
34
34
  }
35
35
  /** A service connected here, offered to the person's other projects: each button signs in again for that project (tokens are never copied). */
36
36
  function elsewhere(view, base) {
37
- const offers = view.connections.filter(one => one.state === "connected").flatMap(one => (view.others ?? []).filter(other => other.open.includes(one.id)).map(other => `<button name="also" value="${e(`${one.id}:${other.repo}`)}" class="secondary">Also connect ${e(one.label)} to ${e(other.project)}</button>`));
37
+ const offers = view.connections.filter(one => one.state === "connected").flatMap(one => (view.others ?? []).filter(other => other.open.includes(one.id)).map(other => html `<button name="also" value="${`${one.id}:${other.repo}`}" class="secondary">Also connect ${one.label} to ${other.project}</button>`));
38
38
  if (offers.length === 0)
39
39
  return "";
40
- return `<details class="tool-elsewhere"><summary>Also connect to another project</summary><form method="post" action="/settings/tools/connect">${hidden({ ...base, ...(view.kit === null ? {} : { kit: view.kit }) })}${password}<div class="tool-controls">${offers.join("")}</div></form></details>`;
40
+ return html `<details class="tool-elsewhere"><summary>Also connect to another project</summary>${postForm("/settings/tools/connect", html `${password}<div class="tool-controls">${offers}</div>`, { hidden: { ...base, ...(view.kit === null ? {} : { kit: view.kit }) } })}</details>`;
41
41
  }
42
42
  export function toolsHtml(view, csrf, canManage, message = {}) {
43
43
  const manage = canManage && csrf !== "";
44
44
  // `shown` is the project this page shows; the server refuses a post whose repo differs from it.
45
- const base = { csrf, repo: view.repo, shown: view.repo };
46
- const to = ` to ${e(view.project)}`;
47
- const intro = `<p>Builds in ${e(view.project)} use exactly these tools. MCP servers set up elsewhere on this computer aren't used.</p><details><summary>How tools work</summary><ul><li>A tool you add reaches work you approve from now on. Approve a task again to give it a new tool.</li><li>Removing a tool takes it away from every build right away.</li><li>Secrets stay on this computer, are never shown again, and go only to the tool that needs them.</li><li>Reviewers and the lead chat never get tools. Gemini builds don't use tools yet.</li></ul></details>`;
48
- const cards = view.tools.length === 0 ? '<p class="meta">No tools yet. Builds in this project get none.</p>' : view.tools.map(one => toolCard(one, base, manage, view.project, view.connections.find(c => c.id === one.tool.name && c.state === "connected")?.research ?? null)).join("");
49
- const fromList = view.catalog.length === 0 ? "" : `<form method="post" action="/settings/tools/change">${hidden({ ...base, action: "add-catalog" })}<label>Common tools<select name="catalog">${view.catalog.map(one => `<option value="${e(one.name)}">${e(one.label)}: ${e(one.about)}</option>`).join("")}</select></label>${password}<button>Add${to}</button></form>`;
50
- const custom = `<details><summary>Add your own</summary><form method="post" action="/settings/tools/change">${hidden({ ...base, action: "add-custom" })}<label>Name<input name="name" pattern="[a-z0-9][a-z0-9_\\-]{0,39}" maxlength="40" required placeholder="for example: postgres"></label><label>It runs as<select name="transport"><option value="stdio">A program on this computer</option><option value="http">A web address</option></select></label><label>Command or address<input name="target" required placeholder="npx -y some-mcp-server@1.2.3, or https://…"></label><label>Secrets it needs (names, comma separated)<input name="secrets" placeholder="for example: DATABASE_URL"></label><p class="meta">For a web address, the first secret is sent as its sign-in token. Set each secret's value after adding.</p>${password}<button>Add${to}</button></form></details>`;
45
+ const base = { repo: view.repo, shown: view.repo };
46
+ const to = ` to ${view.project}`;
47
+ const intro = html `<p>Builds in ${view.project} use exactly these tools. MCP servers set up elsewhere on this computer aren't used.</p><details><summary>How tools work</summary><ul><li>A tool you add reaches work you approve from now on. Approve a task again to give it a new tool.</li><li>Removing a tool takes it away from every build right away.</li><li>Secrets stay on this computer, are never shown again, and go only to the tool that needs them.</li><li>Reviewers and the lead chat never get tools. Gemini builds don't use tools yet.</li></ul></details>`;
48
+ const cards = view.tools.length === 0 ? html `<p class="meta">No tools yet. Builds in this project get none.</p>` : view.tools.map(one => toolCard(one, base, manage, view.project, view.connections.find(c => c.id === one.tool.name && c.state === "connected")?.research ?? null));
49
+ const fromList = view.catalog.length === 0 ? "" : postForm("/settings/tools/change", html `<label>Common tools<select name="catalog">${view.catalog.map(one => html `<option value="${one.name}">${one.label}: ${one.about}</option>`)}</select></label>${password}<button>Add${to}</button>`, { hidden: { ...base, action: "add-catalog" } });
50
+ const custom = html `<details><summary>Add your own</summary>${postForm("/settings/tools/change", html `<label>Name<input name="name" pattern="[a-z0-9][a-z0-9_\\-]{0,39}" maxlength="40" required placeholder="for example: postgres"></label><label>It runs as<select name="transport"><option value="stdio">A program on this computer</option><option value="http">A web address</option></select></label><label>Command or address<input name="target" required placeholder="npx -y some-mcp-server@1.2.3, or https://…"></label><label>Secrets it needs (names, comma separated)<input name="secrets" placeholder="for example: DATABASE_URL"></label><p class="meta">For a web address, the first secret is sent as its sign-in token. Set each secret's value after adding.</p>${password}<button>Add${to}</button>`, { hidden: { ...base, action: "add-custom" } })}</details>`;
51
51
  // One click: sign in on the service's own page. The password is the same as for adding any tool.
52
52
  const tiles = view.connections.map(one => one.state === "taken"
53
- ? `<button type="button" class="connect-tile" id="connect-${e(one.id)}" data-state="taken" disabled>${brandMarkHtml(one.id, one.label, false)}<strong>${e(one.label)}</strong><span>Added another way</span></button>`
54
- : `<button name="service" value="${e(one.id)}" class="connect-tile${one.local ? " connect-local" : ""}" id="connect-${e(one.id)}" data-state="${one.state}" aria-label="${one.state === "connected" ? `${e(one.label)}, connected${to}. ${one.local ? "Connect again" : "Sign in again"}` : `Connect ${e(one.label)}${to}`}">${brandMarkHtml(one.id.replace(/-desktop$/, ""), one.label, one.state === "connected")}<strong>${e(one.label)}</strong><span>${one.state === "connected" ? "Connected" : e(one.about)}</span></button>`).join("");
53
+ ? html `<button type="button" class="connect-tile" id="connect-${one.id}" data-state="taken" disabled>${brandMarkHtml(one.id, one.label, false)}<strong>${one.label}</strong><span>Added another way</span></button>`
54
+ : html `<button name="service" value="${one.id}" class="connect-tile${one.local ? " connect-local" : ""}" id="connect-${one.id}" data-state="${one.state}" aria-label="${one.state === "connected" ? `${one.label}, connected${to}. ${one.local ? "Connect again" : "Sign in again"}` : `Connect ${one.label}${to}`}">${brandMarkHtml(one.id.replace(/-desktop$/, ""), one.label, one.state === "connected")}<strong>${one.label}</strong><span>${one.state === "connected" ? "Connected" : one.about}</span></button>`);
55
55
  const wanted = view.connections.find(one => one.id === view.wanted && one.state !== "taken");
56
- const first = wanted === undefined ? "" : `<button name="service" value="${e(wanted.id)}" class="connect-wanted">${wanted.state === "connected" && !wanted.local ? `Sign in to ${e(wanted.label)} again for ${e(view.project)}` : `Connect ${e(wanted.label)}${to}`}</button>`;
56
+ const first = wanted === undefined ? "" : html `<button name="service" value="${wanted.id}" class="connect-wanted">${wanted.state === "connected" && !wanted.local ? `Sign in to ${wanted.label} again for ${view.project}` : `Connect ${wanted.label}${to}`}</button>`;
57
57
  const connecting = manage && view.connections.length > 0;
58
58
  // A Connect that failed says why at its button, focused so it is heard and seen there (not only at the top).
59
59
  const atButton = connecting && view.wanted !== null && view.connections.some(one => one.id === view.wanted) && Boolean(message.problem);
60
- const failed = atButton ? `<p class="problem connect-problem" id="connect-problem" role="alert" tabindex="-1" autofocus>${e(message.problem)}</p>` : "";
61
- const connect = connecting ? `<section class="card tool-connect" id="connect" aria-labelledby="connect-heading"><h2 id="connect-heading">Connect${to}</h2><form method="post" action="/settings/tools/connect">${hidden({ ...base, ...(view.kit === null ? {} : { kit: view.kit }) })}${password}${first}${failed}<div class="connect-grid">${tiles}</div></form>${elsewhere(view, base)}</section>` : "";
62
- const add = manage ? `<details class="card"${view.tools.length === 0 ? " open" : ""}><summary>Add a tool</summary>${fromList}${custom}</details>` : "";
63
- const found = manage && view.found.length > 0 ? `<details class="card"><summary>Found on this computer (${view.found.length})</summary><p class="meta">MCP servers your other apps use. Adding one copies its settings, and any secrets it has, into this project.</p><form method="post" action="/settings/tools/change">${hidden({ ...base, action: "import" })}${view.found.map(one => `<label class="tool-found"><input type="checkbox" name="import" value="${e(one.spec.name)}"><span><strong>${e(one.spec.name)}</strong> · ${e(one.source)}<br><code>${e(toolCommandLine(one.spec))}</code>${one.spec.secrets.length === 0 ? "" : `<br><span class="meta">Brings ${e(one.spec.secrets.map(s => s.name).join(", "))}</span>`}</span></label>`).join("")}${password}<button>Add selected${to}</button></form></details>` : "";
64
- const note = atButton ? "" : message.problem ? `<p class="problem" role="alert">${e(message.problem)}</p>` : message.said ? `<p role="status">${e(message.said)}</p>` : "";
65
- return `<section class="tools">${note}${intro}${cards}${connect}${add}${found}</section>`;
60
+ const failed = atButton ? html `<p class="problem connect-problem" id="connect-problem" role="alert" tabindex="-1" autofocus>${message.problem}</p>` : "";
61
+ const connect = connecting ? html `<section class="card tool-connect" id="connect" aria-labelledby="connect-heading"><h2 id="connect-heading">Connect${to}</h2>${postForm("/settings/tools/connect", html `${password}${first}${failed}<div class="connect-grid">${tiles}</div>`, { hidden: { ...base, ...(view.kit === null ? {} : { kit: view.kit }) } })}${elsewhere(view, base)}</section>` : "";
62
+ const add = manage ? html `<details class="card"${view.tools.length === 0 ? html ` open` : ""}><summary>Add a tool</summary>${fromList}${custom}</details>` : "";
63
+ const found = manage && view.found.length > 0 ? html `<details class="card"><summary>Found on this computer (${view.found.length})</summary><p class="meta">MCP servers your other apps use. Adding one copies its settings, and any secrets it has, into this project.</p>${postForm("/settings/tools/change", html `${view.found.map(one => html `<label class="tool-found"><input type="checkbox" name="import" value="${one.spec.name}"><span><strong>${one.spec.name}</strong> · ${one.source}<br><code>${toolCommandLine(one.spec)}</code>${one.spec.secrets.length === 0 ? "" : html `<br><span class="meta">Brings ${one.spec.secrets.map(s => s.name).join(", ")}</span>`}</span></label>`)}${password}<button>Add selected${to}</button>`, { hidden: { ...base, action: "import" } })}</details>` : "";
64
+ const note = atButton ? "" : message.problem ? html `<p class="problem" role="alert">${message.problem}</p>` : message.said ? html `<p role="status">${message.said}</p>` : "";
65
+ return html `<section class="tools">${note}${intro}${cards}${connect}${add}${found}</section>`;
66
66
  }
@@ -1,3 +1,11 @@
1
+ /**
2
+ * A time a person reads. A desk shows the full stamp, as before; a phone (760px and narrower) shows the short
3
+ * one — "16:39" today, "Yesterday 16:39", "Sep 28" otherwise — and the full stamp stays in the title. Stamps are
4
+ * UTC to the minute, so the server counts in UTC; in the browser `localizeTimes` rewords each of these with the same
5
+ * formatter in the viewer's own zone, as the React views do. The CSS that picks one lives in the shared page
6
+ * stylesheet (`.so-when-*` in serve.ts).
7
+ */
8
+ import { type Html } from "./html.js";
1
9
  /** The viewer's own time zone (a browser's); UTC where there is none to read. */
2
10
  export declare function viewerZone(): string;
3
11
  /** The one formatter for a time a person reads, on every surface: "16:39" today, "Yesterday 16:39" /
@@ -23,10 +31,10 @@ export declare function localizeTimes(root: ParentNode, now?: Date, zone?: strin
23
31
  /** The exact second in `zone` ("2026-09-30 16:39:12", with " UTC" when it is UTC): an audit record's stamp. */
24
32
  export declare function exactWhen(iso: string, zone?: string): string;
25
33
  /** The `<time>` for an exact stamp: its seconds in UTC words on the server, the viewer's zone in a browser. */
26
- export declare function exactWhenHtml(iso: string): string;
34
+ export declare function exactWhenHtml(iso: string): Html;
27
35
  /** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
28
- export declare function whenHtml(iso: string | null, full: string, now?: Date): string;
36
+ export declare function whenHtml(iso: string | null, full: string, now?: Date): Html;
29
37
  /** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
30
38
  export declare const utcMinute: (iso: string) => string;
31
39
  /** Both the desk and phone forms of a UTC stamp. */
32
- export declare const whenUtc: (iso: string | null, now?: Date) => string;
40
+ export declare const whenUtc: (iso: string | null, now?: Date) => Html;
package/dist/when-html.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * formatter in the viewer's own zone, as the React views do. The CSS that picks one lives in the shared page
6
6
  * stylesheet (`.so-when-*` in serve.ts).
7
7
  */
8
- const escape = (value) => value.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
8
+ import { html } from "./html.js";
9
9
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
10
10
  const DAY_MS = 86_400_000;
11
11
  /** One `Intl.DateTimeFormat` per zone: building one is the slow part, and every time on a page shares a few zones. */
@@ -121,16 +121,15 @@ export function exactWhen(iso, zone = "UTC") {
121
121
  }
122
122
  /** The `<time>` for an exact stamp: its seconds in UTC words on the server, the viewer's zone in a browser. */
123
123
  export function exactWhenHtml(iso) {
124
- return `<time data-when="exact" datetime="${escape(iso)}" title="${escape(iso)}">${escape(exactWhen(iso))}</time>`;
124
+ return html `<time data-when="exact" datetime="${iso}" title="${iso}">${exactWhen(iso)}</time>`;
125
125
  }
126
126
  /** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
127
127
  export function whenHtml(iso, full, now = new Date()) {
128
128
  if (iso === null || iso === "")
129
- return "";
130
- const words = escape(full);
131
- return `<time data-when datetime="${escape(iso)}" title="${words}"><span class="so-when-full">${words}</span><span class="so-when-short">${escape(shortWhen(iso, now))}</span></time>`;
129
+ return html ``;
130
+ return html `<time data-when datetime="${iso}" title="${full}"><span class="so-when-full">${full}</span><span class="so-when-short">${shortWhen(iso, now)}</span></time>`;
132
131
  }
133
132
  /** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
134
133
  export const utcMinute = (iso) => `${iso.slice(0, 16).replace("T", " ")} UTC`;
135
134
  /** Both the desk and phone forms of a UTC stamp. */
136
- export const whenUtc = (iso, now = new Date()) => iso === null || iso === "" ? "" : whenHtml(iso, utcMinute(iso), now);
135
+ export const whenUtc = (iso, now = new Date()) => iso === null || iso === "" ? html `` : whenHtml(iso, utcMinute(iso), now);
@@ -22,6 +22,7 @@ export { ACCEPT_NEEDS_REASON, cantAcceptYetOf, evidenceProblemOf, type EvidenceP
22
22
  import { type ProofVerdict } from "./proof.js";
23
23
  import type { ReviewRetryState, TaskState } from "./store.js";
24
24
  import type { TaskControlView } from "./task-control.js";
25
+ import { type Html } from "./html.js";
25
26
  /** The Work destination's views — shortcuts over the same rows, never a
26
27
  * persisted state. All is the default. */
27
28
  export type WorkView = "all" | "needs-you" | "running" | "completed";
@@ -273,8 +274,9 @@ export declare function compareWorkRows(a: {
273
274
  * lives under. */
274
275
  export type PrimaryDestination = "chat" | "work" | "projects" | "flows" | "settings" | null;
275
276
  export declare function primaryDestinationOf(active: string): PrimaryDestination;
276
- /** Learning stays in Settings and a useful, closed result disclosure. */
277
- export declare function learningHtml(view: import('./project-learning.js').LearningView, csrf: string, canManage: boolean, source?: number): string;
277
+ /** Learning stays in Settings and a useful, closed result disclosure. `csrf` gates the management forms (a cookie
278
+ * session has one); postForm adds the request's own CSRF field. */
279
+ export declare function learningHtml(view: import('./project-learning.js').LearningView, csrf: string, canManage: boolean, source?: number): Html;
278
280
  /** The first signed requirement a finished attempt missed (failed or unanswered; a person's own check is not a miss),
279
281
  * with the evidence line behind it in plain words. Null when every requirement held. */
280
282
  export declare function missedRequirementOf(matrix: readonly {
@@ -21,6 +21,7 @@ export { ACCEPT_NEEDS_REASON, cantAcceptYetOf, evidenceProblemOf };
21
21
  import { GOAL_ASSESSMENT_PENDING, manualReviewOnly, plainReasonWords } from "./proof.js";
22
22
  import { OPEN_RESULT, plainReasonOf, stageOfCode, taskStatusOf, workToneOf } from "./task-status.js";
23
23
  import { whenUtc } from "./when-html.js";
24
+ import { html, postForm } from "./html.js";
24
25
  export const WORK_VIEWS = [
25
26
  { key: "all", label: "All", hint: "Every task in view, most urgent first.", empty: "Nothing is in progress. Describe work in chat or add a task, and it appears here." },
26
27
  { key: "needs-you", label: "Needs you", hint: "Tasks waiting on an answer, approval, or your inspection.", empty: "Nothing needs you right now. Queued and running work continues on its own." },
@@ -521,19 +522,18 @@ export function primaryDestinationOf(active) {
521
522
  return "settings";
522
523
  return null;
523
524
  }
524
- /** Learning stays in Settings and a useful, closed result disclosure. */
525
+ /** Learning stays in Settings and a useful, closed result disclosure. `csrf` gates the management forms (a cookie
526
+ * session has one); postForm adds the request's own CSRF field. */
525
527
  export function learningHtml(view, csrf, canManage, source) {
526
- const e = (x) => String(x).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
527
- const fields = (values) => Object.entries(values).map(([k, v]) => `<input type="hidden" name="${e(k)}" value="${e(v)}">`).join('');
528
- const base = { csrf, repo: view.repo, identity: view.identity, revision: view.revision };
529
- const act = (action, label, lesson) => canManage && csrf ? `<form method="post" action="/settings/learning/change">${fields({ ...base, action, ...(lesson ? { lesson: lesson.id, version: lesson.version, sha: lesson.sha } : {}) })}<button type="submit">${label}</button></form>` : '';
530
- const evidence = (list, run) => list.map(one => `<li><a href="/r/${one.runId ?? run}/evidence/${one.artifactId}">Source #${one.artifactId}</a><blockquote>${e(one.excerpt)}</blockquote><code>${e(one.sha256)}</code></li>`).join('');
528
+ const base = { repo: view.repo, identity: view.identity, revision: view.revision };
529
+ const act = (action, label, lesson) => canManage && csrf ? postForm('/settings/learning/change', html `<button type="submit">${label}</button>`, { hidden: { ...base, action, ...(lesson ? { lesson: lesson.id, version: lesson.version, sha: lesson.sha } : {}) } }) : '';
530
+ const evidence = (list, run) => list.map(one => html `<li><a href="/r/${String(one.runId ?? run)}/evidence/${one.artifactId}">Source #${one.artifactId}</a><blockquote>${one.excerpt}</blockquote><code>${one.sha256}</code></li>`);
531
531
  const lessons = view.lessons.filter(l => source === undefined || l.source === source);
532
- const cards = lessons.map(l => `<article class="card" data-lesson="${l.id}"><p><strong>${e(l.payload.observation)}</strong></p><p>${e(l.payload.action)}</p><p class="meta">${l.payload.kind === 'system' ? 'System suggestion · No change applied' : e(l.status === 'adopted' ? 'Adopted advice' : l.status === 'disabled' ? 'Disabled' : 'Proposed lesson')}</p><details><summary>Source and use</summary><p><a href="/r/${l.source}">Result #${l.source}</a> · <a href="/r/${l.reviewer}">Review #${l.reviewer}</a></p><p>${e(l.payload.paths.join(', '))} · ${e(l.payload.phases.join(', '))} · ${e({ darwin: "macOS", win32: "Windows", linux: "Linux" }[l.payload.platform] ?? l.payload.platform)}</p><ul>${evidence(l.payload.evidence, l.source)}</ul>${l.payload.kind !== 'project' ? '' : `<p class="meta">${l.status === 'proposed' ? 'Save permits advisory reuse when enabled and applicable. It does not prove a remedy works.' : 'Disabling affects future runs. Active snapshots, code and approvals stay unchanged.'}</p>${l.status === 'proposed' ? act('adopt', 'Save lesson', l) : ''}`}</details>${l.payload.kind === 'project' && l.status === 'adopted' ? act('disable', 'Disable lesson', l) : ''}</article>`).join('');
532
+ const cards = lessons.map(l => html `<article class="card" data-lesson="${l.id}"><p><strong>${l.payload.observation}</strong></p><p>${l.payload.action}</p><p class="meta">${l.payload.kind === 'system' ? 'System suggestion · No change applied' : l.status === 'adopted' ? 'Adopted advice' : l.status === 'disabled' ? 'Disabled' : 'Proposed lesson'}</p><details><summary>Source and use</summary><p><a href="/r/${l.source}">Result #${l.source}</a> · <a href="/r/${l.reviewer}">Review #${l.reviewer}</a></p><p>${l.payload.paths.join(', ')} · ${l.payload.phases.join(', ')} · ${{ darwin: "macOS", win32: "Windows", linux: "Linux" }[l.payload.platform] ?? l.payload.platform}</p><ul>${evidence(l.payload.evidence, l.source)}</ul>${l.payload.kind !== 'project' ? '' : html `<p class="meta">${l.status === 'proposed' ? 'Save permits advisory reuse when enabled and applicable. It does not prove a remedy works.' : 'Disabling affects future runs. Active snapshots, code and approvals stay unchanged.'}</p>${l.status === 'proposed' ? act('adopt', 'Save lesson', l) : ''}`}</details>${l.payload.kind === 'project' && l.status === 'adopted' ? act('disable', 'Disable lesson', l) : ''}</article>`);
533
533
  if (source !== undefined)
534
- return lessons.length ? `<details class="learning result-learning"><summary>Learned from this task</summary>${cards}<a href="/settings/learning?repo=${encodeURIComponent(view.repo)}">Learning settings</a></details>` : '';
535
- const history = view.events.map(ev => `<article class="card" data-learning-event="${e(ev.action)}"><p><strong>${e({ assessment: { propose: 'Learning suggested', none: 'No lesson needed', unassessed: 'Learning not assessed', invalid: 'Learning assessment invalid' }[ev.after] ?? 'Learning not assessed', proposal: 'Suggestion recorded', adopt: 'Lesson adopted', disable: 'Lesson disabled', reset: 'Learning reset', enable: 'Reuse enabled', pause: 'Reuse paused', reuse: 'Run context saved', failure: 'Learning issue', capture: 'Capture finished' }[ev.action] ?? ev.action)}</strong>${ev.action === "reuse" ? ` · ${e(ev.after)}` : ""}</p><p class="meta">${whenUtc(ev.at)} · ${e(ev.actor)}${ev.run === null ? '' : ` · <a href="/r/${ev.run}">Run #${ev.run}</a>${ev.outcome === null ? "" : ` · ${e(ev.outcome)}`}`}</p><details><summary>Details</summary><p>${e(ev.before)} → ${e(ev.after)}</p><p>${e(ev.reason)}</p>${ev.lesson === null ? '' : `<p>Lesson #${ev.lesson}</p>`}<ul>${evidence(ev.evidence, ev.run)}</ul>${ev.snapshot === null ? "" : `<details><summary>Exact context</summary><pre>${e(ev.snapshot)}</pre></details>`}</details></article>`).join('');
536
- return `<section class="learning">${view.damaged ? '<p class="problem" role="alert">Some lessons no longer verify and are excluded from reuse.</p>' : ''}<p>${view.enabled ? 'Use adopted lessons: on' : 'Use adopted lessons: off'}</p>${act(view.enabled ? 'pause' : 'enable', view.enabled ? 'Pause reuse' : 'Enable reuse')}${cards || '<p>No lessons yet. Reviews may suggest useful advice here.</p>'}<details><summary>Reset learning</summary><p>Disable all adopted lessons and future reuse. Keep history, code, approvals and active run snapshots.</p>${act('reset', 'Reset learning')}</details><h2>Changes</h2>${history || '<p>No learning changes yet.</p>'}${view.next === null ? '' : `<a class="button-link" href="/settings/learning?repo=${encodeURIComponent(view.repo)}&before=${view.next}">Older changes</a>`}</section>`;
534
+ return lessons.length ? html `<details class="learning result-learning"><summary>Learned from this task</summary>${cards}<a href="/settings/learning?repo=${encodeURIComponent(view.repo)}">Learning settings</a></details>` : html ``;
535
+ const history = view.events.map(ev => html `<article class="card" data-learning-event="${ev.action}"><p><strong>${{ assessment: { propose: 'Learning suggested', none: 'No lesson needed', unassessed: 'Learning not assessed', invalid: 'Learning assessment invalid' }[ev.after] ?? 'Learning not assessed', proposal: 'Suggestion recorded', adopt: 'Lesson adopted', disable: 'Lesson disabled', reset: 'Learning reset', enable: 'Reuse enabled', pause: 'Reuse paused', reuse: 'Run context saved', failure: 'Learning issue', capture: 'Capture finished' }[ev.action] ?? ev.action}</strong>${ev.action === "reuse" ? ` · ${ev.after}` : ""}</p><p class="meta">${whenUtc(ev.at)} · ${ev.actor}${ev.run === null ? '' : html ` · <a href="/r/${ev.run}">Run #${ev.run}</a>${ev.outcome === null ? "" : ` · ${ev.outcome}`}`}</p><details><summary>Details</summary><p>${ev.before} → ${ev.after}</p><p>${ev.reason}</p>${ev.lesson === null ? '' : html `<p>Lesson #${ev.lesson}</p>`}<ul>${evidence(ev.evidence, ev.run)}</ul>${ev.snapshot === null ? "" : html `<details><summary>Exact context</summary><pre>${ev.snapshot}</pre></details>`}</details></article>`);
536
+ return html `<section class="learning">${view.damaged ? html `<p class="problem" role="alert">Some lessons no longer verify and are excluded from reuse.</p>` : ''}<p>${view.enabled ? 'Use adopted lessons: on' : 'Use adopted lessons: off'}</p>${act(view.enabled ? 'pause' : 'enable', view.enabled ? 'Pause reuse' : 'Enable reuse')}${cards.length ? cards : html `<p>No lessons yet. Reviews may suggest useful advice here.</p>`}<details><summary>Reset learning</summary><p>Disable all adopted lessons and future reuse. Keep history, code, approvals and active run snapshots.</p>${act('reset', 'Reset learning')}</details><h2>Changes</h2>${history.length ? history : html `<p>No learning changes yet.</p>`}${view.next === null ? '' : html `<a class="button-link" href="/settings/learning?repo=${encodeURIComponent(view.repo)}&before=${view.next}">Older changes</a>`}</section>`;
537
537
  }
538
538
  /** The first signed requirement a finished attempt missed (failed or unanswered; a person's own check is not a miss),
539
539
  * with the evidence line behind it in plain words. Null when every requirement held. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.54",
3
+ "version": "0.9.55",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"