toolroll 0.9.53 → 0.9.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +5 -4
  3. package/dist/agent-onboard.js +4 -1
  4. package/dist/approval-rules-ui.d.ts +3 -2
  5. package/dist/approval-rules-ui.js +15 -20
  6. package/dist/assignment-ui.d.ts +3 -2
  7. package/dist/assignment-ui.js +4 -11
  8. package/dist/backup-ui.d.ts +2 -1
  9. package/dist/backup-ui.js +16 -27
  10. package/dist/brand-mark.d.ts +3 -2
  11. package/dist/brand-mark.js +4 -4
  12. package/dist/browser/workspace.css +1 -1
  13. package/dist/browser/workspace.js +25 -25
  14. package/dist/browser-shell.d.ts +2 -1
  15. package/dist/browser-shell.js +9 -9
  16. package/dist/browser-workspace.d.ts +2 -3
  17. package/dist/browser-workspace.js +0 -3
  18. package/dist/chat-ask.js +8 -8
  19. package/dist/chat-core.d.ts +171 -0
  20. package/dist/chat-core.js +288 -0
  21. package/dist/chat-decide.d.ts +10 -8
  22. package/dist/chat-decide.js +26 -21
  23. package/dist/chat-delivery-state.d.ts +51 -75
  24. package/dist/chat-delivery-state.js +140 -170
  25. package/dist/chat-delivery.d.ts +21 -2
  26. package/dist/chat-delivery.js +313 -194
  27. package/dist/chat-flow.d.ts +29 -1
  28. package/dist/chat-flow.js +24 -50
  29. package/dist/chat-messages.d.ts +319 -0
  30. package/dist/chat-messages.js +841 -0
  31. package/dist/chat-migration.d.ts +30 -0
  32. package/dist/chat-migration.js +802 -0
  33. package/dist/chat-polish.d.ts +5 -4
  34. package/dist/chat-polish.js +9 -10
  35. package/dist/chat-quiet.d.ts +3 -0
  36. package/dist/chat-quiet.js +5 -0
  37. package/dist/check-levels-ui.d.ts +2 -1
  38. package/dist/check-levels-ui.js +26 -53
  39. package/dist/cli.d.ts +3 -1
  40. package/dist/cli.js +9 -3
  41. package/dist/coding-shipping-ui.d.ts +2 -1
  42. package/dist/coding-shipping-ui.js +6 -6
  43. package/dist/coding-ui.d.ts +3 -1
  44. package/dist/coding-ui.js +30 -29
  45. package/dist/contracts/chat-content.d.ts +71 -2
  46. package/dist/contracts/chat-content.js +22 -3
  47. package/dist/contracts/chat-tables.d.ts +11 -4
  48. package/dist/contracts/chat-tables.js +165 -87
  49. package/dist/contracts/cli.d.ts +1 -3
  50. package/dist/contracts/console-api.d.ts +1 -1
  51. package/dist/contracts/console-api.js +6 -5
  52. package/dist/contracts/discord-callback.js +2 -1
  53. package/dist/contracts/health.d.ts +1 -3
  54. package/dist/contracts/slack-callback.js +3 -2
  55. package/dist/contracts/store-json.d.ts +1 -5
  56. package/dist/contracts/store-json.js +1 -4
  57. package/dist/contracts/teams-callback.js +2 -1
  58. package/dist/control-ui.d.ts +5 -4
  59. package/dist/control-ui.js +9 -34
  60. package/dist/credentials-ui.d.ts +5 -4
  61. package/dist/credentials-ui.js +23 -24
  62. package/dist/demo-chat.d.ts +3 -3
  63. package/dist/demo-chat.js +29 -76
  64. package/dist/discord-chat.js +19 -38
  65. package/dist/discord-settings.d.ts +3 -2
  66. package/dist/discord-settings.js +21 -55
  67. package/dist/discord.js +4 -14
  68. package/dist/envelope.js +10 -0
  69. package/dist/evidence-pack.d.ts +2 -1
  70. package/dist/evidence-pack.js +45 -49
  71. package/dist/export-ui.d.ts +7 -2
  72. package/dist/export-ui.js +4 -10
  73. package/dist/export.js +4 -4
  74. package/dist/flow-gallery-ui.d.ts +4 -3
  75. package/dist/flow-gallery-ui.js +28 -43
  76. package/dist/flow-starters-ui.d.ts +2 -1
  77. package/dist/flow-starters-ui.js +12 -15
  78. package/dist/flow-triggers.d.ts +1 -0
  79. package/dist/flow-triggers.js +16 -14
  80. package/dist/flows-ui.d.ts +4 -3
  81. package/dist/flows-ui.js +19 -30
  82. package/dist/flows.js +2 -1
  83. package/dist/guides.js +108 -3
  84. package/dist/html.d.ts +76 -0
  85. package/dist/html.js +173 -0
  86. package/dist/integrations-ui.d.ts +2 -1
  87. package/dist/integrations-ui.js +19 -23
  88. package/dist/kits-ui.d.ts +5 -4
  89. package/dist/kits-ui.js +18 -20
  90. package/dist/knowledge-ui.d.ts +6 -5
  91. package/dist/knowledge-ui.js +26 -25
  92. package/dist/lead-context.d.ts +11 -1
  93. package/dist/lead-context.js +6 -8
  94. package/dist/ledger-view.d.ts +2 -1
  95. package/dist/ledger-view.js +27 -29
  96. package/dist/limits-ui.d.ts +2 -1
  97. package/dist/limits-ui.js +7 -7
  98. package/dist/live-bus.d.ts +50 -8
  99. package/dist/live-bus.js +133 -50
  100. package/dist/mate-tools.js +3 -2
  101. package/dist/mcp-oauth.js +35 -36
  102. package/dist/models-ui.d.ts +2 -1
  103. package/dist/models-ui.js +25 -35
  104. package/dist/modes.d.ts +13 -1
  105. package/dist/modes.js +17 -1
  106. package/dist/monitoring-ui.d.ts +4 -3
  107. package/dist/monitoring-ui.js +13 -26
  108. package/dist/notification-rows.d.ts +30 -0
  109. package/dist/notification-rows.js +102 -0
  110. package/dist/openrouter-models.d.ts +2 -1
  111. package/dist/openrouter-models.js +5 -9
  112. package/dist/operate.js +16 -9
  113. package/dist/people-audit-ui.d.ts +2 -1
  114. package/dist/people-audit-ui.js +13 -13
  115. package/dist/plane-review.js +5 -11
  116. package/dist/policy-ui.d.ts +3 -2
  117. package/dist/policy-ui.js +17 -23
  118. package/dist/project-delete-ui.d.ts +4 -3
  119. package/dist/project-delete-ui.js +15 -27
  120. package/dist/project-delete.js +21 -26
  121. package/dist/pull-request-ui.d.ts +2 -1
  122. package/dist/pull-request-ui.js +17 -32
  123. package/dist/recipe-ui.d.ts +8 -7
  124. package/dist/recipe-ui.js +39 -32
  125. package/dist/release-adapters.d.ts +60 -0
  126. package/dist/release-adapters.js +435 -0
  127. package/dist/release-cli.d.ts +24 -0
  128. package/dist/release-cli.js +181 -0
  129. package/dist/release-gate.d.ts +80 -0
  130. package/dist/release-gate.js +150 -0
  131. package/dist/release-links.d.ts +29 -0
  132. package/dist/release-links.js +102 -0
  133. package/dist/release.d.ts +331 -0
  134. package/dist/release.js +550 -0
  135. package/dist/reply-shape.d.ts +2 -1
  136. package/dist/reply-shape.js +5 -5
  137. package/dist/repository-context-ui.d.ts +2 -1
  138. package/dist/repository-context-ui.js +2 -7
  139. package/dist/request-budget-ui.d.ts +2 -1
  140. package/dist/request-budget-ui.js +16 -16
  141. package/dist/result-review.d.ts +2 -3
  142. package/dist/result-review.js +3 -9
  143. package/dist/result-shots.js +2 -2
  144. package/dist/retention-ui.d.ts +7 -1
  145. package/dist/retention-ui.js +7 -11
  146. package/dist/retention.js +1 -1
  147. package/dist/serve.d.ts +11 -2
  148. package/dist/serve.js +69 -58
  149. package/dist/server/chat.d.ts +3 -6
  150. package/dist/server/chat.js +969 -1006
  151. package/dist/server/chrome.d.ts +291 -0
  152. package/dist/server/chrome.js +747 -0
  153. package/dist/server/console.css +2819 -0
  154. package/dist/server/flows.d.ts +2 -6
  155. package/dist/server/flows.js +840 -766
  156. package/dist/server/guards.d.ts +1 -1
  157. package/dist/server/guards.js +5 -2
  158. package/dist/server/handler-context.d.ts +1 -1
  159. package/dist/server/handler-context.js +1 -1
  160. package/dist/server/handler-registry.d.ts +8 -6
  161. package/dist/server/handler-registry.js +14 -2
  162. package/dist/server/http.d.ts +274 -0
  163. package/dist/server/http.js +239 -0
  164. package/dist/server/live.d.ts +34 -0
  165. package/dist/server/live.js +309 -0
  166. package/dist/server/pages.d.ts +2 -6
  167. package/dist/server/pages.js +808 -789
  168. package/dist/server/people-tokens.d.ts +1 -6
  169. package/dist/server/people-tokens.js +580 -572
  170. package/dist/server/remote.d.ts +4 -7
  171. package/dist/server/remote.js +37 -70
  172. package/dist/server/render-chat.d.ts +402 -0
  173. package/dist/server/render-chat.js +927 -0
  174. package/dist/server/render-pages.d.ts +535 -0
  175. package/dist/server/render-pages.js +1277 -0
  176. package/dist/server/render-people.d.ts +32 -0
  177. package/dist/server/render-people.js +121 -0
  178. package/dist/server/render-results.d.ts +744 -0
  179. package/dist/server/render-results.js +2237 -0
  180. package/dist/server/render-settings.d.ts +147 -0
  181. package/dist/server/render-settings.js +577 -0
  182. package/dist/server/render-tasks.d.ts +632 -0
  183. package/dist/server/render-tasks.js +2345 -0
  184. package/dist/server/request-context.d.ts +46 -0
  185. package/dist/server/request-context.js +17 -0
  186. package/dist/server/route-table.d.ts +25 -3
  187. package/dist/server/route-table.js +163 -75
  188. package/dist/server/runtime.d.ts +15 -7
  189. package/dist/server/runtime.js +7 -1
  190. package/dist/server/session.d.ts +138 -0
  191. package/dist/server/session.js +94 -0
  192. package/dist/server/settings.d.ts +1 -5
  193. package/dist/server/settings.js +2182 -2117
  194. package/dist/server/task-views.d.ts +11 -4
  195. package/dist/server/task-views.js +26 -22
  196. package/dist/server/tasks.d.ts +9 -9
  197. package/dist/server/tasks.js +1486 -1418
  198. package/dist/server-telemetry.d.ts +4 -3
  199. package/dist/server-telemetry.js +4 -8
  200. package/dist/skills-ui.d.ts +4 -3
  201. package/dist/skills-ui.js +14 -17
  202. package/dist/skills.d.ts +1 -1
  203. package/dist/skills.js +2 -1
  204. package/dist/slack-api.d.ts +5 -1
  205. package/dist/slack-api.js +2 -2
  206. package/dist/slack-chat.d.ts +9 -4
  207. package/dist/slack-chat.js +29 -52
  208. package/dist/slack-settings.d.ts +3 -2
  209. package/dist/slack-settings.js +23 -57
  210. package/dist/slack.js +6 -16
  211. package/dist/spend-ui.d.ts +2 -1
  212. package/dist/spend-ui.js +9 -32
  213. package/dist/sso-ui.d.ts +3 -2
  214. package/dist/sso-ui.js +26 -27
  215. package/dist/storage-ui.d.ts +2 -1
  216. package/dist/storage-ui.js +14 -33
  217. package/dist/store.d.ts +97 -364
  218. package/dist/store.js +207 -1531
  219. package/dist/surface.js +14 -3
  220. package/dist/task-composer.d.ts +2 -1
  221. package/dist/task-composer.js +7 -7
  222. package/dist/task-status.d.ts +4 -3
  223. package/dist/task-status.js +15 -16
  224. package/dist/team-http.d.ts +5 -9
  225. package/dist/team-http.js +5 -63
  226. package/dist/team-ui.d.ts +2 -1
  227. package/dist/team-ui.js +2 -5
  228. package/dist/teammate-question.js +12 -12
  229. package/dist/teammates-ui.d.ts +5 -4
  230. package/dist/teammates-ui.js +61 -90
  231. package/dist/teams-chat.js +12 -9
  232. package/dist/teams-settings.d.ts +3 -2
  233. package/dist/teams-settings.js +22 -33
  234. package/dist/teams.js +4 -5
  235. package/dist/telegram-flow.js +5 -5
  236. package/dist/telegram-settings.d.ts +3 -2
  237. package/dist/telegram-settings.js +14 -25
  238. package/dist/telegram.js +4 -4
  239. package/dist/toolroll-update-ui.d.ts +3 -2
  240. package/dist/toolroll-update-ui.js +35 -39
  241. package/dist/toolroll-update.d.ts +10 -0
  242. package/dist/toolroll-update.js +50 -3
  243. package/dist/tools-ui.d.ts +5 -2
  244. package/dist/tools-ui.js +31 -31
  245. package/dist/when-html.d.ts +11 -3
  246. package/dist/when-html.js +5 -6
  247. package/dist/workspace-revision.js +3 -1
  248. package/dist/workspace-ui.d.ts +4 -2
  249. package/dist/workspace-ui.js +10 -10
  250. package/package.json +1 -1
  251. package/dist/server/shared.d.ts +0 -3151
  252. package/dist/server/shared.js +0 -12001
  253. package/dist/slack-state.d.ts +0 -13
  254. package/dist/slack-state.js +0 -10
