toolroll 0.9.85 → 0.9.87

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 (220) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/agent-fence.d.ts +6 -2
  3. package/dist/agent-fence.js +3 -2
  4. package/dist/agents-board.d.ts +12 -0
  5. package/dist/agents-board.js +26 -0
  6. package/dist/approval-rules-ui.d.ts +4 -1
  7. package/dist/approval-rules-ui.js +31 -15
  8. package/dist/assignment-ui.js +1 -1
  9. package/dist/assignment.js +4 -3
  10. package/dist/backup-ui.d.ts +5 -4
  11. package/dist/backup-ui.js +65 -32
  12. package/dist/browser/terminal.css +1 -1
  13. package/dist/browser/terminal.js +57 -54
  14. package/dist/browser/workspace.css +2 -2
  15. package/dist/browser/workspace.js +35 -68
  16. package/dist/browser-crew.js +1 -1
  17. package/dist/browser-workspace.d.ts +363 -177
  18. package/dist/browser-workspace.js +10 -9
  19. package/dist/chat-actions.d.ts +11 -1
  20. package/dist/chat-actions.js +53 -1
  21. package/dist/chat-channel.js +1 -0
  22. package/dist/chat-controls.d.ts +1 -1
  23. package/dist/chat-controls.js +1 -1
  24. package/dist/chat-quiet.js +3 -2
  25. package/dist/chat-settings-page.d.ts +29 -9
  26. package/dist/chat-settings-page.js +59 -28
  27. package/dist/check-levels-ui.d.ts +2 -1
  28. package/dist/check-levels-ui.js +67 -58
  29. package/dist/claim.d.ts +3 -0
  30. package/dist/claim.js +4 -1
  31. package/dist/contracts/chat-actions.d.ts +9 -0
  32. package/dist/contracts/chat-actions.js +3 -0
  33. package/dist/contracts/cli.d.ts +43 -0
  34. package/dist/contracts/cli.js +4 -0
  35. package/dist/contracts/console-api.d.ts +4 -1
  36. package/dist/contracts/console-api.js +4 -1
  37. package/dist/contracts/flow-propose.d.ts +4 -1
  38. package/dist/contracts/flow-propose.js +1 -1
  39. package/dist/contracts/flow.d.ts +41 -0
  40. package/dist/contracts/flow.js +4 -2
  41. package/dist/contracts/lead-tools.d.ts +21 -0
  42. package/dist/contracts/lead-tools.js +2 -0
  43. package/dist/contracts/store-json.d.ts +2 -0
  44. package/dist/contracts/trigger-payloads.d.ts +20 -0
  45. package/dist/contracts/trigger-payloads.js +4 -0
  46. package/dist/coordinator.js +1 -0
  47. package/dist/credentials-ui.d.ts +5 -3
  48. package/dist/credentials-ui.js +58 -31
  49. package/dist/deploy-gate.js +4 -0
  50. package/dist/design/kit.css +685 -0
  51. package/dist/design/kit.d.ts +208 -0
  52. package/dist/design/kit.js +177 -0
  53. package/dist/design/tokens.css +19 -1
  54. package/dist/discord-settings.js +8 -4
  55. package/dist/dispatch.d.ts +3 -0
  56. package/dist/dispatch.js +4 -1
  57. package/dist/export-ui.d.ts +6 -4
  58. package/dist/export-ui.js +18 -8
  59. package/dist/flow-browser.d.ts +103 -0
  60. package/dist/flow-browser.js +204 -0
  61. package/dist/flow-changed.d.ts +14 -0
  62. package/dist/flow-changed.js +53 -0
  63. package/dist/flow-code.d.ts +5 -1
  64. package/dist/flow-code.js +4 -1
  65. package/dist/flow-engine.js +16 -0
  66. package/dist/flow-gallery-ui.d.ts +41 -4
  67. package/dist/flow-gallery-ui.js +95 -63
  68. package/dist/flow-live.js +9 -1
  69. package/dist/flow-pull-request.js +15 -7
  70. package/dist/flow-share.js +3 -1
  71. package/dist/flow-starters-ui.d.ts +3 -9
  72. package/dist/flow-starters-ui.js +12 -23
  73. package/dist/flow-steps.d.ts +9 -1
  74. package/dist/flow-steps.js +55 -5
  75. package/dist/flow-triggers.js +55 -0
  76. package/dist/flow-unattended-words.d.ts +20 -0
  77. package/dist/flow-unattended-words.js +24 -0
  78. package/dist/flow-unattended.d.ts +9 -5
  79. package/dist/flow-unattended.js +45 -23
  80. package/dist/flows-cli.d.ts +19 -0
  81. package/dist/flows-cli.js +54 -5
  82. package/dist/flows-ui.d.ts +12 -6
  83. package/dist/flows-ui.js +152 -32
  84. package/dist/flows.js +5 -2
  85. package/dist/integrations-ui.d.ts +6 -4
  86. package/dist/integrations-ui.js +34 -40
  87. package/dist/integrations.d.ts +2 -2
  88. package/dist/integrations.js +4 -4
  89. package/dist/kits-ui.d.ts +8 -10
  90. package/dist/kits-ui.js +29 -33
  91. package/dist/kits.js +1 -1
  92. package/dist/knowledge-ui.js +76 -19
  93. package/dist/lead-tools.js +35 -6
  94. package/dist/lead-voice.js +16 -5
  95. package/dist/ledger-view.d.ts +2 -0
  96. package/dist/ledger-view.js +41 -38
  97. package/dist/models-ui.d.ts +8 -3
  98. package/dist/models-ui.js +82 -59
  99. package/dist/monitoring-ui.d.ts +4 -2
  100. package/dist/monitoring-ui.js +44 -13
  101. package/dist/needs-you.d.ts +1 -1
  102. package/dist/needs-you.js +8 -0
  103. package/dist/operate-remote-arguments.js +1 -0
  104. package/dist/operate-remote.js +1 -1
  105. package/dist/operate.d.ts +1 -1
  106. package/dist/operate.js +249 -6
  107. package/dist/passwordless.d.ts +1 -1
  108. package/dist/passwordless.js +1 -1
  109. package/dist/people-audit-ui.d.ts +2 -1
  110. package/dist/people-audit-ui.js +25 -23
  111. package/dist/policy-ui.d.ts +3 -2
  112. package/dist/policy-ui.js +27 -21
  113. package/dist/policy.d.ts +3 -0
  114. package/dist/policy.js +14 -1
  115. package/dist/project-delete-ui.d.ts +4 -2
  116. package/dist/project-delete-ui.js +135 -126
  117. package/dist/project-delete.js +7 -3
  118. package/dist/project-knowledge.d.ts +10 -10
  119. package/dist/publish.d.ts +11 -0
  120. package/dist/publish.js +16 -3
  121. package/dist/pull-request-flow.js +35 -4
  122. package/dist/pull-request-ui.d.ts +2 -1
  123. package/dist/pull-request-ui.js +39 -28
  124. package/dist/recipe-ui.d.ts +4 -2
  125. package/dist/recipe-ui.js +14 -4
  126. package/dist/release-adapters.d.ts +3 -0
  127. package/dist/release-adapters.js +22 -2
  128. package/dist/release-cli.js +81 -9
  129. package/dist/release.d.ts +21 -0
  130. package/dist/release.js +40 -4
  131. package/dist/releases.js +1 -1
  132. package/dist/request-budget-ui.d.ts +4 -2
  133. package/dist/request-budget-ui.js +30 -19
  134. package/dist/result-follow-ups.js +4 -0
  135. package/dist/retention-ui.d.ts +4 -3
  136. package/dist/retention-ui.js +15 -12
  137. package/dist/scope.d.ts +8 -0
  138. package/dist/scope.js +13 -0
  139. package/dist/serve.d.ts +0 -1
  140. package/dist/serve.js +51 -7
  141. package/dist/server/agents-computers.d.ts +9 -0
  142. package/dist/server/agents-computers.js +67 -0
  143. package/dist/server/chat.js +20 -18
  144. package/dist/server/chrome.d.ts +1 -1
  145. package/dist/server/chrome.js +19 -9
  146. package/dist/server/console.css +31 -2
  147. package/dist/server/flows.js +79 -36
  148. package/dist/server/home.d.ts +35 -0
  149. package/dist/server/home.js +104 -0
  150. package/dist/server/http.d.ts +3 -0
  151. package/dist/server/http.js +5 -0
  152. package/dist/server/kit-standalone.d.ts +22 -0
  153. package/dist/server/kit-standalone.js +69 -0
  154. package/dist/server/kit.d.ts +405 -0
  155. package/dist/server/kit.js +361 -0
  156. package/dist/server/pages.d.ts +2 -1
  157. package/dist/server/pages.js +213 -174
  158. package/dist/server/people-tokens.js +26 -85
  159. package/dist/server/render-history.d.ts +51 -0
  160. package/dist/server/render-history.js +228 -0
  161. package/dist/server/render-new-task.d.ts +29 -0
  162. package/dist/server/render-new-task.js +96 -0
  163. package/dist/server/render-next.d.ts +63 -0
  164. package/dist/server/render-next.js +109 -0
  165. package/dist/server/render-pages.d.ts +35 -190
  166. package/dist/server/render-pages.js +154 -563
  167. package/dist/server/render-people.d.ts +24 -3
  168. package/dist/server/render-people.js +93 -72
  169. package/dist/server/render-projects.d.ts +103 -0
  170. package/dist/server/render-projects.js +247 -0
  171. package/dist/server/render-results.d.ts +5 -24
  172. package/dist/server/render-results.js +48 -185
  173. package/dist/server/render-settings.d.ts +5 -74
  174. package/dist/server/render-settings.js +102 -493
  175. package/dist/server/render-tasks.d.ts +43 -5
  176. package/dist/server/render-tasks.js +131 -91
  177. package/dist/server/route-table.js +31 -5
  178. package/dist/server/settings.js +276 -255
  179. package/dist/server/task-views.d.ts +8 -0
  180. package/dist/server/task-views.js +90 -2
  181. package/dist/server/tasks.d.ts +18 -0
  182. package/dist/server/tasks.js +148 -41
  183. package/dist/server/terminals.js +1 -1
  184. package/dist/server/welcome.d.ts +22 -0
  185. package/dist/server/welcome.js +69 -0
  186. package/dist/settings-groups-ui.d.ts +279 -0
  187. package/dist/settings-groups-ui.js +615 -0
  188. package/dist/settings-kit.d.ts +42 -6
  189. package/dist/settings-kit.js +69 -7
  190. package/dist/settings-proofs.js +1 -0
  191. package/dist/setup-guide.d.ts +2 -2
  192. package/dist/skills-ui.js +59 -7
  193. package/dist/slack-settings.js +12 -5
  194. package/dist/spend-ui.d.ts +45 -26
  195. package/dist/spend-ui.js +124 -56
  196. package/dist/sso-ui.d.ts +5 -2
  197. package/dist/sso-ui.js +63 -31
  198. package/dist/storage-ui.d.ts +3 -2
  199. package/dist/storage-ui.js +56 -50
  200. package/dist/store.d.ts +182 -5
  201. package/dist/store.js +512 -38
  202. package/dist/surface.js +23 -3
  203. package/dist/task-archive.d.ts +10 -0
  204. package/dist/task-archive.js +16 -0
  205. package/dist/task-control.d.ts +1 -1
  206. package/dist/teams-settings.d.ts +2 -1
  207. package/dist/teams-settings.js +24 -41
  208. package/dist/telegram-settings.d.ts +0 -4
  209. package/dist/telegram-settings.js +52 -25
  210. package/dist/tidy.d.ts +130 -0
  211. package/dist/tidy.js +503 -0
  212. package/dist/toolroll-update-ui.d.ts +8 -4
  213. package/dist/toolroll-update-ui.js +55 -45
  214. package/dist/tools-ui.d.ts +5 -3
  215. package/dist/tools-ui.js +107 -42
  216. package/dist/work-index.d.ts +24 -0
  217. package/dist/work-index.js +56 -30
  218. package/dist/workspace-ui.d.ts +1 -1
  219. package/dist/workspace-ui.js +8 -4
  220. package/package.json +1 -1