@@ -0,0 +1,577 @@
1
+ /** Settings pages: the settings home, lead, capabilities and their cards. */
2
+ import { ACCENT_PRESETS, DEFAULT_ACCENT } from "../accent-colors.js";
3
+ import {} from "../brand-icons.js";
4
+ import { brandIconHtml } from "../brand-mark.js";
5
+ import {} from "../browser-workspace.js";
6
+ import { connectionWords } from "../control-ui.js";
7
+ import { isSubscriptionChatProvider, PRICED_MODELS } from "../converse.js";
8
+ import { digestTimes } from "../digest-times.js";
9
+ import { describeCapability } from "../gaps.js";
10
+ import { ABOUT_YOU_LINE_MAX, ABOUT_YOU_MAX_LINES } from "../lead-about.js";
11
+ import { LEAD_NAME_MAX, LEAD_PERSONA_MAX } from "../lead-identity.js";
12
+ import { projectName } from "../project.js";
13
+ import {} from "../provider-connection.js";
14
+ import {} from "../provider.js";
15
+ import { qualityModeTitle } from "../quality.js";
16
+ import { latestVersionNow } from "../releases.js";
17
+ import { RESULT_SHOT_CHOICES } from "../result-shots.js";
18
+ import { isAlive as runnerAlive } from "../runner.js";
19
+ import {} from "../scope.js";
20
+ import { ASSISTANTS } from "../setup-guide.js";
21
+ import {} from "../store.js";
22
+ import { redactToken, TOKEN_ENV } from "../telegram.js";
23
+ import { requestContext } from "./request-context.js";
24
+ import { chatMoney, screen, strokeIcon, when, whenTime } from "./chrome.js";
25
+ import { taskHref } from "./http.js";
26
+ import { html, joinHtml, postForm } from "../html.js";
27
+ import { permissionModeChoices, qualityModeChoices } from "./render-tasks.js";
28
+ /** Settings → Lead: one line for what runs the lead and one action; the full form, turning it off and stored keys under Advanced. */
29
+ export function leadSettingsHtml(data) {
30
+ const { config, facts } = data;
31
+ const returnTo = "/settings/lead";
32
+ const summary = config === null
33
+ ? data.signedIn !== null
34
+ ? html `<p>The lead is off.</p>${postForm("/settings/lead/on", html `<button type="submit">Use your ${data.signedIn} sign-in</button>`, { returnTo })}`
35
+ : html `<p>The lead is off. Sign in an agent on this computer to turn it on:</p><p><code>${data.command}</code></p>`
36
+ : data.words !== null
37
+ ? html `<p>The lead uses ${data.words}.</p><p class="meta">${config.model === "default" ? "Its default model" : `Model ${config.model}`} · up to ${config.dailyTurns} turns a day · no dollar spend. Every action it proposes waits for you to confirm it.</p>`
38
+ : html `<p>The lead uses the ${config.provider === "anthropic-api" ? "Anthropic" : "OpenRouter"} API with ${config.model}.</p><p class="meta">Up to ${chatMoney(config.weeklyCeilingMicrousd)} a week · up to ${config.dailyTurns} turns a day.</p>`;
39
+ const forget = facts.keyFacts.filter(one => one.state === "stored").map(one => postForm("/chat/config", html `<input type="password" name="token" placeholder="your password" autocomplete="current-password" aria-label="Your password"><button type="submit" class="secondary">Forget the stored ${one.provider} key</button>`, { attrs: { class: "inline" }, returnTo, hidden: { "forget-key": one.provider } }));
40
+ return joinHtml([
41
+ html `<p><a href="/settings">Settings</a></p><h1>Lead</h1>`,
42
+ data.said === null ? "" : html `<p class="problem" role="status">${data.said}</p>`,
43
+ html `<section class="card lead-settings" data-lead-settings>${summary}</section>`,
44
+ data.identity === undefined ? "" : postForm("/settings/lead/identity", html `\
45
+ <label>Name your lead<input name="name" value="${data.identity.name}" maxlength="${LEAD_NAME_MAX}" autocomplete="off" required></label>\
46
+ <label>Persona<textarea name="persona" rows="4" maxlength="${LEAD_PERSONA_MAX}">${data.identity.persona}</textarea></label>\
47
+ <button type="submit">Save</button>${data.saved === true ? html ` <span class="meta" role="status">Saved.</span>` : ""}`, { attrs: { class: "card lead-identity", "data-lead-identity": true } }),
48
+ // What the lead knows about this person: lines they confirmed in chat or wrote here. Every project, every chat.
49
+ data.about === undefined ? "" : postForm("/settings/lead/about", html `\
50
+ <label>What your lead knows about you<textarea name="about" rows="${Math.min(10, Math.max(4, data.about.length + 1))}" maxlength="${ABOUT_YOU_MAX_LINES * (ABOUT_YOU_LINE_MAX + 1)}" placeholder="Keep copy terse.&#10;I test changes myself.">${data.about.join("\n")}</textarea></label>\
51
+ <p class="meta">One per line, up to ${ABOUT_YOU_MAX_LINES}.</p>\
52
+ <button type="submit">Save</button>${data.aboutSaved === true ? html ` <span class="meta" role="status">Saved.</span>` : ""}`, { attrs: { class: "card lead-about", "data-lead-about": true } }),
53
+ // What the lead promised to follow up on; it reports each once, here in chat, and drops it after 7 days.
54
+ (data.promises ?? []).length === 0 ? "" : html `<section class="card lead-promises" data-lead-promises><h2>Promises</h2><ul class="lead-promise-list">${data.promises.map(one => html `<li data-promise="${one.id}"><p>${one.what}</p><p class="meta">${one.when.charAt(0).toUpperCase() + one.when.slice(1)} · <span class="nowrap">until ${new Date(one.until).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}</span></p>\
55
+ ${postForm("/settings/lead/promise/cancel", html `<button type="submit" class="secondary">Cancel</button>`, { attrs: { class: "inline" }, returnTo, hidden: { promise: one.id } })}</li>`)}</ul></section>`,
56
+ html `<details class="lead-advanced" data-lead-advanced><summary>Advanced</summary>`,
57
+ leadConfigForm(config, facts),
58
+ config === null ? "" : postForm("/chat/config", html `<input type="password" name="token" placeholder="your password" autocomplete="current-password" aria-label="Your password"><button type="submit" class="secondary">Turn the lead off</button>`, { attrs: { class: "inline" }, returnTo, hidden: { off: "1" } }),
59
+ forget,
60
+ html `</details>`,
61
+ ], "\n");
62
+ }
63
+ /** The lead's full settings (Settings → Lead → Advanced): provider, model, limits and a direct API key. */
64
+ export function leadConfigForm(current, data) {
65
+ const anthropicModels = PRICED_MODELS.filter(one => !one.includes("/"));
66
+ const openrouterModels = data.openrouterModels ?? PRICED_MODELS.filter(one => one.includes("/"));
67
+ const currentSubscription = current !== null && isSubscriptionChatProvider(current.provider);
68
+ const labels = new Map((data.liveModels ?? []).map(one => [one.value, one.label]));
69
+ const models = [...new Set(["default", ...labels.keys(), ...anthropicModels, ...openrouterModels, ...(current === null ? [] : [current.model])])];
70
+ return joinHtml([
71
+ postForm("/chat/config", joinHtml([
72
+ "",
73
+ html `<label>Provider<select name="provider">`,
74
+ html `<option value="codex-subscription"${current?.provider === "codex-subscription" ? " selected" : ""}>Codex membership (logged-in CLI)</option>`,
75
+ html `<option value="claude-subscription"${current?.provider === "claude-subscription" ? " selected" : ""}>Anthropic membership (logged-in CLI)</option>`,
76
+ html `<option value="anthropic-api"${current?.provider === "anthropic-api" ? " selected" : ""}>anthropic-api (direct API)</option>`,
77
+ html `<option value="openrouter-api"${current?.provider === "openrouter-api" ? " selected" : ""}>openrouter-api (direct API)</option>`,
78
+ html `</select></label>`,
79
+ html `<label>Model <span class="meta">(use default for your membership's current model; direct API models need a pinned price)</span>\
80
+ <input name="model" list="chat-models" value="${current?.model ?? "default"}"><datalist id="chat-models">\
81
+ ${models.map(model => html `<option value="${model}">${labels.get(model) ?? ""}</option>`)}</datalist></label>`,
82
+ data.openrouterModels === null
83
+ ? html `<p class="meta">With OPENROUTER_API_KEY in the serve environment, this list becomes OpenRouter's full live catalog — each model priced by the party that bills it</p>`
84
+ : html `<p class="meta">${data.openrouterModels.length} models live from OpenRouter's catalog; saving pins today's price — re-save to re-pin</p>`,
85
+ currentSubscription
86
+ ? html `<p class="meta"><strong>No dollar maximum.</strong> Membership chat uses the plan attached to the logged-in CLI; the conversation stays live until you end it, and the daily turn limit still applies.</p>`
87
+ : html `<label>Weekly ceiling <span class="meta">(direct API only; leave blank when choosing a membership)</span>\
88
+ <input type="text" name="weekly-usd" inputmode="decimal" style="width:8rem" value="${current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2)}"></label>`,
89
+ html `<label>Daily turns <span class="meta">(default 50)</span>\
90
+ <input type="text" name="daily-turns" inputmode="numeric" style="width:8rem" value="${current === null ? "" : String(current.dailyTurns)}"></label>`,
91
+ currentSubscription
92
+ ? html `<p class="meta">Authenticate on this machine first with ${current?.provider === "codex-subscription" ? html `<span class="mono">codex login</span>` : html `the <span class="mono">claude</span> CLI`}. Toolroll reuses that cached login and never stores it.</p>`
93
+ : html `<label>API key <span class="meta">(${data.keyFacts
94
+ .map(one => one.state === "none"
95
+ ? `${one.provider}: none yet`
96
+ : one.state === "environment"
97
+ ? `${one.provider}: from the environment`
98
+ : `${one.provider}: stored ${one.tail ?? ""}`)
99
+ .join(" · ")})</span>\
100
+ <input type="password" name="key" placeholder="direct API only — leave empty to keep" autocomplete="off"></label>`,
101
+ html `<label>Your password <span class="meta">(typed again to change the provider)</span>\
102
+ <input type="password" name="token" autocomplete="current-password"></label>`,
103
+ html `<button type="submit">${current === null ? "turn chat on" : "save"}</button>`,
104
+ "",
105
+ ], "\n"), { attrs: { class: "card" }, returnTo: data.returnTo }),
106
+ currentSubscription
107
+ ? html `<p class="meta">The membership provider runs without repository tools in a temporary directory; Toolroll remains the only layer that can turn a proposed action into a confirmation card.</p>`
108
+ : html `<p class="meta">A pasted key is written once to a mode-0600 file beside the database — never INTO the database, never shown again beyond its last characters; an environment variable (\
109
+ <span class="mono">ANTHROPIC_API_KEY</span> / <span class="mono">OPENROUTER_API_KEY</span>) always wins when set</p>`,
110
+ ], "\n");
111
+ }
112
+ export function capsPage(chrome, caps, gaps, repo, now) {
113
+ if (caps === null) {
114
+ return screen("requirements", joinHtml([
115
+ html `<h1>Requirements</h1>`,
116
+ html `<p class="meta">Open a project to see its requirements — <a href="/projects">projects</a></p>`,
117
+ ], "\n"), { chrome });
118
+ }
119
+ const list = caps.length === 0
120
+ ? html `<p>Nothing recorded.</p>`
121
+ : joinHtml(caps
122
+ .map(capability => html `<p class="row">${capability.kind}:${capability.name} — \
123
+ ${describeCapability(capability, now ?? new Date())}</p>`), "\n");
124
+ const blocked = gaps.length === 0
125
+ ? html `<p class="meta">No gaps — everything recorded is verified</p>`
126
+ : joinHtml(gaps
127
+ .map(gap => html `<div class="card"><p>${gap.key} — ${gap.state}</p>\
128
+ <p class="meta">${gap.unblocks.length > 0
129
+ ? `fills → ${gap.unblocks.length} task(s) start: ${gap.unblocks.join(", ")}`
130
+ : gap.alsoBlocks.length > 0
131
+ ? `part of what holds: ${gap.alsoBlocks.join(", ")}`
132
+ : "nothing queued needs it yet"}</p>\
133
+ <p class="meta">Verify: ${gap.verify}</p>\
134
+ <p class="meta">${gap.instructions}</p></div>`), "\n");
135
+ return screen("requirements", joinHtml([
136
+ html `<h1>Requirements</h1>`,
137
+ html `<p class="hint">tools and credentials builds need — each is probed on the worker before any build spends money; values never leave your machine</p>`,
138
+ list,
139
+ html `<h2>Missing, ranked by what filling them frees</h2>`,
140
+ blocked,
141
+ html `<p class="meta">Read-only here: checks are shell commands you wrote, and a web button that runs shell would need its own security review</p>`,
142
+ ], "\n"), { chrome });
143
+ }
144
+ /** Whether the bot's saved delivery state reports a problem (no new check runs). */
145
+ export function telegramTrouble(store, bot) {
146
+ return bot !== null && (store.telegramPush(bot.botId)?.problem ?? null) !== null;
147
+ }
148
+ /** v98: how the bot's messages reach Toolroll, in words: pushed to the public address, or asked for. */
149
+ export function telegramDeliveryWords(store, bot) {
150
+ if (bot === null)
151
+ return null;
152
+ const state = store.telegramPush(bot.botId);
153
+ if (state?.url)
154
+ return `Telegram pushes new messages to ${state.url}${state.problem === null ? "." : `, but ${state.problem.charAt(0).toLowerCase()}${state.problem.slice(1)}.`}`;
155
+ return `Toolroll asks Telegram for new messages every few seconds${state?.problem ? ` (${state.problem})` : ""}.`;
156
+ }
157
+ /** The projects this person can see, by name, and whether they muted each one's pings. */
158
+ export function notificationProjects(store, account) {
159
+ const muted = new Set(store.mutedProjects(account));
160
+ return store.listProjects().filter(one => store.accountCanAccess(account, one.path))
161
+ .map(one => ({ repo: one.path, name: one.name, muted: muted.has(one.path) }))
162
+ .sort((a, b) => a.name.localeCompare(b.name));
163
+ }
164
+ export function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null, push = null, providerKeys = null, digest = null, permissionDefault = null, qualityDefault = null, email = null, telegramDelivery = null, workers = null, updates = null, firstResult = null, chatNotices = null, telegramFailing = false, phone = null) {
165
+ const permissionCard = permissionDefault === null
166
+ ? ""
167
+ : joinHtml([
168
+ html `<h2>Unattended permissions</h2>`,
169
+ permissionDefault.canManage && csrf !== ""
170
+ ? postForm("/settings/permission-default", html `${permissionModeChoices("permission-mode", permissionDefault.mode)}<button type="submit">Save default</button>`, { attrs: { class: "card permission-policy", "data-autosave": true } })
171
+ : html `<div class="card"><p><strong>${permissionDefault.mode === "bypassPermissions" ? "Full access" : "Auto"}</strong></p><p class="meta">An approver can change this default</p></div>`,
172
+ permissionDefault.updatedAt === null
173
+ ? ""
174
+ : html `<p class="meta settings-changed">Changed ${whenTime(permissionDefault.updatedAt)}${permissionDefault.updatedBy === null ? "" : ` by ${permissionDefault.updatedBy}`}</p>`,
175
+ ], "\n");
176
+ const qualityCard = qualityDefault === null
177
+ ? ""
178
+ : joinHtml([
179
+ html `<h2>Quality mode</h2>`,
180
+ qualityDefault.canManage && csrf !== ""
181
+ ? postForm("/settings/quality-default", html `${qualityModeChoices("quality-mode", qualityDefault.mode)}<button type="submit">Save default</button>`, { attrs: { class: "card permission-policy", "data-autosave": true } })
182
+ : html `<div class="card"><p><strong>${qualityModeTitle(qualityDefault.mode)}</strong></p><p class="meta">An approver can change this default</p></div>`,
183
+ qualityDefault.updatedAt === null
184
+ ? ""
185
+ : html `<p class="meta settings-changed">Changed ${whenTime(qualityDefault.updatedAt)}${qualityDefault.updatedBy === null ? "" : ` by ${qualityDefault.updatedBy}`}</p>`,
186
+ ], "\n");
187
+ // Quiet chat: this person's own choice. The installation's Telegram cadence only bundles Every step updates.
188
+ const chatCard = chatNotices === null || csrf === ""
189
+ ? ""
190
+ : joinHtml([
191
+ html `<h3>Chat messages</h3>`,
192
+ postForm("/settings/notifications", joinHtml([
193
+ "",
194
+ html `<label><input type="radio" name="mode" value="quiet"${chatNotices.mode === "quiet" ? " checked" : ""}> Only when I'm needed <span class="meta">· one message per task, updated as it moves</span></label>`,
195
+ html `<label><input type="radio" name="mode" value="all"${chatNotices.mode === "all" ? " checked" : ""}> Every step <span class="meta">· a new message for each update</span></label>`,
196
+ html `<label>Evening digest<select name="digest">\
197
+ ${digestTimes(chatNotices.digestAt).map(([value, label]) => html `<option value="${value}"${value === (chatNotices.digestAt ?? "off") ? " selected" : ""}>${label}</option>`)}\
198
+ </select></label>`,
199
+ html `<p class="meta">One message: what finished, what waits, what failed.</p>`,
200
+ html `<label>Screenshots with results<select name="screenshots">\
201
+ ${RESULT_SHOT_CHOICES.map(([value, label]) => html `<option value="${value}"${value === (chatNotices.screenshots ?? "off") ? " selected" : ""}>${label}</option>`)}\
202
+ </select></label>`,
203
+ html `<button type="submit">Save</button>`,
204
+ "",
205
+ ], "\n"), { attrs: { class: "card", "data-autosave": true } }),
206
+ ...((chatNotices.projects ?? []).length === 0 ? [] : [
207
+ html `<h3>Projects</h3>`,
208
+ html `<ul class="card">${chatNotices.projects.map(one => html `<li>${postForm("/settings/notifications/mute", html `${one.name} <span class="meta">· ${one.muted ? "muted" : "pings on"}</span> <button type="submit">${one.muted ? "Unmute" : "Mute"}</button>`, { hidden: { repo: one.repo, pings: one.muted ? null : "on" } })}</li>`)}</ul>`,
209
+ html `<p class="meta">Muted projects still show in Tasks and the evening digest.</p>`,
210
+ ]),
211
+ ], "\n");
212
+ const digestCard = digest === null || csrf === "" || chatNotices?.mode === "quiet"
213
+ ? ""
214
+ : joinHtml([
215
+ html `<h3>Telegram digest</h3>`,
216
+ html `<p class="meta">Bundle routine updates. Anything that needs you still arrives at once.</p>`,
217
+ postForm("/settings/telegram-digest", joinHtml([
218
+ "",
219
+ html `<label>Send a digest<select name="every">\
220
+ ${[
221
+ ["off", "Off: send each update"],
222
+ ["30", "every 30 minutes"],
223
+ ["60", "every hour"],
224
+ ["240", "every 4 hours"],
225
+ ["720", "every 12 hours"],
226
+ ["1440", "once a day"],
227
+ ]
228
+ .map(([value, label]) => {
229
+ const selected = value === "off" ? digest.everyMs === null : digest.everyMs === Number(value) * 60_000;
230
+ return html `<option value="${value}"${selected ? " selected" : ""}>${label}</option>`;
231
+ })}\
232
+ </select></label>`,
233
+ html `<p class="meta">${digest.everyMs === null
234
+ ? ""
235
+ : `${digest.held} routine fact(s) held · next digest ${digest.lastSentAt === null ? "at the next bridge pass" : `no earlier than ${new Date(new Date(digest.lastSentAt).getTime() + digest.everyMs).toISOString()}`}`}</p>`,
236
+ html `<button type="submit">Save digest</button>`,
237
+ "",
238
+ ], "\n"), { attrs: { class: "card", "data-autosave": true } }),
239
+ ], "\n");
240
+ const keysCard = providerKeys === null || csrf === ""
241
+ ? ""
242
+ : joinHtml([
243
+ html `<h2 id="providers">AI providers</h2><p><a href="/control">Set up this project</a></p>`,
244
+ html `<p class="meta">Keys stay on this computer and are never shown again.</p>`,
245
+ html `<details class="settings-more"><summary>How keys are used</summary><p class="meta">A key is used only when that provider’s sign-in is set to API key. With a subscription sign-in the key is kept out of the agent, so your membership never turns into API billing. Keys are private files beside the database, never stored in it.</p></details>`,
246
+ ...providerKeys.map(one => {
247
+ // One status row per provider; the controls wait behind Manage.
248
+ const name = ASSISTANTS[one.provider]?.name ?? one.provider;
249
+ const status = one.connection !== undefined && one.connection.state === "connected"
250
+ ? { tone: "ok", words: [connectionWords(one.connection), one.connection.plan].filter(Boolean).join(" · ") }
251
+ : one.mode === "subscription"
252
+ ? { tone: one.connection === undefined ? "neutral" : "warn", words: one.connection === undefined ? "Uses its own sign-in" : connectionWords(one.connection) }
253
+ : one.connection?.state === "key-works" ? { tone: "ok", words: "API key works" } : one.connection?.state === "key-refused" ? { tone: "warn", words: "API key refused" }
254
+ : one.set ? { tone: "ok", words: "API key saved" } : one.ambient ? { tone: "ok", words: "Key from this computer’s environment" } : { tone: "off", words: "Not set up" };
255
+ return joinHtml([
256
+ html `<div class="provider-row" data-provider="${one.provider}">`,
257
+ html `<p class="provider-head"><strong>${name}</strong> <span class="provider-status provider-status--${status.tone}"><i aria-hidden="true"></i>${status.words}</span></p>`,
258
+ html `<details class="provider-manage"><summary>Manage</summary>`,
259
+ postForm("/settings/provider-key", joinHtml([
260
+ "",
261
+ one.connection === undefined ? "" : html `<p class="provider-connection"><strong>${connectionWords(one.connection)}</strong> ${[one.connection.email, one.connection.plan, one.connection.method].filter(Boolean).join(" · ")} · <a href="/settings?check-connection=${encodeURIComponent(one.provider)}#providers">Check again</a></p>`,
262
+ html `<p class="meta">${one.mode === "subscription" ? "Uses its own sign-in, so no API-key spend" : "Uses the API key"} · <span class="mono">${one.envName}</span> · ${one.set ? `key stored${one.updatedAt === null ? "" : ` ${one.updatedAt.slice(0, 10)}`}` : one.ambient ? "key in this server's environment" : "no key stored"}</p>`,
263
+ one.subscriptionCapable
264
+ ? html `<label>Sign-in<select name="auth-mode">\
265
+ <option value="subscription"${one.mode === "subscription" ? " selected" : ""}>${name} subscription</option>\
266
+ <option value="api-key"${one.mode === "api-key" ? " selected" : ""}>API key</option>\
267
+ </select></label>`
268
+ : "",
269
+ html `<label>API key<input type="password" name="value" autocomplete="off" placeholder="${one.set ? "Paste to replace the stored key" : "Paste a key"}"></label>`,
270
+ html `<button type="submit">Save ${name}</button>`,
271
+ one.set
272
+ ? html ` <details class="confirm-remove"><summary>Remove the stored key</summary><p class="meta">Runs that use this API key stop until you add one again.</p><button type="submit" formaction="/settings/provider-key-clear" class="danger">Remove key</button></details>`
273
+ : "",
274
+ "",
275
+ ], "\n"), { attrs: { class: "card" }, hidden: { provider: one.provider } }),
276
+ html `</details></div>`,
277
+ ], "\n");
278
+ }),
279
+ ], "\n");
280
+ const pushCard = push === null || csrf === ""
281
+ ? ""
282
+ : joinHtml([
283
+ html `<h3>This device</h3>`,
284
+ push.available
285
+ ? joinHtml([
286
+ html `<p class="meta">A notification when something needs you. On iPhone, add this app to your Home Screen first.</p>`,
287
+ postForm("/push/subscribe", joinHtml([
288
+ "",
289
+ html `<input type="hidden" name="endpoint" value=""><input type="hidden" name="p256dh" value=""><input type="hidden" name="auth" value="">`,
290
+ html `<label>Your password <input type="password" name="token" autocomplete="current-password"></label>`,
291
+ html `<button type="submit" id="push-enable">Get alerts on this device</button>`,
292
+ html `<p class="meta" id="push-state"></p>`,
293
+ "",
294
+ ], "\n"), { attrs: { id: "push-form", class: "card" } }),
295
+ ], "\n")
296
+ : html `<p class="meta">Alerts need a secure (https) address for this app.</p>`,
297
+ ...push.devices
298
+ .filter(one => one.retiredAt === null || one.retiredReason === "gone")
299
+ .map(one => html `<p class="row">${one.uaWords} · since ${whenTime(one.createdAt)}\
300
+ ${one.retiredAt !== null ? html ` · <span class="meta">expired</span>` : one.consecutiveFailures >= 20 ? html ` · <span class="meta">failing</span>` : ""}\
301
+ ${one.retiredAt === null
302
+ ? html ` ${postForm("/push/remove", html `<button type="submit">Remove</button>`, { attrs: { class: "inline" }, hidden: { id: one.id } })}`
303
+ : ""}\
304
+ </p>`),
305
+ ], "\n");
306
+ // The enrollment behavior rides the ONE composed script (arc 4, finding
307
+ // 18) — it fills the subscription fields the form posts; it never reads
308
+ // the password field beside them (the named functional exception).
309
+ const pushScript = push === null || !push.available || csrf === ""
310
+ ? null
311
+ : `(function(){` +
312
+ `if(!("serviceWorker" in navigator)||!("PushManager" in window))return;` +
313
+ `var link=document.createElement("link");link.rel="manifest";link.href="/manifest.webmanifest";document.head.appendChild(link);` +
314
+ `navigator.serviceWorker.register("/sw.js",{scope:"/"}).catch(function(){});` +
315
+ `var form=document.getElementById("push-form");if(!form)return;` +
316
+ `form.addEventListener("submit",function(event){` +
317
+ `if(form.dataset.ready==="1")return;` +
318
+ `event.preventDefault();var state=document.getElementById("push-state");` +
319
+ `Notification.requestPermission().then(function(granted){` +
320
+ `if(granted!=="granted"){if(state)state.textContent="notifications are blocked for this site in the browser settings";return;}` +
321
+ `return fetch("/push/key").then(function(r){return r.json();}).then(function(d){` +
322
+ `return navigator.serviceWorker.ready.then(function(reg){` +
323
+ `return reg.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:Uint8Array.from(atob(d.key.replace(/-/g,"+").replace(/_/g,"/")),function(c){return c.charCodeAt(0);})});});` +
324
+ `}).then(function(sub){var raw=sub.toJSON();` +
325
+ `form.querySelector("[name=endpoint]").value=sub.endpoint;` +
326
+ `form.querySelector("[name=p256dh]").value=(raw.keys.p256dh||"").replace(/\\+/g,"-").replace(/\\//g,"_").replace(/=+$/,"");` +
327
+ `form.querySelector("[name=auth]").value=(raw.keys.auth||"").replace(/\\+/g,"-").replace(/\\//g,"_").replace(/=+$/,"");` +
328
+ `form.dataset.ready="1";form.submit();});` +
329
+ `}).catch(function(){if(state)state.textContent="could not subscribe — the browser said no";});});` +
330
+ `})();`;
331
+ const messagingCard = messaging === null || messaging.configured.length === 0
332
+ ? ""
333
+ : messaging.configured.length === 1 && !messaging.implicit
334
+ ? html `<h3>Alert service</h3><p class="provider-head"><strong>${messaging.configured[0]}</strong> <span class="provider-status provider-status--ok"><i aria-hidden="true"></i>Receiving alerts</span></p>`
335
+ : joinHtml([
336
+ html `<h3>Alert service</h3>`,
337
+ html `<p class="meta">Alerts go through one service, so you are never notified twice${messaging.implicit ? html ` · <strong>Several are connected and none was chosen. Pick one.</strong>` : ""}</p>`,
338
+ postForm("/settings/messaging", joinHtml([
339
+ "",
340
+ ...messaging.configured.map(channel => html `<label style="display:flex;gap:.5rem;align-items:center"><input type="radio" name="primary" value="${channel}"${channel === messaging.channel ? " checked" : ""}> ${channel}${channel === messaging.channel ? html ` <span class="meta">— receiving alerts now${messaging.implicit ? " (by default, not by choice)" : ""}</span>` : ""}${channel === "telegram" ? html ` <span class="meta">· can carry answer buttons and reply-notes</span>` : html ` <span class="meta">· messages with console links; acting stays here</span>`}</label>`),
341
+ html `<button type="submit">Use this service</button>`,
342
+ "",
343
+ ], "\n"), { attrs: { class: "card" } }),
344
+ html `<p class="meta">Telegram still accepts taps and replies when another service sends alerts.</p>`,
345
+ ], "\n");
346
+ const current = hasEnv
347
+ ? `set in the environment (${TOKEN_ENV}) — that takes precedence over anything saved here`
348
+ : existing === null
349
+ ? "not set"
350
+ : `saved: ${redactToken(existing.token)} (bot ${existing.botId})`;
351
+ const theme = requestContext.getStore()?.theme ?? "system";
352
+ const view = {
353
+ kind: "settings",
354
+ said: problem,
355
+ groups: settingsGroups(messaging, telegramFailing),
356
+ ...(messaging?.legacyWarning ? { legacyWebhookWarning: messaging.legacyWarning } : {}),
357
+ theme,
358
+ accent: requestContext.getStore()?.accent ?? DEFAULT_ACCENT,
359
+ accentPresets: ACCENT_PRESETS,
360
+ permission: permissionDefault === null ? null : { mode: permissionDefault.mode, canManage: permissionDefault.canManage && csrf !== "", changed: permissionDefault.updatedAt === null ? null : `Changed ${when(permissionDefault.updatedAt)}${permissionDefault.updatedBy === null ? "" : ` by ${permissionDefault.updatedBy}`}` },
361
+ quality: qualityDefault === null ? null : { mode: qualityDefault.mode, canManage: qualityDefault.canManage && csrf !== "", changed: qualityDefault.updatedAt === null ? null : `Changed ${when(qualityDefault.updatedAt)}${qualityDefault.updatedBy === null ? "" : ` by ${qualityDefault.updatedBy}`}` },
362
+ providers: providerKeys === null || csrf === "" ? null : providerKeys.map(one => {
363
+ const name = ASSISTANTS[one.provider]?.name ?? one.provider;
364
+ const status = one.connection !== undefined && one.connection.state === "connected"
365
+ ? { tone: "ok", words: [connectionWords(one.connection), one.connection.plan].filter(Boolean).join(" · ") }
366
+ : one.mode === "subscription"
367
+ ? { tone: one.connection === undefined ? "neutral" : "warn", words: one.connection === undefined ? "Uses its own sign-in" : connectionWords(one.connection) }
368
+ : one.connection?.state === "key-works" ? { tone: "ok", words: "API key works" } : one.connection?.state === "key-refused" ? { tone: "warn", words: "API key refused" }
369
+ : one.set ? { tone: "ok", words: "API key saved" } : one.ambient ? { tone: "ok", words: "Key from this computer’s environment" } : { tone: "off", words: "Not set up" };
370
+ return {
371
+ provider: one.provider, name, tone: status.tone, words: status.words,
372
+ connection: one.connection === undefined ? null : { words: connectionWords(one.connection), facts: [one.connection.email, one.connection.plan, one.connection.method].filter(Boolean).join(" · "), checkHref: `/settings?check-connection=${encodeURIComponent(one.provider)}#providers` },
373
+ usage: `${one.mode === "subscription" ? "Uses its own sign-in, so no API-key spend" : "Uses the API key"} · ${one.set ? `key stored${one.updatedAt === null ? "" : ` ${one.updatedAt.slice(0, 10)}`}` : one.ambient ? "key in this server's environment" : "no key stored"}`,
374
+ envName: one.envName, subscriptionCapable: one.subscriptionCapable, mode: one.mode, set: one.set,
375
+ };
376
+ }),
377
+ services: messaging === null || messaging.configured.length === 0 ? null : { configured: messaging.configured, channel: messaging.channel, implicit: messaging.implicit },
378
+ push: push === null || csrf === "" ? null : { available: push.available, devices: push.devices.filter(one => one.retiredAt === null || one.retiredReason === "gone").map(one => ({ id: one.id, words: `${one.uaWords} · since ${when(one.createdAt)}`, state: one.retiredAt !== null ? "expired" : one.consecutiveFailures >= 20 ? "failing" : "ok", removable: one.retiredAt === null })) },
379
+ chat: chatNotices === null || csrf === "" ? null : chatNotices,
380
+ digest: digest === null || csrf === "" || chatNotices?.mode === "quiet" ? null : { every: digest.everyMs === null ? "off" : String(Math.round(digest.everyMs / 60_000)), held: digest.everyMs === null ? null : `${digest.held} routine fact(s) held` },
381
+ telegram: { state: hasEnv ? "from the environment" : existing === null ? "not set" : "saved", current, delivery: telegramDelivery },
382
+ email,
383
+ workers,
384
+ updates,
385
+ firstResult,
386
+ ...(phone === null ? {} : { phone }),
387
+ };
388
+ return screen("Settings", joinHtml([
389
+ html `<h1>Settings</h1>`,
390
+ messaging?.legacyWarning ? html `<p data-legacy-webhooks>Legacy webhooks are deprecated. Connect <a href="/settings/slack" style="display:inline-flex;min-height:44px;align-items:center">Slack</a> or <a href="/settings/discord" style="display:inline-flex;min-height:44px;align-items:center">Discord</a> in Chat settings.</p>` : "",
391
+ settingsTiles(view.groups),
392
+ phone === null ? "" : phoneSetupHtml(phone),
393
+ appearanceCard(csrf),
394
+ permissionCard,
395
+ qualityCard,
396
+ keysCard,
397
+ workersCard(workers),
398
+ updatesCard(updates),
399
+ firstResult === null ? "" : html `<p class="meta" data-first-result>${firstResult}</p>`,
400
+ pushCard === "" && messagingCard === "" && digestCard === "" && chatCard === "" ? "" : html `<h2>Notifications</h2>`,
401
+ chatCard,
402
+ messagingCard,
403
+ pushCard,
404
+ digestCard,
405
+ problem === null ? "" : html `<p class="problem" role="alert">${problem}</p>`,
406
+ html `<details class="settings-more" id="telegram-token"><summary>Telegram bot token <span class="meta">${hasEnv ? "from the environment" : existing === null ? "not set" : "saved"}</span></summary>`,
407
+ html `<p class="meta">Current: ${current}</p>`,
408
+ telegramDelivery === null ? "" : html `<p class="meta" data-telegram-delivery>${telegramDelivery}</p>`,
409
+ postForm("/settings/telegram-token", joinHtml([
410
+ "",
411
+ html `<label>Token from @BotFather<input type="password" name="token" autocomplete="off"></label>`,
412
+ html `<button type="submit">Save token</button>`,
413
+ "",
414
+ ], "\n")),
415
+ html `<p class="meta">Stored privately on this computer. Then pair your phone under <a href="/settings/telegram">Telegram</a>. In Telegram, send <code>/status</code>, <code>/task &lt;id&gt;</code> or <code>/help</code>; these use no AI model.</p>`,
416
+ html `</details>`,
417
+ ], "\n"), { chrome, workspace: { view }, functional: { script: SETTINGS_AUTOSAVE_SCRIPT + (pushScript ?? ""), ...(pushScript === null ? {} : { fetches: true }) } });
418
+ }
419
+ /** The phone setup for the server-rendered Settings page: the same choices as the React card. */
420
+ export function phoneSetupHtml(phone) {
421
+ const [first, ...others] = phone.chatApps;
422
+ return html `<section class="card" id="phone" aria-labelledby="phone-card-title" data-phone-card><h2 id="phone-card-title">Use it from your phone</h2>\
423
+ <p>${first === undefined ? "" : html `<a href="${first.href}">Pair ${first.label}</a>`}${others.length === 0 ? "" : html ` · or ${joinHtml(others.map(one => html `<a href="${one.href}">${one.label}</a>`), ", ")}`}</p>\
424
+ <p class="meta" data-phone-tailnet>${phone.tailnet === null ? html `Or install <a href="https://tailscale.com/download">Tailscale</a> on this computer and your phone, then reload this page for the address.`
425
+ : phone.tailnet.restart === null ? html `Or on your phone open <code>${phone.tailnet.address}</code> and sign in.`
426
+ : html `Or start Toolroll with <code>${phone.tailnet.restart}</code>, then on your phone open <code>${phone.tailnet.address}</code> and sign in.`}</p></section>`;
427
+ }
428
+ /** Every worker that is not retired: its capacity, and the tasks it holds now. */
429
+ export function settingsWorkers(store, now) {
430
+ const claims = store.liveClaims(null, now);
431
+ return store.listRunners().filter(one => one.retiredAt === null).map(one => {
432
+ const age = now.getTime() - new Date(one.heartbeatAt).getTime();
433
+ const alive = runnerAlive(one, now);
434
+ return {
435
+ name: one.name,
436
+ tone: alive ? "ok" : age < 60 * 60_000 ? "warn" : "off",
437
+ state: alive ? "Connected" : age < 60 * 60_000 ? `Quiet for ${Math.max(1, Math.round(age / 60_000))} min` : "Not connected",
438
+ capacity: one.capacity,
439
+ busy: store.liveClaimCount(one.name, now),
440
+ running: claims.filter(claim => claim.runner === one.name).map(claim => ({
441
+ taskId: claim.taskId,
442
+ title: store.getTask(claim.taskId)?.title ?? claim.taskId,
443
+ href: taskHref(claim.taskId),
444
+ project: claim.repo === null ? null : projectName(claim.repo),
445
+ })),
446
+ };
447
+ });
448
+ }
449
+ /** The page's workers section, for browsers without the app script. */
450
+ export function workersCard(workers) {
451
+ if (workers === null)
452
+ return html ``;
453
+ return html `<section id="workers" aria-labelledby="workers-title"><h2 id="workers-title">Workers</h2>\
454
+ ${workers.length === 0
455
+ ? html `<p class="meta">No worker is connected. Run <code>toolroll up</code> on the computer with your projects.</p>`
456
+ : html `${workers.map(one => html `<div class="card" data-worker="${one.name}"><p class="row"><strong>${one.name}</strong> <span class="meta">${one.state}</span>\
457
+ <span class="right">${one.busy} of ${one.capacity} running</span></p>\
458
+ ${one.running.length === 0
459
+ ? html `<p class="meta">Nothing running.</p>`
460
+ : html `<ul>${one.running.map(task => html `<li><a href="${task.href}">${task.title}</a>${task.project === null ? "" : html ` <span class="meta">${task.project}</span>`}</li>`)}</ul>`}\
461
+ </div>`)}\
462
+ <p class="meta">To change how many a worker runs at once: <code>toolroll runner capacity &lt;name&gt; &lt;n&gt;</code></p>`}\
463
+ </section>`;
464
+ }
465
+ /** Settings → Updates, for browsers without the app script. */
466
+ export function updatesCard(updates) {
467
+ if (updates === null)
468
+ return html ``;
469
+ const latest = updates.latest;
470
+ return html `<section id="updates" aria-labelledby="updates-title"><h2 id="updates-title">Updates</h2>\
471
+ <p class="row">This version <span class="mono">${updates.current}</span> · ${!updates.check.on ? "Update checks are off" : latest === null ? "Not checked yet" : latest.newer ? html `Latest <span class="mono">${latest.version}</span>` : "Up to date"}</p>\
472
+ ${latest !== null && latest.newer
473
+ ? html `<p>Update with <code>${updates.updateCommand}</code> · <a href="${latest.url}">Release notes</a></p>\
474
+ ${latest.notes === "" ? "" : html `<details class="settings-more"><summary>What's new in ${latest.version}</summary><pre class="update-notes">${latest.notes}</pre></details>`}`
475
+ : ""}\
476
+ ${updates.check.canManage && !updates.check.byEnv
477
+ ? postForm("/settings/updates/checks", html `<button type="submit">${updates.check.on ? "Turn off daily check" : "Turn on daily check"}</button>`, { attrs: { class: "inline" }, hidden: { check: updates.check.on ? "off" : "on" } })
478
+ : updates.check.byEnv ? html `<p class="meta">Off by TOOLROLL_NO_UPDATE_CHECK.</p>` : ""}\
479
+ ${updates.workers.length === 0 ? "" : html `<ul>${updates.workers.map(one => html `<li data-worker-version="${one.name}">${one.name} <span class="mono">${one.version ?? "unknown"}</span>${one.older ? " · older" : ""}</li>`)}</ul>`}\
480
+ </section>`;
481
+ }
482
+ /** Choices save the moment they change; without the script the Save
483
+ * button stays and the form works the same. */
484
+ export const SETTINGS_AUTOSAVE_SCRIPT = `(function(){document.querySelectorAll('form[data-autosave]').forEach(function(form){form.classList.add('js-autosave');form.addEventListener('change',function(ev){var t=ev.target;if(t&&(t.type==='radio'||t.tagName==='SELECT')){if(form.requestSubmit)form.requestSubmit();else form.submit();}});});})();`;
485
+ /** The newest release, remembered for a while so the page stays quick. */
486
+ export let latestSeen = null;
487
+ export async function latestReleaseFor(latest) {
488
+ if (latest === undefined && latestSeen !== null && Date.now() - latestSeen.at < ("problem" in latestSeen.value ? 60_000 : 900_000))
489
+ return latestSeen.value;
490
+ let value;
491
+ try {
492
+ value = { version: (await (latest ?? latestVersionNow)()).version };
493
+ }
494
+ catch (error) {
495
+ value = { problem: error.message };
496
+ }
497
+ if (latest === undefined)
498
+ latestSeen = { at: Date.now(), value };
499
+ return value;
500
+ }
501
+ /** Settings destinations under short headings: an icon and a name each, and a chat app's logo and state. */
502
+ export function settingsTiles(groups) {
503
+ return html `<nav class="settings-tiles" aria-label="Settings sections">${groups.map(group => html `<section aria-label="${group.title}"><h2>${group.title}</h2><div>${group.tiles.map(tile => html `<a href="${tile.href}">${tile.brand === undefined ? strokeIcon(SETTINGS_TILE_ICONS.get(tile.href) ?? html ``) : brandIconHtml(tile.brand)}<span>${tile.label}${tile.status === undefined ? "" :
504
+ html `<span class="provider-status provider-status--${tile.status.tone}"><i aria-hidden="true"></i>${tile.status.words}</span>`}</span></a>`)}</div></section>`)}</nav>`;
505
+ }
506
+ /** Each destination once, in order: a group's heading, then its [href, label, icon] tiles. */
507
+ export const SETTINGS_GROUPS = [
508
+ ["Agents", [
509
+ ["/settings/lead", "Lead", html `<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 9h8M8 13h5"/>`],
510
+ ["/settings/models", "Models", html `<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/>`],
511
+ ["/settings/skills", "Skills", html `<path d="m12 3 1.9 5.8L20 10l-5 3.6L16.8 20 12 16.4 7.2 20 9 13.6 4 10l6.1-1.2z"/>`],
512
+ ["/settings/tools", "Tools", html `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>`],
513
+ ["/settings/knowledge", "Knowledge", html `<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20V3H6.5A2.5 2.5 0 0 0 4 5.5z"/><path d="M4 19.5A2.5 2.5 0 0 0 6.5 22H20v-5"/>`],
514
+ ["/settings/learning", "Learning", html `<path d="M3 3v18h18"/><path d="m7 15 4-4 3 3 5-6"/>`],
515
+ ]],
516
+ ["Automation", [
517
+ ["/settings/flows", "Flows", html `<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><path d="M10 6.5h4a3 3 0 0 1 3 3V14"/>`],
518
+ ["/settings/integrations", "Integrations", html `<path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/>`],
519
+ ]],
520
+ ["Chat apps", [
521
+ ["/settings/telegram", "Telegram", html ``],
522
+ ["/settings/slack", "Slack", html ``],
523
+ ["/settings/discord", "Discord", html ``],
524
+ ["/settings/teams", "Teams", html ``],
525
+ ]],
526
+ ["Access and rules", [
527
+ ["/settings/sign-in", "Sign-in", html `<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>`],
528
+ ["/settings/sessions", "Sessions & tokens", html `<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/>`],
529
+ ["/settings/project", "Project", html `<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>`],
530
+ ["/settings/policy", "Policy", html `<path d="M9 12l2 2 4-4"/><rect x="4" y="3" width="16" height="18" rx="2"/>`],
531
+ ["/settings/approval", "Approval rules", html `<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/>`],
532
+ ]],
533
+ ["System", [
534
+ ["/settings/monitoring", "Monitoring", html `<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>`],
535
+ ["/settings/retention", "Retention", html `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`],
536
+ ["/settings/storage", "Storage", html `<path d="M22 12H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M6 16h.01M10 16h.01"/>`],
537
+ ["/settings/updates", "Updates", html `<path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/>`],
538
+ ["/settings/backups", "Backups", html `<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/>`],
539
+ ["/settings/data", "Data", html `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>`],
540
+ ]],
541
+ ];
542
+ export const SETTINGS_TILE_ICONS = new Map(SETTINGS_GROUPS.flatMap(([, tiles]) => tiles.map(([href, , icon]) => [href, icon])));
543
+ export const CHAT_APP_TILES = { "/settings/telegram": "telegram", "/settings/slack": "slack", "/settings/discord": "discord", "/settings/teams": "teams" };
544
+ /**
545
+ * The settings groups, with each chat app's state from what the server already holds: not set up,
546
+ * connected, or getting alerts (only when that service was chosen, or is the only one).
547
+ * A Telegram bot whose saved delivery state reports a problem says so instead. With no config folder
548
+ * the state is unknown, so the tiles carry none.
549
+ */
550
+ export function settingsGroups(messaging, telegramFailing = false) {
551
+ return SETTINGS_GROUPS.map(([title, tiles]) => ({
552
+ title,
553
+ tiles: tiles.map(([href, label]) => {
554
+ const app = CHAT_APP_TILES[href];
555
+ if (app === undefined)
556
+ return { href, label };
557
+ if (messaging === null)
558
+ return { href, label, brand: app };
559
+ if (!messaging.configured.includes(app))
560
+ return { href, label, brand: app, status: { tone: "off", words: "Not set up" } };
561
+ if (app === "telegram" && telegramFailing)
562
+ return { href, label, brand: app, status: { tone: "warn", words: "Has a problem" } };
563
+ return { href, label, brand: app, status: { tone: "ok", words: messaging.channel === app && !messaging.implicit ? "Gets alerts" : "Connected" } };
564
+ }),
565
+ }));
566
+ }
567
+ /** Light, dark or the device's choice, one tap each. Per browser (a cookie). */
568
+ export function appearanceCard(csrf) {
569
+ if (csrf === "")
570
+ return html ``;
571
+ const pinned = requestContext.getStore()?.theme ?? null;
572
+ const current = pinned ?? "system";
573
+ const option = (value, label) => html `<button type="submit" name="theme" value="${value}" class="theme-choice" aria-pressed="${String(current === value)}">${label}</button>`;
574
+ return html `<section class="appearance" aria-labelledby="appearance-title"><h2 id="appearance-title">Appearance</h2>\
575
+ ${postForm("/settings/appearance", html `${option("system", "Match device")}${option("light", "Light")}${option("dark", "Dark")}`, { attrs: { class: "theme-switch" } })}\
576
+ <p class="meta">Saved in this browser.</p></section>`;
577
+ }