@@ -1,148 +1,133 @@
1
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
2
  import { isSubscriptionChatProvider, PRICED_MODELS } from "../converse.js";
8
- import { digestTimes } from "../digest-times.js";
9
3
  import { describeCapability } from "../gaps.js";
10
4
  import { ABOUT_YOU_LINE_MAX, ABOUT_YOU_MAX_LINES } from "../lead-about.js";
11
5
  import { LEAD_NAME_MAX, LEAD_PERSONA_MAX } from "../lead-identity.js";
12
6
  import { projectName } from "../project.js";
13
- import {} from "../provider-connection.js";
14
- import {} from "../provider.js";
15
- import { qualityModeTitle } from "../quality.js";
16
7
  import { latestVersionNow } from "../releases.js";
17
- import { RESULT_SHOT_CHOICES } from "../result-shots.js";
18
8
  import { isAlive as runnerAlive } from "../runner.js";
19
- import {} from "../scope.js";
20
- import { ASSISTANTS } from "../setup-guide.js";
21
9
  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";
10
+ import {} from "../telegram.js";
11
+ import { chatMoney, screen, when } from "./chrome.js";
25
12
  import { taskHref } from "./http.js";
26
- import { html, joinHtml, postForm, reauth } from "../html.js";
27
- import { permissionModeChoices, qualityModeChoices } from "./render-tasks.js";
13
+ import { html, joinHtml, postForm } from "../html.js";
14
+ import { acts, btn, chip, codeLine, disclose, facts, formRow, input, note, notice, select, settingsLink, settingsRecord, settingsSection, textarea } from "./kit.js";
15
+ import { kitSectionForm, SECRET_OPT_OUTS } from "../settings-kit.js";
16
+ import { settingsSubPage } from "../settings-groups-ui.js";
28
17
  /** Settings → Lead: one line for what runs the lead and one action; the full form, turning it off and stored keys under Advanced. */
29
18
  export function leadSettingsHtml(data) {
30
19
  const { config, facts } = data;
31
20
  const returnTo = "/settings/lead";
32
- const summary = config === null
21
+ const runs = config === null
33
22
  ? 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>${postForm("/lead/check", html `<button type="submit" class="secondary">Check again</button>`, { returnTo })}`
23
+ ? settingsSection({ id: "lead-runs", title: "What runs the lead", lead: "The lead is off.",
24
+ body: postForm("/settings/lead/on", acts(btn(`Use your ${data.signedIn} sign-in`, { variant: "primary", type: "submit" })), { returnTo }) })
25
+ : settingsSection({ id: "lead-runs", title: "What runs the lead", lead: "The lead is off. Sign in an agent on this computer to turn it on:",
26
+ body: joinHtml([codeLine(data.command), postForm("/lead/check", acts(btn("Check again", { sm: true, type: "submit" })), { returnTo })]) })
36
27
  : 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 `${reauth(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
- // D5: the lead's subagents, one line each; everything else about one is on its own page.
57
- data.subagents === undefined ? "" : html `<section class="card lead-subagents" id="subagents" data-lead-subagents><h2>Subagents</h2>${data.subagents.length === 0 ? html `<p class="meta">None yet. Your lead can ask one for help once it's here.</p>`
58
- : html `<ul class="lead-subagent-list">${data.subagents.map(one => html `<li data-subagent="${one.id}"><a href="/settings/lead/subagents/${one.id}">${one.label}</a><span class="meta">${one.project}${one.paused ? " · Paused" : ""}</span></li>`)}</ul>`}<p><a class="button-link" href="/settings/lead/subagents?add=1#add" data-add-subagent>Add subagent</a></p></section>`,
59
- html `<details class="lead-advanced" data-lead-advanced><summary>Advanced</summary>`,
60
- leadConfigForm(config, facts),
61
- config === null ? "" : postForm("/chat/config", html `${reauth(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" } }),
62
- forget,
63
- html `</details>`,
64
- ], "\n");
28
+ ? settingsSection({ id: "lead-runs", title: "What runs the lead", lead: `The lead uses ${data.words}.`,
29
+ body: note(`${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.`) })
30
+ : settingsSection({ id: "lead-runs", title: "What runs the lead", lead: `The lead uses the ${config.provider === "anthropic-api" ? "Anthropic" : "OpenRouter"} API with ${config.model}.`,
31
+ body: note(`Up to ${chatMoney(config.weeklyCeilingMicrousd)} a week · up to ${config.dailyTurns} turns a day.`) });
32
+ const savedLine = (shown) => shown ? html `<span class="k-ssave-said" role="status">Saved.</span>` : html ``;
33
+ const identity = data.identity === undefined ? "" : settingsSection({ id: "lead-identity", title: "Name and persona",
34
+ body: postForm("/settings/lead/identity", joinHtml([
35
+ formRow("Name your lead", null, input({ id: "lead-name", name: "name", value: data.identity.name, attrs: { maxlength: LEAD_NAME_MAX, autocomplete: "off", required: true, "aria-label": "Name your lead" } })),
36
+ formRow("Persona", "How it talks to you.", textarea({ id: "lead-persona", name: "persona", rows: 4, value: data.identity.persona, attrs: { maxlength: LEAD_PERSONA_MAX, "aria-label": "Persona" } }), "top"),
37
+ html `<div class="k-ssave">${savedLine(data.saved === true)}${btn("Save", { sm: true, type: "submit" })}</div>`,
38
+ ]), { attrs: { "data-lead-identity": true } }) });
39
+ // What the lead knows about this person: lines they confirmed in chat or wrote here. Every project, every chat.
40
+ const about = data.about === undefined ? "" : settingsSection({ id: "lead-about", title: "What your lead knows about you",
41
+ body: postForm("/settings/lead/about", joinHtml([
42
+ formRow("About you", `One per line, up to ${ABOUT_YOU_MAX_LINES}. Used in every project and every chat.`, textarea({ id: "lead-about-text", name: "about", rows: Math.min(10, Math.max(4, data.about.length + 1)), value: data.about.join("\n"),
43
+ attrs: { maxlength: ABOUT_YOU_MAX_LINES * (ABOUT_YOU_LINE_MAX + 1), placeholder: "Keep copy terse.\nI test changes myself.", "aria-label": "What your lead knows about you" } }), "top"),
44
+ html `<div class="k-ssave">${savedLine(data.aboutSaved === true)}${btn("Save", { sm: true, type: "submit" })}</div>`,
45
+ ]), { attrs: { "data-lead-about": true } }) });
46
+ // What the lead promised to follow up on; it reports each once, here in chat, and drops it after 7 days.
47
+ const promises = (data.promises ?? []).length === 0 ? "" : settingsSection({ id: "lead-promises", title: "Promises", lead: "What the lead said it would follow up on. Each is reported once, then dropped after 7 days.",
48
+ body: joinHtml(data.promises.map(one => settingsRecord({
49
+ title: one.what, sub: `${one.when.charAt(0).toUpperCase() + one.when.slice(1)} · until ${new Date(one.until).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}`,
50
+ action: postForm("/settings/lead/promise/cancel", btn("Cancel", { sm: true, type: "submit" }), { returnTo, hidden: { promise: one.id } }),
51
+ attrs: { "data-promise": one.id },
52
+ }))) });
53
+ // D5: the lead's subagents, one line each; everything else about one is on its own page.
54
+ const subagents = data.subagents === undefined ? "" : settingsSection({ id: "subagents", title: "Subagents",
55
+ right: btn("Add subagent", { sm: true, href: "/settings/lead/subagents?add=1#add", attrs: { "data-add-subagent": true } }),
56
+ body: data.subagents.length === 0 ? note("None yet. Your lead can ask one for help once it's here.")
57
+ : html `<div class="k-slinks">${data.subagents.map(one => settingsLink({ href: `/settings/lead/subagents/${one.id}`, title: one.label, status: `${one.project}${one.paused ? " · Paused" : ""}` }))}</div>` });
58
+ // The lead's own key: where each provider's key comes from (never the key), and Forget for a stored one.
59
+ const keys = settingsSection({ id: "lead-key", title: "The lead's own key",
60
+ lead: "A direct API key the lead uses instead of a membership. A pasted key is kept in a file only this account can read, never in the database, and never shown again. ANTHROPIC_API_KEY or OPENROUTER_API_KEY in the environment always wins.",
61
+ body: joinHtml(facts.keyFacts.map(one => settingsRecord({
62
+ title: one.provider,
63
+ sub: one.state === "none" ? "No key yet" : one.state === "environment" ? "From the environment" : `stored ${one.tail ?? ""}`.trim(),
64
+ action: one.state !== "stored" ? "" : kitSectionForm({ proof: "lead-config", action: "/chat/config", id: `lead-forget-${one.provider}`, hidden: { return: returnTo, "forget-key": one.provider },
65
+ save: { label: "Forget", variant: "danger" }, confirm: `Forget the lead's own ${one.provider} key`, body: html `` }),
66
+ }))) });
67
+ const off = config === null ? "" : settingsSection({ id: "lead-off", title: "Turn the lead off",
68
+ body: kitSectionForm({ proof: "lead-config", action: "/chat/config", id: "lead-off-form", hidden: { return: returnTo, off: "1" },
69
+ save: { label: "Turn the lead off", variant: "danger" }, confirm: "Turn the lead off", body: note("The lead stops answering until you turn it on again.") }) });
70
+ return settingsSubPage("agents", "Lead", joinHtml([
71
+ data.problem ? notice(data.problem.charAt(0).toUpperCase() + data.problem.slice(1)) : data.said === null ? "" : html `<p class="k-notice info" role="status">${data.said}</p>`,
72
+ runs, identity, about, promises, subagents,
73
+ disclose("Advanced", joinHtml([leadConfigForm(config, facts), keys, off])),
74
+ ], "\n"));
65
75
  }
66
- /** The lead's full settings (Settings → Lead → Advanced): provider, model, limits and a direct API key. */
76
+ /** The lead's full settings (Settings → Lead → Advanced): provider, model, limits and the lead's own key. */
67
77
  export function leadConfigForm(current, data) {
68
78
  const anthropicModels = PRICED_MODELS.filter(one => !one.includes("/"));
69
79
  const openrouterModels = data.openrouterModels ?? PRICED_MODELS.filter(one => one.includes("/"));
70
80
  const currentSubscription = current !== null && isSubscriptionChatProvider(current.provider);
71
81
  const labels = new Map((data.liveModels ?? []).map(one => [one.value, one.label]));
72
82
  const models = [...new Set(["default", ...labels.keys(), ...anthropicModels, ...openrouterModels, ...(current === null ? [] : [current.model])])];
73
- return joinHtml([
74
- postForm("/chat/config", joinHtml([
75
- "",
76
- html `<label>Provider<select name="provider">`,
77
- html `<option value="codex-subscription"${current?.provider === "codex-subscription" ? " selected" : ""}>Codex membership (logged-in CLI)</option>`,
78
- html `<option value="claude-subscription"${current?.provider === "claude-subscription" ? " selected" : ""}>Anthropic membership (logged-in CLI)</option>`,
79
- html `<option value="anthropic-api"${current?.provider === "anthropic-api" ? " selected" : ""}>anthropic-api (direct API)</option>`,
80
- html `<option value="openrouter-api"${current?.provider === "openrouter-api" ? " selected" : ""}>openrouter-api (direct API)</option>`,
81
- html `</select></label>`,
82
- html `<label>Model <span class="meta">(use default for your membership's current model; direct API models need a pinned price)</span>\
83
- <input name="model" list="chat-models" value="${current?.model ?? "default"}"><datalist id="chat-models">\
84
- ${models.map(model => html `<option value="${model}">${labels.get(model) ?? ""}</option>`)}</datalist></label>`,
85
- data.openrouterModels === null
86
- ? 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>`
87
- : html `<p class="meta">${data.openrouterModels.length} models live from OpenRouter's catalog; saving pins today's price — re-save to re-pin</p>`,
88
- currentSubscription
89
- ? 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>`
90
- : html `<label>Weekly ceiling <span class="meta">(direct API only; leave blank when choosing a membership)</span>\
91
- <input type="text" name="weekly-usd" inputmode="decimal" style="width:8rem" value="${current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2)}"></label>`,
92
- html `<label>Daily turns <span class="meta">(default 50)</span>\
93
- <input type="text" name="daily-turns" inputmode="numeric" style="width:8rem" value="${current === null ? "" : String(current.dailyTurns)}"></label>`,
94
- currentSubscription
95
- ? 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>`
96
- : html `<label>API key <span class="meta">(${data.keyFacts
97
- .map(one => one.state === "none"
98
- ? `${one.provider}: none yet`
99
- : one.state === "environment"
100
- ? `${one.provider}: from the environment`
101
- : `${one.provider}: stored ${one.tail ?? ""}`)
102
- .join(" · ")})</span>\
103
- <input type="password" name="key" placeholder="direct API only — leave empty to keep" autocomplete="off"></label>`,
104
- html `<label>Your password <span class="meta">(typed again to change the provider)</span>\
105
- ${reauth(html `<input type="password" name="token" autocomplete="current-password">`)}</label>`,
106
- html `<button type="submit">${current === null ? "turn chat on" : "save"}</button>`,
107
- "",
108
- ], "\n"), { attrs: { class: "card" }, returnTo: data.returnTo }),
83
+ const keyWords = data.keyFacts.map(one => one.state === "none" ? `${one.provider}: none yet` : one.state === "environment" ? `${one.provider}: from the environment` : `${one.provider}: stored ${one.tail ?? ""}`.trim()).join(" · ");
84
+ const body = joinHtml([
85
+ formRow("Provider", null, select({ id: "lead-provider", name: "provider", label: "Provider", ...(current === null ? {} : { current: current.provider }), items: [
86
+ ["codex-subscription", "Codex membership (signed-in CLI)"],
87
+ ["claude-subscription", "Anthropic membership (signed-in CLI)"],
88
+ ["anthropic-api", "Anthropic API (direct)"],
89
+ ["openrouter-api", "OpenRouter API (direct)"],
90
+ ] })),
91
+ formRow("Model", "Use default for your membership's current model. A direct API model needs a pinned price.", html `${input({ id: "lead-model", name: "model", value: current?.model ?? "default", attrs: { list: "chat-models", "aria-label": "Model" } })}<datalist id="chat-models">\
92
+ ${models.map(model => html `<option value="${model}">${labels.get(model) ?? ""}</option>`)}</datalist>`),
93
+ note(data.openrouterModels === null
94
+ ? "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."
95
+ : `${data.openrouterModels.length} models live from OpenRouter's catalog. Saving pins today's price; save again to pin it again.`),
109
96
  currentSubscription
110
- ? 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>`
111
- : 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 (\
112
- <span class="mono">ANTHROPIC_API_KEY</span> / <span class="mono">OPENROUTER_API_KEY</span>) always wins when set</p>`,
113
- ], "\n");
97
+ ? note("No dollar maximum. Membership chat uses the plan of the signed-in CLI; the conversation stays live until you end it, and the daily turn limit still applies.")
98
+ : formRow("Weekly ceiling", "Direct API only, in dollars. Leave blank when choosing a membership.", input({ id: "lead-weekly", name: "weekly-usd", value: current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2), attrs: { inputmode: "decimal", "aria-label": "Weekly ceiling" } })),
99
+ formRow("Daily turns", "50 when left blank.", input({ id: "lead-turns", name: "daily-turns", value: current === null ? "" : String(current.dailyTurns), attrs: { inputmode: "numeric", "aria-label": "Daily turns" } })),
100
+ currentSubscription
101
+ ? note(html `Sign in on this computer first with ${current?.provider === "codex-subscription" ? html `<span class="k-mono">codex login</span>` : html `the <span class="k-mono">claude</span> CLI`}. Toolroll reuses that sign-in and never stores it.`)
102
+ : formRow("The lead's own key", `Direct API only. Leave empty to keep the one you have. ${keyWords}.`, html `<input class="k-input" id="lead-own-key" type="password" name="key" autocomplete="off" spellcheck="false" autocapitalize="none" data-so-secret data-1p-ignore data-lpignore="${SECRET_OPT_OUTS["data-lpignore"]}" data-bwignore data-form-type="other" aria-label="The lead's own key" placeholder="Paste a key">`),
103
+ ]);
104
+ return settingsSection({ id: "lead-config", title: "Model and limits",
105
+ lead: currentSubscription
106
+ ? "The membership provider runs without repository tools, in a temporary directory. Only Toolroll can turn an action it proposes into a confirmation card."
107
+ : "Changing these always asks for your password, typed.",
108
+ body: kitSectionForm({ proof: "lead-config", action: "/chat/config", id: "lead-config-form", ...(current === null ? {} : { gate: true }), hidden: { return: data.returnTo },
109
+ save: { label: current === null ? "Turn the lead on" : "Save" }, confirm: current === null ? "Turn the lead on" : "Change the lead's model and limits", body }) });
114
110
  }
115
111
  export function capsPage(chrome, caps, gaps, repo, now) {
116
112
  if (caps === null) {
117
- return screen("requirements", joinHtml([
118
- html `<h1>Requirements</h1>`,
119
- html `<p class="meta">Open a project to see its requirements — <a href="/projects">projects</a></p>`,
120
- ], "\n"), { chrome });
113
+ return screen("requirements", settingsSubPage("approval", "Requirements", note(html `Open a project to see its requirements: <a class="k-link" href="/projects">Projects</a>.`)), { chrome });
121
114
  }
122
- const list = caps.length === 0
123
- ? html `<p>Nothing recorded.</p>`
124
- : joinHtml(caps
125
- .map(capability => html `<p class="row">${capability.kind}:${capability.name} — \
126
- ${describeCapability(capability, now ?? new Date())}</p>`), "\n");
127
- const blocked = gaps.length === 0
128
- ? html `<p class="meta">No gaps — everything recorded is verified</p>`
129
- : joinHtml(gaps
130
- .map(gap => html `<div class="card"><p>${gap.key} — ${gap.state}</p>\
131
- <p class="meta">${gap.unblocks.length > 0
132
- ? `fills → ${gap.unblocks.length} task(s) start: ${gap.unblocks.join(", ")}`
133
- : gap.alsoBlocks.length > 0
134
- ? `part of what holds: ${gap.alsoBlocks.join(", ")}`
135
- : "nothing queued needs it yet"}</p>\
136
- <p class="meta">Verify: ${gap.verify}</p>\
137
- <p class="meta">${gap.instructions}</p></div>`), "\n");
138
- return screen("requirements", joinHtml([
139
- html `<h1>Requirements</h1>`,
140
- 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>`,
141
- list,
142
- html `<h2>Missing, ranked by what filling them frees</h2>`,
143
- blocked,
144
- 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>`,
145
- ], "\n"), { chrome });
115
+ const recorded = settingsSection({ id: "recorded", title: "Recorded",
116
+ body: caps.length === 0 ? note("Nothing recorded.") : joinHtml(caps.map(capability => settingsRecord({
117
+ title: html `<span class="k-mono">${capability.kind}:${capability.name}</span>`, sub: describeCapability(capability, now ?? new Date()),
118
+ }))) });
119
+ const missing = settingsSection({ id: "missing", title: "Missing", lead: "Ranked by what filling them frees.",
120
+ body: gaps.length === 0 ? note("Nothing missing. Everything recorded is verified.") : joinHtml(gaps.map(gap => settingsRecord({
121
+ title: html `<span class="k-mono">${gap.key}</span>${chip(gap.state)}`,
122
+ sub: gap.unblocks.length > 0
123
+ ? `Filling it starts ${gap.unblocks.length} task${gap.unblocks.length === 1 ? "" : "s"}: ${gap.unblocks.join(", ")}`
124
+ : gap.alsoBlocks.length > 0 ? `Part of what holds ${gap.alsoBlocks.join(", ")}` : "Nothing queued needs it yet",
125
+ more: facts([["Check", codeLine(gap.verify)], ["How to fill it", gap.instructions]]),
126
+ }))) });
127
+ return screen("requirements", settingsSubPage("approval", "Requirements", joinHtml([
128
+ recorded, missing,
129
+ note("Read-only here: the checks are shell commands you wrote, and a web button that runs them would need its own security review."),
130
+ ], "\n"), { lead: "Tools and credentials builds need. Each is checked on the builder before any build spends money; values never leave your computer." }), { chrome });
146
131
  }
147
132
  /** Whether the bot's saved delivery state reports a problem (no new check runs). */
148
133
  export function telegramTrouble(store, bot) {
@@ -164,270 +149,6 @@ export function notificationProjects(store, account) {
164
149
  .map(one => ({ repo: one.path, name: one.name, muted: muted.has(one.path) }))
165
150
  .sort((a, b) => a.name.localeCompare(b.name));
166
151
  }
167
- 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) {
168
- const permissionCard = permissionDefault === null
169
- ? ""
170
- : joinHtml([
171
- html `<h2>Unattended permissions</h2>`,
172
- permissionDefault.canManage && csrf !== ""
173
- ? postForm("/settings/permission-default", html `${permissionModeChoices("permission-mode", permissionDefault.mode)}${permissionDefault.mode === "bypassPermissions" ? "" : reauth(html `<label>Your password <span class="meta">(needed for Full access)</span><input type="password" name="password" autocomplete="current-password"></label>`)}<button type="submit">Save default</button>`, { attrs: { class: "card permission-policy" } })
174
- : 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>`,
175
- permissionDefault.updatedAt === null
176
- ? ""
177
- : html `<p class="meta settings-changed">Changed ${whenTime(permissionDefault.updatedAt)}${permissionDefault.updatedBy === null ? "" : ` by ${permissionDefault.updatedBy}`}</p>`,
178
- ], "\n");
179
- const qualityCard = qualityDefault === null
180
- ? ""
181
- : joinHtml([
182
- html `<h2>Quality mode</h2>`,
183
- qualityDefault.canManage && csrf !== ""
184
- ? postForm("/settings/quality-default", html `${qualityModeChoices("quality-mode", qualityDefault.mode)}<button type="submit">Save default</button>`, { attrs: { class: "card permission-policy", "data-autosave": true } })
185
- : html `<div class="card"><p><strong>${qualityModeTitle(qualityDefault.mode)}</strong></p><p class="meta">An approver can change this default</p></div>`,
186
- qualityDefault.updatedAt === null
187
- ? ""
188
- : html `<p class="meta settings-changed">Changed ${whenTime(qualityDefault.updatedAt)}${qualityDefault.updatedBy === null ? "" : ` by ${qualityDefault.updatedBy}`}</p>`,
189
- ], "\n");
190
- // Quiet chat: this person's own choice. The installation's Telegram cadence only bundles Every step updates.
191
- const chatCard = chatNotices === null || csrf === ""
192
- ? ""
193
- : joinHtml([
194
- html `<h3>Chat messages</h3>`,
195
- postForm("/settings/notifications", joinHtml([
196
- "",
197
- 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>`,
198
- 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>`,
199
- html `<label>Evening digest<select name="digest">\
200
- ${digestTimes(chatNotices.digestAt).map(([value, label]) => html `<option value="${value}"${value === (chatNotices.digestAt ?? "off") ? " selected" : ""}>${label}</option>`)}\
201
- </select></label>`,
202
- html `<p class="meta">One message: what finished, what waits, what failed.</p>`,
203
- html `<label>Screenshots with results<select name="screenshots">\
204
- ${RESULT_SHOT_CHOICES.map(([value, label]) => html `<option value="${value}"${value === (chatNotices.screenshots ?? "off") ? " selected" : ""}>${label}</option>`)}\
205
- </select></label>`,
206
- html `<button type="submit">Save</button>`,
207
- "",
208
- ], "\n"), { attrs: { class: "card", "data-autosave": true } }),
209
- ...((chatNotices.projects ?? []).length === 0 ? [] : [
210
- html `<h3>Projects</h3>`,
211
- 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>`,
212
- html `<p class="meta">Muted projects still show in Tasks and the evening digest.</p>`,
213
- ]),
214
- ], "\n");
215
- const digestCard = digest === null || csrf === "" || chatNotices?.mode === "quiet"
216
- ? ""
217
- : joinHtml([
218
- html `<h3>Telegram digest</h3>`,
219
- html `<p class="meta">Bundle routine updates. Anything that needs you still arrives at once.</p>`,
220
- postForm("/settings/telegram-digest", joinHtml([
221
- "",
222
- html `<label>Send a digest<select name="every">\
223
- ${[
224
- ["off", "Off: send each update"],
225
- ["30", "every 30 minutes"],
226
- ["60", "every hour"],
227
- ["240", "every 4 hours"],
228
- ["720", "every 12 hours"],
229
- ["1440", "once a day"],
230
- ]
231
- .map(([value, label]) => {
232
- const selected = value === "off" ? digest.everyMs === null : digest.everyMs === Number(value) * 60_000;
233
- return html `<option value="${value}"${selected ? " selected" : ""}>${label}</option>`;
234
- })}\
235
- </select></label>`,
236
- html `<p class="meta">${digest.everyMs === null
237
- ? ""
238
- : `${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>`,
239
- html `<button type="submit">Save digest</button>`,
240
- "",
241
- ], "\n"), { attrs: { class: "card", "data-autosave": true } }),
242
- ], "\n");
243
- const keysCard = providerKeys === null || csrf === ""
244
- ? ""
245
- : joinHtml([
246
- html `<h2 id="providers">AI providers</h2><p><a href="/control">Set up this project</a></p>`,
247
- html `<p class="meta">Keys stay on this computer and are never shown again.</p>`,
248
- 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>`,
249
- ...providerKeys.map(one => {
250
- // One status row per provider; the controls wait behind Manage.
251
- const name = ASSISTANTS[one.provider]?.name ?? one.provider;
252
- const status = one.connection !== undefined && one.connection.state === "connected"
253
- ? { tone: "ok", words: [connectionWords(one.connection), one.connection.plan].filter(Boolean).join(" · ") }
254
- : one.mode === "subscription"
255
- ? { tone: one.connection === undefined ? "neutral" : "warn", words: one.connection === undefined ? "Uses its own sign-in" : connectionWords(one.connection) }
256
- : one.connection?.state === "key-works" ? { tone: "ok", words: "API key works" } : one.connection?.state === "key-refused" ? { tone: "warn", words: "API key refused" }
257
- : 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" };
258
- return joinHtml([
259
- html `<div class="provider-row" data-provider="${one.provider}">`,
260
- 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>`,
261
- html `<details class="provider-manage"><summary>Manage</summary>`,
262
- postForm("/settings/provider-key", joinHtml([
263
- "",
264
- 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(" · ")} · <button type="submit" class="secondary" formaction="/settings/connection/check" formnovalidate>Check again</button></p>`,
265
- 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>`,
266
- one.subscriptionCapable
267
- ? html `<label>Sign-in<select name="auth-mode">\
268
- <option value="subscription"${one.mode === "subscription" ? " selected" : ""}>${name} subscription</option>\
269
- <option value="api-key"${one.mode === "api-key" ? " selected" : ""}>API key</option>\
270
- </select></label>`
271
- : "",
272
- html `<label>API key<input type="password" name="value" autocomplete="off" placeholder="${one.set ? "Paste to replace the stored key" : "Paste a key"}"></label>`,
273
- html `<button type="submit">Save ${name}</button>`,
274
- one.set
275
- ? 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>`
276
- : "",
277
- "",
278
- ], "\n"), { attrs: { class: "card" }, hidden: { provider: one.provider } }),
279
- html `</details></div>`,
280
- ], "\n");
281
- }),
282
- ], "\n");
283
- const pushCard = push === null || csrf === ""
284
- ? ""
285
- : joinHtml([
286
- html `<h3>This device</h3>`,
287
- push.available
288
- ? joinHtml([
289
- html `<p class="meta">A notification when something needs you. On iPhone, add this app to your Home Screen first.</p>`,
290
- postForm("/push/subscribe", joinHtml([
291
- "",
292
- html `<input type="hidden" name="endpoint" value=""><input type="hidden" name="p256dh" value=""><input type="hidden" name="auth" value="">`,
293
- html `${reauth(html `<label>Your password <input type="password" name="token" autocomplete="current-password"></label>`)}`,
294
- html `<button type="submit" id="push-enable">Get alerts on this device</button>`,
295
- html `<p class="meta" id="push-state"></p>`,
296
- "",
297
- ], "\n"), { attrs: { id: "push-form", class: "card" } }),
298
- ], "\n")
299
- : html `<p class="meta">Alerts need a secure (https) address for this app.</p>`,
300
- ...push.devices
301
- .filter(one => one.retiredAt === null || one.retiredReason === "gone")
302
- .map(one => html `<p class="row">${one.uaWords} · since ${whenTime(one.createdAt)}\
303
- ${one.retiredAt !== null ? html ` · <span class="meta">expired</span>` : one.consecutiveFailures >= 20 ? html ` · <span class="meta">failing</span>` : ""}\
304
- ${one.retiredAt === null
305
- ? html ` ${postForm("/push/remove", html `<button type="submit">Remove</button>`, { attrs: { class: "inline" }, hidden: { id: one.id } })}`
306
- : ""}\
307
- </p>`),
308
- ], "\n");
309
- // The enrollment behavior rides the ONE composed script (arc 4, finding
310
- // 18) — it fills the subscription fields the form posts; it never reads
311
- // the password field beside them (the named functional exception).
312
- const pushScript = push === null || !push.available || csrf === ""
313
- ? null
314
- : `(function(){` +
315
- `if(!("serviceWorker" in navigator)||!("PushManager" in window))return;` +
316
- `var link=document.createElement("link");link.rel="manifest";link.href="/manifest.webmanifest";document.head.appendChild(link);` +
317
- `navigator.serviceWorker.register("/sw.js",{scope:"/"}).catch(function(){});` +
318
- `var form=document.getElementById("push-form");if(!form)return;` +
319
- `form.addEventListener("submit",function(event){` +
320
- `if(form.dataset.ready==="1")return;` +
321
- `event.preventDefault();var state=document.getElementById("push-state");` +
322
- `Notification.requestPermission().then(function(granted){` +
323
- `if(granted!=="granted"){if(state)state.textContent="notifications are blocked for this site in the browser settings";return;}` +
324
- `return fetch("/push/key").then(function(r){return r.json();}).then(function(d){` +
325
- `return navigator.serviceWorker.ready.then(function(reg){` +
326
- `return reg.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:Uint8Array.from(atob(d.key.replace(/-/g,"+").replace(/_/g,"/")),function(c){return c.charCodeAt(0);})});});` +
327
- `}).then(function(sub){var raw=sub.toJSON();` +
328
- `form.querySelector("[name=endpoint]").value=sub.endpoint;` +
329
- `form.querySelector("[name=p256dh]").value=(raw.keys.p256dh||"").replace(/\\+/g,"-").replace(/\\//g,"_").replace(/=+$/,"");` +
330
- `form.querySelector("[name=auth]").value=(raw.keys.auth||"").replace(/\\+/g,"-").replace(/\\//g,"_").replace(/=+$/,"");` +
331
- `form.dataset.ready="1";form.submit();});` +
332
- `}).catch(function(){if(state)state.textContent="could not subscribe — the browser said no";});});` +
333
- `})();`;
334
- const messagingCard = messaging === null || messaging.configured.length === 0
335
- ? ""
336
- : messaging.configured.length === 1 && !messaging.implicit
337
- ? 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>`
338
- : joinHtml([
339
- html `<h3>Alert service</h3>`,
340
- 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>`,
341
- postForm("/settings/messaging", joinHtml([
342
- "",
343
- ...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>`),
344
- html `<button type="submit">Use this service</button>`,
345
- "",
346
- ], "\n"), { attrs: { class: "card" } }),
347
- html `<p class="meta">Telegram still accepts taps and replies when another service sends alerts.</p>`,
348
- ], "\n");
349
- const current = hasEnv
350
- ? `set in the environment (${TOKEN_ENV}) — that takes precedence over anything saved here`
351
- : existing === null
352
- ? "not set"
353
- : `saved: ${redactToken(existing.token)} (bot ${existing.botId})`;
354
- const theme = requestContext.getStore()?.theme ?? "system";
355
- const view = {
356
- kind: "settings",
357
- said: problem,
358
- groups: settingsGroups(messaging, telegramFailing),
359
- ...(messaging?.legacyWarning ? { legacyWebhookWarning: messaging.legacyWarning } : {}),
360
- theme,
361
- accent: requestContext.getStore()?.accent ?? DEFAULT_ACCENT,
362
- accentPresets: ACCENT_PRESETS,
363
- 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}`}` },
364
- 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}`}` },
365
- providers: providerKeys === null || csrf === "" ? null : providerKeys.map(one => {
366
- const name = ASSISTANTS[one.provider]?.name ?? one.provider;
367
- const status = one.connection !== undefined && one.connection.state === "connected"
368
- ? { tone: "ok", words: [connectionWords(one.connection), one.connection.plan].filter(Boolean).join(" · ") }
369
- : one.mode === "subscription"
370
- ? { tone: one.connection === undefined ? "neutral" : "warn", words: one.connection === undefined ? "Uses its own sign-in" : connectionWords(one.connection) }
371
- : one.connection?.state === "key-works" ? { tone: "ok", words: "API key works" } : one.connection?.state === "key-refused" ? { tone: "warn", words: "API key refused" }
372
- : 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" };
373
- return {
374
- provider: one.provider, name, tone: status.tone, words: status.words,
375
- connection: one.connection === undefined ? null : { words: connectionWords(one.connection), facts: [one.connection.email, one.connection.plan, one.connection.method].filter(Boolean).join(" · "), checkAction: "/settings/connection/check" },
376
- 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"}`,
377
- envName: one.envName, subscriptionCapable: one.subscriptionCapable, mode: one.mode, set: one.set,
378
- };
379
- }),
380
- services: messaging === null || messaging.configured.length === 0 ? null : { configured: messaging.configured, channel: messaging.channel, implicit: messaging.implicit },
381
- 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 })) },
382
- chat: chatNotices === null || csrf === "" ? null : chatNotices,
383
- 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` },
384
- telegram: { state: hasEnv ? "from the environment" : existing === null ? "not set" : "saved", current, delivery: telegramDelivery },
385
- email,
386
- workers,
387
- updates,
388
- firstResult,
389
- ...(phone === null ? {} : { phone }),
390
- };
391
- return screen("Settings", joinHtml([
392
- html `<h1>Settings</h1>`,
393
- 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>` : "",
394
- settingsTiles(view.groups),
395
- phone === null ? "" : phoneSetupHtml(phone),
396
- appearanceCard(csrf),
397
- permissionCard,
398
- qualityCard,
399
- keysCard,
400
- workersCard(workers),
401
- updatesCard(updates),
402
- firstResult === null ? "" : html `<p class="meta" data-first-result>${firstResult}</p>`,
403
- pushCard === "" && messagingCard === "" && digestCard === "" && chatCard === "" ? "" : html `<h2>Notifications</h2>`,
404
- chatCard,
405
- messagingCard,
406
- pushCard,
407
- digestCard,
408
- problem === null ? "" : html `<p class="problem" role="alert">${problem}</p>`,
409
- 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>`,
410
- html `<p class="meta">Current: ${current}</p>`,
411
- telegramDelivery === null ? "" : html `<p class="meta" data-telegram-delivery>${telegramDelivery}</p>`,
412
- postForm("/settings/telegram-token", joinHtml([
413
- "",
414
- html `<label>Token from @BotFather<input type="password" name="token" autocomplete="off"></label>`,
415
- html `<button type="submit">Save token</button>`,
416
- "",
417
- ], "\n")),
418
- 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>`,
419
- html `</details>`,
420
- ], "\n"), { chrome, workspace: { view }, functional: { script: SETTINGS_AUTOSAVE_SCRIPT + (pushScript ?? ""), ...(pushScript === null ? {} : { fetches: true }) } });
421
- }
422
- /** The phone setup for the server-rendered Settings page: the same choices as the React card. */
423
- export function phoneSetupHtml(phone) {
424
- const [first, ...others] = phone.chatApps;
425
- 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>\
426
- <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>\
427
- <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.`
428
- : phone.tailnet.restart === null ? html `Or on your phone open <code>${phone.tailnet.address}</code> and sign in.`
429
- : 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>`;
430
- }
431
152
  /** Every worker that is not retired: its capacity, and the tasks it holds now. */
432
153
  export function settingsWorkers(store, now) {
433
154
  const claims = store.liveClaims(null, now);
@@ -449,39 +170,6 @@ export function settingsWorkers(store, now) {
449
170
  };
450
171
  });
451
172
  }
452
- /** The page's workers section, for browsers without the app script. */
453
- export function workersCard(workers) {
454
- if (workers === null)
455
- return html ``;
456
- return html `<section id="workers" aria-labelledby="workers-title"><h2 id="workers-title">Workers</h2>\
457
- ${workers.length === 0
458
- ? html `<p class="meta">No worker is connected. Run <code>toolroll up</code> on the computer with your projects.</p>`
459
- : 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>\
460
- <span class="right">${one.busy} of ${one.capacity} running</span></p>\
461
- ${one.running.length === 0
462
- ? html `<p class="meta">Nothing running.</p>`
463
- : 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>`}\
464
- </div>`)}\
465
- <p class="meta">To change how many a worker runs at once: <code>toolroll runner capacity &lt;name&gt; &lt;n&gt;</code></p>`}\
466
- </section>`;
467
- }
468
- /** Settings → Updates, for browsers without the app script. */
469
- export function updatesCard(updates) {
470
- if (updates === null)
471
- return html ``;
472
- const latest = updates.latest;
473
- return html `<section id="updates" aria-labelledby="updates-title"><h2 id="updates-title">Updates</h2>\
474
- <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>\
475
- ${latest !== null && latest.newer
476
- ? html `<p>Update with <code>${updates.updateCommand}</code> · <a href="${latest.url}">Release notes</a></p>\
477
- ${latest.notes === "" ? "" : html `<details class="settings-more"><summary>What's new in ${latest.version}</summary><pre class="update-notes">${latest.notes}</pre></details>`}`
478
- : ""}\
479
- ${updates.check.canManage && !updates.check.byEnv
480
- ? 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" } })
481
- : updates.check.byEnv ? html `<p class="meta">Off by TOOLROLL_NO_UPDATE_CHECK.</p>` : ""}\
482
- ${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>`}\
483
- </section>`;
484
- }
485
173
  /** Choices save the moment they change; without the script the Save
486
174
  * button stays and the form works the same. */
487
175
  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();}});});})();`;
@@ -501,82 +189,3 @@ export async function latestReleaseFor(latest) {
501
189
  latestSeen = { at: Date.now(), value };
502
190
  return value;
503
191
  }
504
- /** Settings destinations under short headings: an icon and a name each, and a chat app's logo and state. */
505
- export function settingsTiles(groups) {
506
- 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 ? "" :
507
- html `<span class="provider-status provider-status--${tile.status.tone}"><i aria-hidden="true"></i>${tile.status.words}</span>`}</span></a>`)}</div></section>`)}</nav>`;
508
- }
509
- /** Each destination once, in order: a group's heading, then its [href, label, icon] tiles. */
510
- export const SETTINGS_GROUPS = [
511
- ["Agents", [
512
- ["/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"/>`],
513
- ["/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"/>`],
514
- ["/settings/spend", "Spend", html `<circle cx="12" cy="12" r="9"/><path d="M15 9.5c-.6-.9-1.7-1.5-3-1.5-1.7 0-3 .9-3 2s1.3 1.7 3 2 3 .9 3 2-1.3 2-3 2c-1.3 0-2.4-.6-3-1.5M12 6v2M12 16v2"/>`],
515
- ["/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"/>`],
516
- ["/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"/>`],
517
- ["/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"/>`],
518
- ["/settings/learning", "Learning", html `<path d="M3 3v18h18"/><path d="m7 15 4-4 3 3 5-6"/>`],
519
- ]],
520
- ["Automation", [
521
- ["/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"/>`],
522
- ["/settings/integrations", "Integrations", html `<path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/>`],
523
- ]],
524
- ["Chat apps", [
525
- ["/settings/telegram", "Telegram", html ``],
526
- ["/settings/slack", "Slack", html ``],
527
- ["/settings/discord", "Discord", html ``],
528
- ["/settings/teams", "Teams", html ``],
529
- ]],
530
- ["Access and rules", [
531
- ["/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"/>`],
532
- ["/settings/passwordless", "Passwordless", html `<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>`],
533
- ["/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"/>`],
534
- ["/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"/>`],
535
- ["/settings/policy", "Policy", html `<path d="M9 12l2 2 4-4"/><rect x="4" y="3" width="16" height="18" rx="2"/>`],
536
- ["/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"/>`],
537
- ]],
538
- ["System", [
539
- ["/settings/monitoring", "Monitoring", html `<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>`],
540
- ["/settings/retention", "Retention", html `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`],
541
- ["/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"/>`],
542
- ["/settings/updates", "Updates", html `<path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/>`],
543
- ["/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"/>`],
544
- ["/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"/>`],
545
- ]],
546
- ];
547
- export const SETTINGS_TILE_ICONS = new Map(SETTINGS_GROUPS.flatMap(([, tiles]) => tiles.map(([href, , icon]) => [href, icon])));
548
- export const CHAT_APP_TILES = { "/settings/telegram": "telegram", "/settings/slack": "slack", "/settings/discord": "discord", "/settings/teams": "teams" };
549
- /**
550
- * The settings groups, with each chat app's state from what the server already holds: not set up,
551
- * connected, or getting alerts (only when that service was chosen, or is the only one).
552
- * A Telegram bot whose saved delivery state reports a problem says so instead. With no config folder
553
- * the state is unknown, so the tiles carry none.
554
- */
555
- export function settingsGroups(messaging, telegramFailing = false) {
556
- return SETTINGS_GROUPS.map(([title, tiles]) => ({
557
- title,
558
- tiles: tiles.map(([href, label]) => {
559
- const app = CHAT_APP_TILES[href];
560
- if (app === undefined)
561
- return { href, label };
562
- if (messaging === null)
563
- return { href, label, brand: app };
564
- if (!messaging.configured.includes(app))
565
- return { href, label, brand: app, status: { tone: "off", words: "Not set up" } };
566
- if (app === "telegram" && telegramFailing)
567
- return { href, label, brand: app, status: { tone: "warn", words: "Has a problem" } };
568
- return { href, label, brand: app, status: { tone: "ok", words: messaging.channel === app && !messaging.implicit ? "Gets alerts" : "Connected" } };
569
- }),
570
- }));
571
- }
572
- /** Light, dark or the device's choice, one tap each. Per browser (a cookie). */
573
- export function appearanceCard(csrf) {
574
- if (csrf === "")
575
- return html ``;
576
- const pinned = requestContext.getStore()?.theme ?? null;
577
- const current = pinned ?? "system";
578
- const option = (value, label) => html `<button type="submit" name="theme" value="${value}" class="theme-choice" aria-pressed="${String(current === value)}">${label}</button>`;
579
- return html `<section class="appearance" aria-labelledby="appearance-title"><h2 id="appearance-title">Appearance</h2>\
580
- ${postForm("/settings/appearance", html `${option("system", "Match device")}${option("light", "Light")}${option("dark", "Dark")}`, { attrs: { class: "theme-switch" } })}\
581
- <p class="meta">Saved in this browser.</p></section>`;
582
- }