toolroll 0.9.86 → 0.9.88

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 (173) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/dist/acp-check.d.ts +9 -0
  3. package/dist/acp-check.js +40 -28
  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/backup-ui.d.ts +5 -4
  10. package/dist/backup-ui.js +65 -32
  11. package/dist/browser/terminal.css +1 -1
  12. package/dist/browser/terminal.js +57 -54
  13. package/dist/browser/workspace.css +2 -2
  14. package/dist/browser/workspace.js +35 -70
  15. package/dist/browser-crew.js +1 -1
  16. package/dist/browser-workspace.d.ts +329 -183
  17. package/dist/browser-workspace.js +10 -9
  18. package/dist/chat-controls.d.ts +1 -1
  19. package/dist/chat-controls.js +1 -1
  20. package/dist/chat-settings-page.d.ts +29 -9
  21. package/dist/chat-settings-page.js +59 -28
  22. package/dist/check-levels-ui.d.ts +2 -1
  23. package/dist/check-levels-ui.js +67 -58
  24. package/dist/contracts/cli.d.ts +43 -0
  25. package/dist/contracts/cli.js +4 -0
  26. package/dist/contracts/console-api.d.ts +1 -1
  27. package/dist/contracts/console-api.js +1 -1
  28. package/dist/credentials-ui.d.ts +5 -3
  29. package/dist/credentials-ui.js +58 -31
  30. package/dist/design/kit.css +722 -0
  31. package/dist/design/kit.d.ts +208 -0
  32. package/dist/design/kit.js +177 -0
  33. package/dist/design/tokens.css +31 -1
  34. package/dist/discord-settings.js +8 -4
  35. package/dist/export-ui.d.ts +6 -4
  36. package/dist/export-ui.js +18 -8
  37. package/dist/flow-changed.d.ts +14 -0
  38. package/dist/flow-changed.js +53 -0
  39. package/dist/flow-gallery-ui.d.ts +41 -4
  40. package/dist/flow-gallery-ui.js +95 -63
  41. package/dist/flow-starters-ui.d.ts +3 -9
  42. package/dist/flow-starters-ui.js +12 -23
  43. package/dist/flow-unattended-words.d.ts +20 -0
  44. package/dist/flow-unattended-words.js +24 -0
  45. package/dist/flow-unattended.d.ts +9 -5
  46. package/dist/flow-unattended.js +45 -23
  47. package/dist/flows-cli.js +7 -3
  48. package/dist/flows-ui.d.ts +12 -6
  49. package/dist/flows-ui.js +141 -30
  50. package/dist/integrations-ui.d.ts +6 -4
  51. package/dist/integrations-ui.js +34 -40
  52. package/dist/integrations.d.ts +2 -2
  53. package/dist/integrations.js +4 -4
  54. package/dist/kits-ui.d.ts +8 -10
  55. package/dist/kits-ui.js +29 -33
  56. package/dist/kits.js +1 -1
  57. package/dist/knowledge-ui.js +76 -19
  58. package/dist/ledger-view.d.ts +2 -0
  59. package/dist/ledger-view.js +41 -38
  60. package/dist/models-ui.d.ts +8 -3
  61. package/dist/models-ui.js +82 -59
  62. package/dist/monitoring-ui.d.ts +4 -2
  63. package/dist/monitoring-ui.js +44 -13
  64. package/dist/needs-you.d.ts +1 -1
  65. package/dist/needs-you.js +8 -0
  66. package/dist/operate-remote-arguments.js +1 -0
  67. package/dist/operate-remote.js +1 -1
  68. package/dist/operate.d.ts +1 -1
  69. package/dist/operate.js +38 -4
  70. package/dist/passwordless.d.ts +1 -1
  71. package/dist/passwordless.js +1 -1
  72. package/dist/people-audit-ui.d.ts +2 -1
  73. package/dist/people-audit-ui.js +25 -23
  74. package/dist/policy-ui.d.ts +3 -2
  75. package/dist/policy-ui.js +27 -21
  76. package/dist/policy.d.ts +3 -0
  77. package/dist/policy.js +14 -1
  78. package/dist/project-delete-ui.d.ts +4 -2
  79. package/dist/project-delete-ui.js +135 -126
  80. package/dist/project-knowledge.d.ts +10 -10
  81. package/dist/publish.d.ts +11 -0
  82. package/dist/publish.js +16 -3
  83. package/dist/pull-request-ui.d.ts +2 -1
  84. package/dist/pull-request-ui.js +39 -28
  85. package/dist/recipe-ui.d.ts +4 -2
  86. package/dist/recipe-ui.js +14 -4
  87. package/dist/release-adapters.d.ts +3 -0
  88. package/dist/release-adapters.js +22 -2
  89. package/dist/release-cli.js +81 -9
  90. package/dist/release.d.ts +23 -0
  91. package/dist/release.js +46 -4
  92. package/dist/releases.js +1 -1
  93. package/dist/request-budget-ui.d.ts +4 -2
  94. package/dist/request-budget-ui.js +30 -19
  95. package/dist/retention-ui.d.ts +4 -3
  96. package/dist/retention-ui.js +15 -12
  97. package/dist/scope.d.ts +3 -0
  98. package/dist/scope.js +6 -0
  99. package/dist/serve.d.ts +0 -1
  100. package/dist/serve.js +51 -7
  101. package/dist/server/agents-computers.d.ts +9 -0
  102. package/dist/server/agents-computers.js +67 -0
  103. package/dist/server/chat.js +16 -16
  104. package/dist/server/chrome.d.ts +1 -1
  105. package/dist/server/chrome.js +19 -9
  106. package/dist/server/console.css +24 -12
  107. package/dist/server/flows.js +64 -36
  108. package/dist/server/home.d.ts +35 -0
  109. package/dist/server/home.js +104 -0
  110. package/dist/server/http.d.ts +3 -0
  111. package/dist/server/http.js +5 -0
  112. package/dist/server/kit-standalone.d.ts +22 -0
  113. package/dist/server/kit-standalone.js +70 -0
  114. package/dist/server/kit.d.ts +419 -0
  115. package/dist/server/kit.js +369 -0
  116. package/dist/server/pages.d.ts +2 -1
  117. package/dist/server/pages.js +213 -174
  118. package/dist/server/people-tokens.js +26 -85
  119. package/dist/server/render-history.d.ts +51 -0
  120. package/dist/server/render-history.js +228 -0
  121. package/dist/server/render-new-task.d.ts +29 -0
  122. package/dist/server/render-new-task.js +96 -0
  123. package/dist/server/render-next.d.ts +63 -0
  124. package/dist/server/render-next.js +109 -0
  125. package/dist/server/render-pages.d.ts +22 -190
  126. package/dist/server/render-pages.js +123 -575
  127. package/dist/server/render-people.d.ts +24 -3
  128. package/dist/server/render-people.js +93 -72
  129. package/dist/server/render-projects.d.ts +103 -0
  130. package/dist/server/render-projects.js +247 -0
  131. package/dist/server/render-results.d.ts +5 -24
  132. package/dist/server/render-results.js +48 -185
  133. package/dist/server/render-settings.d.ts +5 -74
  134. package/dist/server/render-settings.js +102 -493
  135. package/dist/server/render-tasks.d.ts +35 -5
  136. package/dist/server/render-tasks.js +114 -85
  137. package/dist/server/route-table.js +20 -5
  138. package/dist/server/settings.js +276 -255
  139. package/dist/server/task-views.d.ts +8 -0
  140. package/dist/server/task-views.js +85 -2
  141. package/dist/server/tasks.d.ts +8 -0
  142. package/dist/server/tasks.js +95 -40
  143. package/dist/server/terminals.js +1 -1
  144. package/dist/server/welcome.d.ts +22 -0
  145. package/dist/server/welcome.js +69 -0
  146. package/dist/settings-groups-ui.d.ts +279 -0
  147. package/dist/settings-groups-ui.js +615 -0
  148. package/dist/settings-kit.d.ts +42 -6
  149. package/dist/settings-kit.js +69 -7
  150. package/dist/setup-guide.d.ts +2 -2
  151. package/dist/skills-ui.js +59 -7
  152. package/dist/slack-settings.js +12 -5
  153. package/dist/spend-ui.d.ts +45 -26
  154. package/dist/spend-ui.js +124 -56
  155. package/dist/sso-ui.d.ts +5 -2
  156. package/dist/sso-ui.js +63 -31
  157. package/dist/storage-ui.d.ts +3 -2
  158. package/dist/storage-ui.js +56 -50
  159. package/dist/store.d.ts +112 -3
  160. package/dist/store.js +292 -20
  161. package/dist/surface.js +5 -1
  162. package/dist/teams-settings.d.ts +2 -1
  163. package/dist/teams-settings.js +24 -41
  164. package/dist/telegram-settings.d.ts +0 -4
  165. package/dist/telegram-settings.js +52 -25
  166. package/dist/toolroll-update-ui.d.ts +8 -4
  167. package/dist/toolroll-update-ui.js +55 -45
  168. package/dist/tools-ui.d.ts +5 -3
  169. package/dist/tools-ui.js +107 -42
  170. package/dist/work-index.js +11 -7
  171. package/dist/workspace-ui.d.ts +1 -1
  172. package/dist/workspace-ui.js +7 -4
  173. package/package.json +1 -1
@@ -32,7 +32,10 @@ import { authenticateAccount } from "../scope.js";
32
32
  import { budgetLabel, monthOf } from "../spend.js";
33
33
  import { PACKAGE_VERSION } from "../version.js";
34
34
  import { handlersOf } from './handler-registry.js';
35
- import { refuse, screen } from "./chrome.js";
35
+ import { refuse, screen, when } from "./chrome.js";
36
+ import { requestContext } from "./request-context.js";
37
+ import { btn, empty, infoLine, settingsSection } from "./kit.js";
38
+ import { approvalGroup, projectPicker, GROUP_PATHS, groupTitle, modeCeremonyHtml, modePageHtml, passwordlessHtml, HUB_ANCHOR_SCRIPT, pushScriptOf, settingsGroupPage, settingsSubPage } from "../settings-groups-ui.js";
36
39
  import { HANDOFF_STYLE, redirect, respond, safeReturn, sayTo, signInSpent, startedHere } from "./http.js";
37
40
  import { html, htmlString, joinHtml, postForm, reauth } from "../html.js";
38
41
  import { browserCaller, sessionWithinLifetime } from "./session.js";
@@ -51,7 +54,7 @@ import { checkSettingsHtml } from '../check-levels-ui.js';
51
54
  import { CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, suggestQuickCommand } from '../check-levels.js';
52
55
  import { checkoutPlan, cleanCheckouts, discardCheckout, previewDigest } from "../checkout-cleanup.js";
53
56
  import { approveSetup, previewSetup } from "../control-setup.js";
54
- import { connectionHtml, controlSetupHtml, setupPreviewHtml } from "../control-ui.js";
57
+ import { connectionHtml, connectionWords, controlSetupHtml, setupPreviewHtml } from "../control-ui.js";
55
58
  import { hasDisguisedText } from '../decision.js';
56
59
  import { checkDiscordCredentials, clearDiscordCredentials, DiscordError, loadDiscordCredentials, saveDiscordCredentials } from "../discord-api.js";
57
60
  import { discordSettingsHtml } from "../discord-settings.js";
@@ -59,9 +62,7 @@ import { dataExportHtml } from "../export-ui.js";
59
62
  import { buildExport, exportZip } from "../export.js";
60
63
  import { firstResultWords } from "../first-run.js";
61
64
  import { readEmailSettings, saveEmailSettings, sendingAccount, sendThroughServer } from "../flow-actions.js";
62
- import { galleryHtml } from "../flow-gallery-ui.js";
63
- import { startersHtml } from "../flow-starters-ui.js";
64
- import { starterOf, startersFor, switchOnStarter } from "../flow-starters.js";
65
+ import { starterOf, switchOnStarter } from "../flow-starters.js";
65
66
  import { disconnectGoogle, googleConnected, googleConsent, readGoogleMail, saveGoogleClient } from "../google-mail.js";
66
67
  import { integrationsHtml } from "../integrations-ui.js";
67
68
  import { setYouiyouxIntake } from "../youiyoux-intake.js";
@@ -83,7 +84,7 @@ import { monitoringChange, readMonitoring, saveMonitoring } from "../monitoring-
83
84
  import { monitoringHtml, signingSecretHtml } from "../monitoring-ui.js";
84
85
  import { openRouterPickerScript } from "../openrouter-models.js";
85
86
  import { policyHtml } from "../policy-ui.js";
86
- import { checkPolicy, parseList, policyParts } from "../policy.js";
87
+ import { checkPolicy, chosenProviders, parseList, policyParts } from "../policy.js";
87
88
  import { parseProjectConcurrency, saveProjectConcurrency } from "../project-concurrency.js";
88
89
  import { MAX_NETWORK_HOSTS, normalizeHost } from "../egress.js";
89
90
  import { isWritesMode } from "../writes-mode.js";
@@ -113,9 +114,10 @@ import { addProjectInstructions, ASSISTANTS, detectPreparation, modelChoices, pr
113
114
  import { skillsHtml, skillsScript } from "../skills-ui.js";
114
115
  import { checkSlackCredentials, clearSlackCredentials, loadSlackCredentials, saveSlackCredentials, SLACK_MANIFEST, SlackError } from "../slack-api.js";
115
116
  import { slackSettingsHtml } from "../slack-settings.js";
116
- import { spendHtml, spendSettingsHtml } from "../spend-ui.js";
117
+ import { spendHtml, spendScript } from "../spend-ui.js";
118
+ import { approvalFactsNow } from "./kit.js";
117
119
  import { spendReport, spendReportCsv, spendRows } from "../spend-report.js";
118
- import { limitsHtml, limitsView } from "../limits-ui.js";
120
+ import { limitsView } from "../limits-ui.js";
119
121
  import { budgetStates, monthNamed, spendItems, usd as spendUsd, subagentNames as subagentNamesOf } from "../spend.js";
120
122
  import { removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from "../sso-settings.js";
121
123
  import { ssoSettingsHtml } from "../sso-ui.js";
@@ -130,18 +132,19 @@ import { LIVE_EVERY } from "../backup-ui.js";
130
132
  import { checkTeamsCredentials, clearTeamsCredentials, loadTeamsCredentials, saveTeamsCredentials, TeamsError } from "../teams-api.js";
131
133
  import { teamsSettingsHtml } from "../teams-settings.js";
132
134
  import { telegramSettingsHtml } from "../telegram-settings.js";
133
- import { hashPairingCode, loadBotToken, mintPairingCode, PAIRING_TTL_MS, redactToken, saveBotToken } from "../telegram.js";
135
+ import { hashPairingCode, loadBotToken, mintPairingCode, PAIRING_TTL_MS, redactToken, saveBotToken, TOKEN_ENV } from "../telegram.js";
134
136
  import { newerThan, updatesHtml, updatesScript, updateStepsHtml } from "../toolroll-update-ui.js";
135
137
  import { abandonRuntimeUpdate, currentRuntime, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal } from "../toolroll-update.js";
136
138
  import { TOOLS_PROJECT_SCRIPT, toolsHtml, toolsProjectPicker } from "../tools-ui.js";
137
139
  import { effectivePrimary, isMessagingChannel, savePrimary } from "../webhooks.js";
138
140
  import { personChip, ROLE_TITLES } from "./chrome.js";
139
141
  import { chatReturnWithSaid, goOutside } from "./http.js";
140
- import { leadSettingsHtml, notificationProjects, SETTINGS_AUTOSAVE_SCRIPT, settingsPage, settingsWorkers, telegramDeliveryWords, telegramTrouble } from "./render-settings.js";
142
+ import { leadSettingsHtml, notificationProjects, SETTINGS_AUTOSAVE_SCRIPT, settingsWorkers, telegramDeliveryWords, telegramTrouble } from "./render-settings.js";
141
143
  export function createSettingsHandlers(runtime) {
142
144
  const { identities, store, providerHome, managedRepos, consoleProjects, sendScreen, chromeFor, connectionCheck, connectionKnown, lastChecks, checkRelease, clock, modelCatalog, modelSeams, toolsViewOf, codexServers, projectViewOf, sessions, restricted, options, integrations, consoleOrigin, evidenceRoot, storagePool, visible, ssoSettings, checkLocalAgents, chatKeyFor, chatCatalog, chatCatalogKnown, leadWords, agentSignInCommand, settingsUpdates, phoneSetup, authenticateApprover, authenticateCredential, deletedRepos, projectOf, providerFor, admissionList, toolHome, connectVisits, mintApprovalNonce, consumeApprovalNonce, googleVisits } = runtime;
143
- // What agent work cost, by month: API dollars, plan usage and unknown cost apart (spend-report.ts). An instance
144
- // operator's page. Its limits show read-only; they change on Settings → Spend.
145
+ // What agent work cost, by month: API dollars, plan usage and unknown cost apart (spend-report.ts), with this
146
+ // month's limits first. An instance operator's page. A limit opens its form in place (?edit=<scope:key>) and saves
147
+ // to /spend/budget with a step-up.
145
148
  async function spendPage(ctx) {
146
149
  const { url, who, response, now } = ctx;
147
150
  const current = monthOf(now);
@@ -156,67 +159,48 @@ export function createSettingsHandlers(runtime) {
156
159
  return void response.end(spendReportCsv(rows, subagentNames));
157
160
  }
158
161
  const shift = (by) => { const at = new Date(`${month.name}-01T00:00:00.000Z`); at.setUTCMonth(at.getUTCMonth() + by); return at.toISOString().slice(0, 7); };
159
- // Limits read what the budget gate counts today (spend.ts spendItems), for the current month.
160
- const budgets = month.name === current.name ? budgetStates(store.budgets(), spendItems(store.handle, current.from, current.to)) : null;
161
- const windows = month.name !== current.name ? html `` : limitsHtml(limitsView(store.providerLimits(), [], { project: projectName, subagent: id => subagentNames.get(id) ?? `Subagent ${id}` }, now));
162
- const view = { month: month.name, previous: shift(-1), next: month.name === current.name ? null : shift(1), report: spendReport(rows), budgets, windows, subagentNames };
163
- return sendScreen(response, 200, screen("Spend", spendHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }), { chrome: chromeFor(null, "spend") }));
162
+ // Limits read what the budget gate counts today (spend.ts spendItems): always this month's, whichever month is shown.
163
+ const items = spendItems(store.handle, current.from, current.to);
164
+ const saved = store.budgets();
165
+ const budgets = budgetStates(saved, items);
166
+ const has = (scope, key) => saved.some(one => one.scope === scope && one.key === key);
167
+ // Everything and each project with no limit yet, with what it spent this month (counted as a limit would count it).
168
+ const spentFor = (scope, key) => budgetStates([{ id: 0, scope, key, limitMicrousd: 1, hardStop: false, updatedBy: "", updatedAt: "" }], items)[0].spentMicrousd;
169
+ const unlimited = [
170
+ ...(has("installation", "*") ? [] : [{ target: "installation:*", label: "Everything", budget: null, spentMicrousd: spentFor("installation", "*") }]),
171
+ ...consoleProjects().filter(repo => !has("project", repo)).map(repo => ({ target: `project:${repo}`, label: projectName(repo), budget: null, spentMicrousd: spentFor("project", repo) })),
172
+ ];
173
+ const targets = [
174
+ ...store.accountFacts().filter(one => one.revokedAt === null && !has("person", one.name)).map(one => ({ value: `person:${one.name}`, label: one.name, group: "People" })),
175
+ ...store.subagents([...new Set([...managedRepos(), ...store.knownRepos()])]).filter(one => !has("subagent", String(one.id)))
176
+ .map(one => ({ value: `subagent:${one.id}`, label: `${subagentNames.get(one.id) ?? one.handle} (${projectName(one.repo)})`, group: "Subagents" })),
177
+ ];
178
+ const editing = url.searchParams.get("edit");
179
+ const stop = url.searchParams.get("stop");
180
+ const edit = editing === null || editing === "" ? null : {
181
+ target: editing, usd: url.searchParams.get("usd"), stop: stop === null ? null : stop === "1",
182
+ fieldError: url.searchParams.get("field") === "usd" ? url.searchParams.get("problem") : null,
183
+ problem: url.searchParams.get("field") === "usd" ? null : url.searchParams.get("problem"),
184
+ };
185
+ const windows = limitsView(store.providerLimits(), [], { project: projectName, subagent: id => subagentNames.get(id) ?? `Subagent ${id}` }, now);
186
+ const view = { month: month.name, previous: shift(-1), next: month.name === current.name ? null : shift(1), current: current.name, report: spendReport(rows),
187
+ budgets, unlimited, targets, windows, subagentNames, edit, facts: approvalFactsNow() };
188
+ // A refusal for the open form shows in the form; anything else under the limits' heading.
189
+ const said = { said: url.searchParams.get("said"), problem: edit === null ? url.searchParams.get("problem") : null };
190
+ return sendScreen(response, 200, screen("Spend", spendHtml(view, said), { chrome: chromeFor(null, "spend"), functional: { script: spendScript() } }));
164
191
  }
165
192
  async function modePage(ctx) {
166
- const { who, response, now, project } = ctx;
193
+ const { url, who, response, now, project } = ctx;
167
194
  if (project === null) {
168
- return refuse(response, who, 409, "open a project first — a mode is signed per repository");
195
+ return refuse(response, who, 409, "Open a project first: a mode is signed for one project.", "/projects");
169
196
  }
170
197
  const live = store.activeMode(project, now);
171
198
  const liveTerms = live === null ? null : modeTermsFromJson(live.termsJson);
172
- const hasGrant = store.hasMergeCapableGrant(project, now);
173
- const current = live === null || liveTerms === null
174
- ? html `<div class="card"><h2>Locked</h2><p class="meta">No mode is signed — every action asks for your password. That is the default.</p></div>`
175
- : joinHtml([
176
- html `<div class="card">`,
177
- html `<h2 style="margin-top:0">${live.name} <span class="meta">signed by ${personChip(live.signedBy)}</span></h2>`,
178
- joinHtml(modeWords(liveTerms)
179
- .map(words => html `<p class="row">${words}</p>`), "\n"),
180
- who.role === "approver"
181
- ? postForm("/mode/revoke", html `\
182
- <button type="submit">End this mode now</button>\
183
- <span class="meta">one click — every act it covered falls back to its own ceremony</span>`, { attrs: { class: "row" } })
184
- : "",
185
- html `</div>`,
186
- ], "\n");
187
- const signForm = who.role !== "approver"
188
- ? ""
189
- : joinHtml([
190
- html `<div class="card">`,
191
- html `<h2 style="margin-top:0">${live === null ? "sign a mode" : "replace it — a renewal is a new signature"}</h2>`,
192
- postForm("/mode/confirm", joinHtml([
193
- "",
194
- html `<label>Preset<select name="name"><option value="standard">standard — approvals stay with me</option><option value="hands-off">hands-off — your filings auto-approve, full permissions</option></select></label>`,
195
- html `<label>Days <span class="meta">(1–${MODE_MAX_DAYS})</span><input type="number" name="days" value="1" min="1" max="${MODE_MAX_DAYS}"></label>`,
196
- html `<label>Merges<select name="publication">\
197
- <option value="notify">wait for me — even under a merge grant</option>\
198
- ${hasGrant ? html `<option value="automerge">merge themselves when CI is green on the exact commit</option>` : ""}\
199
- </select></label>\
200
- ${hasGrant ? "" : html `<span class="meta">self-merging needs a merge-capable publication grant first</span>`}`,
201
- html `<label>My filings<select name="auto-approve">\
202
- <option value="">the preset's default (standard: wait for approval; hands-off: auto-approve)</option>\
203
- <option value="1">approve the moment I file them</option>\
204
- <option value="0">wait for their own approval</option>\
205
- </select></label>`,
206
- html `<label>Planner approval<select name="plan-auto">\
207
- <option value="0">wait for my approval</option>\
208
- <option value="1">auto-approve plans that preserve my filed contract</option>\
209
- </select><span class="meta">Requires automatic filing approval. File the goal, paths, and acceptance criteria upfront. Changed scope and unanswered questions still pause.</span></label>`,
210
- html `<label>From my chat app<select name="chat-approve">\
211
- <option value="">approve plans and merges here, with my password (the default)</option>\
212
- <option value="1">approve plans and merge ready pull requests from my paired chat, two taps each</option>\
213
- </select><span class="meta">Plans that widen permissions, exceed the attempt cap or touch protected paths still open Toolroll.</span></label>`,
214
- html `<button type="submit">Read the full terms</button>`,
215
- "",
216
- ], "\n")),
217
- html `</div>`,
218
- ], "\n");
219
- return sendScreen(response, 200, screen("mode", joinHtml([html `<h1>Operating mode</h1><p>Authorize this project's automatic approvals once, for up to ${MODE_MAX_DAYS} days. Select the touchpoints below; the signature covers the exact choices. Existing modes keep their original terms.</p><p class="meta">Scope changes, agent questions, and requested revisions still require your attention. Automatic merging also needs a publication grant.</p>`, current, signForm], "\n"), { chrome: chromeFor(project, "mode") }));
199
+ return sendScreen(response, 200, screen("Operating mode", modePageHtml({
200
+ project, name: projectName(project), maxDays: MODE_MAX_DAYS, approver: who.role === "approver", hasMergeGrant: store.hasMergeCapableGrant(project, now),
201
+ said: url.searchParams.get("said"), problem: url.searchParams.get("problem"),
202
+ live: live === null || liveTerms === null ? null : { name: live.name, signedBy: live.signedBy, until: when(live.absoluteExpiry), words: modeWords(liveTerms) },
203
+ }), { chrome: chromeFor(project, "mode") }));
220
204
  }
221
205
  const controlPage = (view) => async (ctx) => {
222
206
  const { url, who, response, project } = ctx;
@@ -233,7 +217,7 @@ ${hasGrant ? "" : html `<span class="meta">self-merging needs a merge-capable pu
233
217
  const command = asked === "claude" ? "claude auth login" : asked === "codex" ? "codex login" : asked === "gemini" ? "gemini" : null;
234
218
  return sendScreen(response, 200, screen(`${ASSISTANTS[asked].name} connection`, html `<h1>${ASSISTANTS[asked].name} ${connection?.state === "connected" ? "is connected" : "connection"}</h1>\
235
219
  ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On the computer running Toolroll, sign in with:</p><pre class="recap">${command}</pre>`}\
236
- <p><a href="/settings#providers">Manage API keys and authentication mode</a></p><input type="hidden" name="resume-task" value="${task}">`, { chrome: chromeFor(project, "settings") }));
220
+ <p><a href="/settings/agents#providers">Manage API keys and how each agent is paid</a></p><input type="hidden" name="resume-task" value="${task}">`, { chrome: chromeFor(project, "settings") }));
237
221
  }
238
222
  const setup = store.liveWorktreeSetup(project);
239
223
  const preparation = detectPreparation(project);
@@ -286,7 +270,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
286
270
  const { response, posted } = ctx;
287
271
  const body = readForm(posted, CONSOLE_FORMS.connectionCheck);
288
272
  const provider = body.get("provider") ?? "";
289
- const back = safeReturn(body.get("return") ?? "/settings#providers");
273
+ const back = safeReturn(body.get("return") ?? "/settings/agents#providers");
290
274
  if (!isProviderId(provider))
291
275
  return redirect(response, back);
292
276
  await connectionCheck(provider, true).catch(() => undefined);
@@ -308,7 +292,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
308
292
  // Only an instance operator changes models (docs/plans/spend-dashboard.md D4); a preset or a switch is previewed
309
293
  // here, read-only, and applied by its own password-checked POST.
310
294
  const view = modelsView(who.via === "cookie" && store.isInstanceOperator(who.name), who.via === "cookie" ? who.session.csrf : "", now, url.searchParams.get("said"), url.searchParams.get("problem"), url.searchParams);
311
- return sendScreen(response, 200, screen("Models", html `<p><a href="/settings">Settings</a></p><h1>Models</h1>${modelsHtml(view)}`, { chrome: chromeFor(project, "settings"), functional: { script: modelsScript() } }));
295
+ return sendScreen(response, 200, screen("Models", modelsHtml({ ...view, facts: approvalFactsNow() }), { chrome: chromeFor(project, "settings"), functional: { script: modelsScript() } }));
312
296
  }
313
297
  async function skillsPage(ctx) {
314
298
  const { url, who, response, project } = ctx;
@@ -316,7 +300,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
316
300
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
317
301
  if (chosen && !projects.includes(chosen))
318
302
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
319
- const selector = projects.length > 1 ? html `<form class="skills" method="get"><label>Project<select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? ' selected' : ''}>${p.split('/').at(-1) ?? p}</option>`)}</select></label><button>Show project</button></form>` : '';
303
+ const selector = projectPicker("/settings/skills", projects.map(repo => ({ repo, name: projectName(repo) })), chosen);
320
304
  let content = html `<p>Add a project to manage its skills.</p>`;
321
305
  if (chosen) {
322
306
  try {
@@ -327,7 +311,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
327
311
  content = html `<p class="problem" role="alert">${error instanceof Error ? error.message : 'Skills are unavailable. Reload to retry.'}</p>`;
328
312
  }
329
313
  }
330
- return sendScreen(response, 200, screen('Skills', html `<p><a href="/settings">Settings</a></p><h1>Skills</h1>${url.searchParams.get('saved') === '1' ? html `<p role="status">Saved.</p>` : ''}${selector}${content}`, { chrome: chromeFor(chosen || project, 'settings'), functional: { script: skillsScript() } }));
314
+ return sendScreen(response, 200, screen('Skills', settingsSubPage("agents", "Skills", html `${url.searchParams.get('saved') === '1' ? html `<p role="status">Saved.</p>` : ''}${selector}${content}`), { chrome: chromeFor(chosen || project, 'settings'), functional: { script: skillsScript() } }));
331
315
  }
332
316
  async function toolsPage(ctx) {
333
317
  const { url, who, response, project } = ctx;
@@ -352,7 +336,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
352
336
  content = html `<p class="problem" role="alert">Tools are unavailable. Reload to retry.</p>`;
353
337
  }
354
338
  }
355
- return sendScreen(response, 200, screen("Tools", html `<p><a href="/settings">Settings</a></p><h1>Tools</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings"), functional: { script: SETTINGS_AUTOSAVE_SCRIPT + TOOLS_PROJECT_SCRIPT } }));
339
+ return sendScreen(response, 200, screen("Tools", settingsSubPage("agents", "Tools", html `${selector}${content}`), { chrome: chromeFor(chosen || project, "settings"), functional: { script: SETTINGS_AUTOSAVE_SCRIPT + TOOLS_PROJECT_SCRIPT } }));
356
340
  }
357
341
  // Settings → Project: what Toolroll holds for a project; an instance operator deletes it here.
358
342
  async function projectPage(ctx) {
@@ -362,23 +346,38 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
362
346
  const chosen = asked ?? (project != null && projects.includes(project) ? project : projects[0] ?? "");
363
347
  if (chosen && !projects.includes(chosen))
364
348
  return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
365
- const selector = projects.length > 1 ? html `<form class="approval-project" method="get" action="/settings/project"><label>Project<select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? " selected" : ""}>${projectName(p)}</option>`)}</select></label><button>Show</button></form>` : "";
349
+ const selector = projectPicker("/settings/project", projects.map(repo => ({ repo, name: projectName(repo) })), chosen);
366
350
  const notice = { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") };
367
- const content = chosen === "" ? html `${notice.said ? html `<p role="status">${notice.said}</p>` : ""}<p>No projects yet.</p>` : (projectSettingsHtml({ ...projectViewOf(chosen, who), remoteUrl: store.projectRemote(chosen) }, notice));
368
- return sendScreen(response, 200, screen("Project", html `<p><a href="/settings">Settings</a></p><h1>Project</h1>${selector}${content}`, { chrome: chromeFor(chosen || null, "settings") }));
351
+ const content = chosen === "" ? html `${notice.said ? infoLine(notice.said) : ""}${empty({ title: "No projects yet.", action: btn("Add a project", { sm: true, href: "/projects" }) })}` : (projectSettingsHtml({ ...projectViewOf(chosen, who), remoteUrl: store.projectRemote(chosen) }, notice));
352
+ return sendScreen(response, 200, screen("Project", settingsSubPage("projects", "Project", html `${selector}${content}`), { chrome: chromeFor(chosen || null, "settings") }));
369
353
  }
370
- // v102: Settings → Approval rules, a project's separation of duties. Anyone who sees the project reads them; an instance operator sets them.
354
+ // v102: Settings → Approvals and safety: the defaults for new tasks, a project's separation of duties and release
355
+ // approval, and the way to its operating mode. Anyone who sees the project reads them; each form's own route decides
356
+ // who may change it (an approver for the defaults and releases, an instance operator for the rules).
371
357
  async function approvalPage(ctx) {
372
- const { url, who, response, project } = ctx;
358
+ const { url, who, response, project, now } = ctx;
373
359
  const projects = consoleProjects();
374
360
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
375
361
  if (chosen && !projects.includes(chosen))
376
362
  return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
377
- const selector = projects.length > 1 ? html `<form class="approval-project" method="get" action="/settings/approval"><label>Project<select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? " selected" : ""}>${projectName(p)}</option>`)}</select></label><button>Show</button></form>` : "";
378
363
  const approvers = chosen === "" ? 0 : store.listApprovers().filter(one => { const account = store.accountOf(one.name); return account !== null && account.revokedAt === null && account.role === "approver" && store.accountCanAccess(one.name, chosen); }).length;
379
364
  const release = chosen === "" ? null : releaseApprovalSetting(store, chosen);
380
- const content = chosen === "" ? html `<p>Add a project to set its approval rules.</p>` : html `${approvalRulesHtml({ repo: chosen, name: projectName(chosen), rules: store.approvalRules(chosen), canChange: who.via === "cookie" && store.isInstanceOperator(who.name), approvers }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}${releaseApprovalHtml({ repo: chosen, auto: release.auto, setBy: release.setBy, at: release.at, canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, chosen) })}`;
381
- return sendScreen(response, 200, screen("Approval rules", html `<p><a href="/settings">Settings</a></p><h1>Approval rules</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings") }));
365
+ const notice = { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") };
366
+ const csrf = who.via === "cookie" ? who.session.csrf : "";
367
+ const rules = chosen === "" ? html `` : html `${approvalRulesHtml({ repo: chosen, name: projectName(chosen), rules: store.approvalRules(chosen), canChange: who.via === "cookie" && store.isInstanceOperator(who.name), approvers }, notice)}${releaseApprovalHtml({ repo: chosen, auto: release.auto, setBy: release.setBy, at: release.at, canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, chosen) })}`;
368
+ // The installation-wide defaults are not a project-limited account's to see or set.
369
+ const limited = restricted();
370
+ const permission = store.permissionDefault(), quality = store.qualityDefault();
371
+ const changed = (at, by) => at === null ? null : `Changed ${when(at)}${by === null ? "" : ` by ${by}`}`;
372
+ const live = chosen === "" ? null : store.activeMode(chosen, now);
373
+ const page = approvalGroup({
374
+ facts: { said: null, problem: null,
375
+ permission: limited ? null : { mode: permission.mode, canManage: who.role === "approver" && csrf !== "", changed: changed(permission.updatedAt, permission.updatedBy) },
376
+ quality: limited ? null : { mode: quality.mode, canManage: who.role === "approver" && csrf !== "", changed: changed(quality.updatedAt, quality.updatedBy) } },
377
+ flags: {}, projects: projects.map(repo => ({ repo, name: projectName(repo) })), chosen, rules,
378
+ mode: chosen === "" ? "Open a project to sign one" : live === null ? `${projectName(chosen)}: locked, approvals stay with people` : `${projectName(chosen)}: ${live.name} until ${when(live.absoluteExpiry)}`,
379
+ });
380
+ return sendScreen(response, 200, screen("Approvals and safety", page, { chrome: chromeFor(chosen || project, "settings") }));
382
381
  }
383
382
  // Sprint 8: Settings → Policy, the organisation policy and its history. Anyone signed in reads it; an instance operator changes it.
384
383
  async function policyPage(ctx) {
@@ -386,24 +385,25 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
386
385
  const repos = consoleProjects();
387
386
  const toolNames = [...new Set(repos.flatMap(repo => store.projectTools(repo).map(one => one.name)))].sort();
388
387
  const view = { policy: store.orgPolicy(), history: store.policyHistory(50), canChange: who.via === "cookie" && store.isInstanceOperator(who.name), toolNames };
389
- return sendScreen(response, 200, screen("Policy", html `<p><a href="/settings">Settings</a></p><h1>Policy</h1>${(policyHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
388
+ return sendScreen(response, 200, screen("Policy", settingsSubPage("approval", "Policy", html `${(policyHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`), { chrome: chromeFor(project, "settings") }));
390
389
  }
391
- // Settings → Flows: the starter flows, each switched on with one yes. ?starter= marks the one a task's
392
- // "Do this every time…" asked about.
390
+ // Settings → Flows: the starter flows live under New flow → Starter flows now. This address (and a task's "Do this
391
+ // every time…", ?starter=) lands there with the project, the starter asked about and how a switch went.
393
392
  async function flowsPage(ctx) {
394
393
  const { url, who, response, project } = ctx;
395
394
  const projects = consoleProjects();
396
395
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
397
396
  if (chosen && !projects.includes(chosen))
398
397
  return refuse(response, who, 403, "That project is outside your access.", "/settings");
399
- // Every template, beside the starters: Use this opens its page for this project.
400
- const content = chosen === "" ? html `<p>Add a project to switch on starter flows.</p>` : html `${(startersHtml({
401
- repo: chosen, projects: projects.map(path => ({ path, name: projectName(path) })), starters: startersFor(store, chosen), csrf: who.via === "cookie" ? who.session.csrf : "",
402
- canSwitch: who.via === "cookie" && who.role === "approver" && !store.isDemo(), suggested: starterOf(url.searchParams.get("starter") ?? "")?.id ?? null,
403
- said: url.searchParams.get("said"), problem: url.searchParams.get("problem")
404
- }))}\
405
- <h2 class="gallery-heading">Templates</h2>${(galleryHtml({ repo: chosen, canUse: who.via === "cookie" && who.role === "approver" && !store.isDemo(), connections: connectionsOf(store, chosen) }))}`;
406
- return sendScreen(response, 200, screen("Flows", html `<p><a href="/settings">Settings</a></p><h1>Flows</h1>${content}`, { chrome: chromeFor(chosen || project, "settings") }));
398
+ const query = new URLSearchParams({ group: "starters" });
399
+ if (chosen)
400
+ query.set("repo", chosen);
401
+ for (const key of ["starter", "said", "problem"]) {
402
+ const value = url.searchParams.get(key);
403
+ if (value !== null)
404
+ query.set(key, value.slice(0, 400));
405
+ }
406
+ return redirect(response, `/flows/new?${query.toString()}`);
407
407
  }
408
408
  // Settings → Integrations: which integrations work. The list is the last checks; a render never waits on one.
409
409
  async function integrationsPage(ctx) {
@@ -412,7 +412,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
412
412
  if (!options.configDir)
413
413
  return refuse(response, who, 403, "An installation approver sees integrations.", "/settings");
414
414
  const list = integrations.list();
415
- return sendScreen(response, 200, screen("Integrations", html `<p><a href="/settings">Settings</a></p><h1>Integrations</h1>${(integrationsHtml(list, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem"), checking: integrations.checking, secrets: toolSecretStorage() }))}`, { chrome: chromeFor(project, "settings") }));
415
+ return sendScreen(response, 200, screen("Integrations", settingsSubPage("system", "Integrations", html `${(integrationsHtml(list, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem"), checking: integrations.checking, secrets: toolSecretStorage() }))}`), { chrome: chromeFor(project, "settings") }));
416
416
  }
417
417
  // v104: Settings → Monitoring, where the audit stream and traces go. An instance operator's page.
418
418
  async function monitoringPage(ctx) {
@@ -421,7 +421,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
421
421
  if (!options.configDir)
422
422
  return refuse(response, who, 403, "An instance operator sets up monitoring.", "/settings");
423
423
  const view = { settings: readMonitoring(options.configDir), status: store.monitoringStatus(), head: store.ledgerHeadId(), origin: consoleOrigin(request.headers.host) };
424
- return sendScreen(response, 200, screen("Monitoring", html `<p><a href="/settings">Settings</a></p><h1>Monitoring</h1>${(monitoringHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
424
+ return sendScreen(response, 200, screen("Monitoring", settingsSubPage("system", "Monitoring", html `${(monitoringHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`), { chrome: chromeFor(project, "settings") }));
425
425
  }
426
426
  // Settings → Updates: the installed version, three ways to update, the update's steps live, then What's new. An instance operator's page.
427
427
  async function updatesPage(ctx) {
@@ -440,14 +440,14 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
440
440
  const checksOff = updateChecksOff(process.env, options.configDir ?? dirname(databaseFile)).off;
441
441
  const known = lastChecks.known("release")?.value ?? null;
442
442
  const view = { current, latest: active ? { version: current } : known ?? (checksOff ? { off: true } : { pending: true }), method: options.updates?.method ?? installMethod(), journal: status.journal, running: status.running, whatsNew: status.whatsNew, rollbackTo: status.lastUpdate?.to === current ? status.lastUpdate.from : null, csrf: who.session.csrf };
443
- return sendScreen(response, 200, screen("Updates", html `<p><a href="/settings">Settings</a></p><h1>Updates</h1>${(updatesHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings"), ...(active ? { functional: { script: updatesScript(), fetches: true } } : {}) }));
443
+ return sendScreen(response, 200, screen("Updates", settingsSubPage("system", "Updates", html `${(updatesHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`), { chrome: chromeFor(project, "settings"), ...(active ? { functional: { script: updatesScript(), fetches: true } } : {}) }));
444
444
  }
445
445
  // v105: Settings → Retention, how long each kind of data is kept. An instance operator's page.
446
446
  async function retentionPage(ctx) {
447
447
  const { url, response, now, project } = ctx;
448
448
  const who = browserCaller(ctx.who);
449
449
  const view = { periods: store.retentionPeriods(), chosen: Object.keys(store.retentionChosen()), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
450
- return sendScreen(response, 200, screen("Retention", html `<p><a href="/settings">Settings</a></p><h1>Retention</h1>${(retentionHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
450
+ return sendScreen(response, 200, screen("Retention", settingsSubPage("system", "Retention", html `${(retentionHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`), { chrome: chromeFor(project, "settings") }));
451
451
  }
452
452
  // Settings → Storage: what task checkouts use, cleaning up now (the preview behind the password), and when a
453
453
  // finished task's clean checkout goes by itself. An instance operator's page.
@@ -460,13 +460,13 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
460
460
  // The last measurement (docs/plans/get-side-effects.md): the background checks measure the checkouts, and so do
461
461
  // Check now and every clean-up; opening the page runs no du or git.
462
462
  const measured = lastChecks.known("storage");
463
- const check = postForm("/settings/storage/check", html `<button type="submit" class="secondary">Check now</button>`, { attrs: { class: "inline" } });
463
+ const check = postForm("/settings/storage/check", btn("Check now", { sm: true, type: "submit" }));
464
464
  if (measured === null) {
465
- return sendScreen(response, 200, screen("Storage", html `<p><a href="/settings">Settings</a></p><h1>Storage</h1><section class="storage"><div class="card storage-state"><h2>Checkouts not measured yet</h2>${check}</div></section>`, { chrome: chromeFor(project, "settings") }));
465
+ return sendScreen(response, 200, screen("Storage", settingsSubPage("system", "Storage", settingsSection({ id: "storage", title: "Checkouts not measured yet", lead: "Toolroll measures the checkouts in the background. Check now measures them at once.", body: check })), { chrome: chromeFor(project, "settings") }));
466
466
  }
467
467
  const plan = { ...measured.value, cleanup: store.checkoutCleanup() };
468
468
  const sweep = { last: lastSweep(store), off: storageSweepOff(), schedule: sweepSchedule(store, now) };
469
- return sendScreen(response, 200, screen("Storage", html `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${(storageHtml({ plan, csrf: who.session.csrf, sweep, landed: landedSummary(store), measured: html `<p class="meta">Measured ${new Date(measured.at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })} ${check}</p>` }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
469
+ return sendScreen(response, 200, screen("Storage", settingsSubPage("system", "Storage", html `${(storageHtml({ plan, csrf: who.session.csrf, sweep, landed: landedSummary(store), measured: html `<div class="k-acts"><span class="k-note">Measured ${new Date(measured.at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</span>${check}</div>` }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`), { chrome: chromeFor(project, "settings") }));
470
470
  }
471
471
  // Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
472
472
  // exactly what turning on would do, or what to fix first.
@@ -482,7 +482,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
482
482
  const check = publishing.on || !canChange ? null : lastChecks.known(`publishing:${repo}`)?.value ?? "unchecked";
483
483
  const body = (pullRequestSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange, publishing, check,
484
484
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }));
485
- return sendScreen(response, 200, screen("Pull requests", html `<p><a href="/projects">Projects</a></p><h1>Pull requests</h1>${body}`, { chrome: chromeFor(project, "projects") }));
485
+ return sendScreen(response, 200, screen("Pull requests", settingsSubPage("projects", "Pull requests", html `${body}`), { chrome: chromeFor(project, "projects") }));
486
486
  }
487
487
  // Settings → Projects → Checks: how much checks after each build, and the quick command beside the full one.
488
488
  async function checksPage(ctx) {
@@ -496,7 +496,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
496
496
  level: projectCheckLevel(store, repo).level, full: store.liveVerifyCommand(repo), quick, suggestion: quick === null ? suggestQuickCommand(repo) : null,
497
497
  review: (({ on, source }) => ({ on, source }))(store.reviewSwitch(repo, clock())), batch: { on: projectBatchChecks(store, repo).on },
498
498
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }));
499
- return sendScreen(response, 200, screen("Checks", html `<p><a href="/projects">Projects</a></p><h1>Checks</h1>${body}`, { chrome: chromeFor(project, "projects") }));
499
+ return sendScreen(response, 200, screen("Checks", settingsSubPage("projects", "Checks", html `${body}`), { chrome: chromeFor(project, "projects") }));
500
500
  }
501
501
  // Sprint 8: Settings → Backups, how the last backup went and the schedule. An instance operator's page.
502
502
  async function backupsPage(ctx) {
@@ -509,13 +509,13 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
509
509
  const live = liveBackupState(store);
510
510
  const view = { settings, folder: backupFolderOf(settings, databaseFile), defaultFolder: defaultBackupFolder(databaseFile), runs: store.backupRuns(20), csrf: who.session.csrf, now,
511
511
  live: { state: live, folder: liveFolderOf(live.settings, databaseFile), defaultFolder: defaultLiveFolder(databaseFile) } };
512
- return sendScreen(response, 200, screen("Backups", html `<p><a href="/settings">Settings</a></p><h1>Backups</h1>${(backupHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
512
+ return sendScreen(response, 200, screen("Backups", settingsSubPage("system", "Backups", html `${(backupHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`), { chrome: chromeFor(project, "settings") }));
513
513
  }
514
514
  // v105: Settings → Data, where an instance operator downloads everything. The download is a POST with the password.
515
515
  async function dataPage(ctx) {
516
516
  const { url, response, project } = ctx;
517
517
  browserCaller(ctx.who);
518
- return sendScreen(response, 200, screen("Data", html `<p><a href="/settings">Settings</a></p><h1>Data</h1>${(dataExportHtml({ problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
518
+ return sendScreen(response, 200, screen("Data", settingsSubPage("system", "Data export", html `${(dataExportHtml({ problem: url.searchParams.get("problem") }))}`), { chrome: chromeFor(project, "settings") }));
519
519
  }
520
520
  // v100: Settings → Sign-in, the identity provider people sign in with. An instance operator's page.
521
521
  async function signInPage(ctx) {
@@ -527,7 +527,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
527
527
  const projects = consoleProjects().map(path => ({ path, name: projectName(path) }));
528
528
  const settings = ssoSettings();
529
529
  const body = ssoSettingsHtml({ settings, redirect: origin === null ? null : `${origin}${SSO_CALLBACK}`, projects, linked: settings !== null && store.ssoIdentitiesOf(who.name).some(one => one.issuer.replace(/\/+$/, "") === settings.issuer) }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
530
- return sendScreen(response, 200, screen("Sign-in", html `<p><a href="/settings">Settings</a></p><h1>Sign-in</h1>${body}`, { chrome: chromeFor(project, "settings") }));
530
+ return sendScreen(response, 200, screen("Sign-in", settingsSubPage("access", "Sign-in with a provider", html `${body}`), { chrome: chromeFor(project, "settings") }));
531
531
  }
532
532
  /** Passwordless (docs/plans/passwordless.md): the switch, and the trade in plain words. */
533
533
  async function passwordlessPage(ctx) {
@@ -535,15 +535,8 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
535
535
  browserCaller(ctx.who);
536
536
  const setting = passwordlessSetting(store);
537
537
  const said = url.searchParams.get("said"), problem = url.searchParams.get("problem");
538
- const note = problem ? html `<p class="problem" role="alert">${problem}</p>` : said ? html `<p role="status">${said}</p>` : "";
539
- const since = setting.at === null ? "" : html ` <span class="meta">(${setting.on ? "on" : "off"} since ${new Date(setting.at).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })}, by ${setting.setBy ?? "someone"})</span>`;
540
- const control = setting.on
541
- ? postForm("/settings/passwordless", html `<button class="secondary">Turn Passwordless off</button>`, { hidden: { action: "off" } })
542
- : postForm("/settings/passwordless", html `<label>Your password<input type="password" name="password" autocomplete="current-password" required></label><button>Turn Passwordless on</button>`, { attrs: { class: "card approve-form" }, hidden: { action: "on" } });
543
- return sendScreen(response, 200, screen("Passwordless", html `<p><a href="/settings">Settings</a></p><h1>Passwordless</h1>${note}\
544
- <p>When it's on, signing in is the only password: approvals, settings, budgets, signing a mode and starting a terminal no longer ask again — in the browser, on the phone, and in this computer's <code>toolroll</code> command (through its remembered login).</p>\
545
- <p class="meta">${PASSWORDLESS_TRADE}</p>\
546
- <p><strong>Passwordless is ${setting.on ? "on" : "off"}.</strong>${since}</p>${control}`, { chrome: chromeFor(project, "settings") }));
538
+ const since = setting.at === null ? null : `${setting.on ? "on" : "off"} since ${new Date(setting.at).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })}, by ${setting.setBy ?? "someone"}`;
539
+ return sendScreen(response, 200, screen("Passwordless", passwordlessHtml({ on: setting.on, since, trade: PASSWORDLESS_TRADE, said, problem }), { chrome: chromeFor(project, "settings") }));
547
540
  }
548
541
  async function passwordlessSave(ctx) {
549
542
  const { response, now, posted } = ctx;
@@ -569,7 +562,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
569
562
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
570
563
  if (chosen && (!projects.includes(chosen) || !visible(chosen)))
571
564
  return refuse(response, who, 403, "That project is outside your access.", "/settings/knowledge");
572
- const selector = projects.length > 1 ? framedSection('Project', html `<form class="field-row" method="get" action="/settings/knowledge"><label><span class="visually-hidden">Project</span><select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? ' selected' : ''}>${p.split('/').at(-1) ?? p}</option>`)}</select></label><button>Show project</button></form>`) : '';
565
+ const selector = projectPicker("/settings/knowledge", projects.map(repo => ({ repo, name: projectName(repo) })), chosen);
573
566
  let content = html `<p>Add a project to keep its knowledge here.</p>`;
574
567
  if (chosen) {
575
568
  try {
@@ -579,7 +572,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
579
572
  const revision = Number(url.searchParams.get('version'));
580
573
  const previous = knowledgeVersion(store, chosen, who.name, revision);
581
574
  const restore = csrf && who.role === 'approver' && revision !== view.revision ? postForm('/settings/knowledge/change', html `<p>Replaces knowledge for future tasks. Existing runs keep their saved context.</p><button>Restore version ${revision}</button>`, { attrs: { class: 'knowledge' }, hidden: { repo: chosen, identity: view.identity, revision: String(view.revision), action: 'restore', restore: String(revision) } }) : '';
582
- return sendScreen(response, 200, screen('Knowledge history', html `<p><a href="/settings/knowledge?repo=${encodeURIComponent(chosen)}">Current knowledge</a></p><h1>Version ${revision}</h1>${knowledgeHtml({ ...view, knowledge: previous, history: [], stale: null }, '', false)}${restore}`, { chrome: chromeFor(chosen, 'settings') }));
575
+ return sendScreen(response, 200, screen('Knowledge history', settingsSubPage('agents', `Knowledge · version ${revision}`, html `<p class="k-note"><a class="k-link" href="/settings/knowledge?repo=${encodeURIComponent(chosen)}">Current knowledge</a></p>${knowledgeHtml({ ...view, knowledge: previous, history: [], stale: null }, '', false)}${restore}`), { chrome: chromeFor(chosen, 'settings') }));
583
576
  }
584
577
  const query = (url.searchParams.get('q') ?? '').slice(0, 1000);
585
578
  const result = query.trim() ? repositoryContextRead({ repo: chosen, query, mode: url.searchParams.get('mode') === 'impact' ? 'impact' : 'search', cacheRoot: join(dirname(evidenceRoot), 'repository-context') }) : null;
@@ -600,7 +593,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
600
593
  content = html `<p class="problem" role="alert">Project knowledge is unavailable. Reload to retry.</p>`;
601
594
  }
602
595
  }
603
- return sendScreen(response, 200, screen('Knowledge', html `<p><a href="/settings">Settings</a></p><h1>Knowledge</h1>${url.searchParams.get('saved') === '1' ? html `<p role="status">Saved for future tasks.</p>` : ''}${selector}${content}`, { chrome: chromeFor(chosen || project, 'settings') }));
596
+ return sendScreen(response, 200, screen('Knowledge', settingsSubPage("agents", "Knowledge", html `${url.searchParams.get('saved') === '1' ? html `<p role="status">Saved for future tasks.</p>` : ''}${selector}${content}`), { chrome: chromeFor(chosen || project, 'settings') }));
604
597
  }
605
598
  async function learningPage(ctx) {
606
599
  const { url, who, response, project } = ctx;
@@ -608,7 +601,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
608
601
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
609
602
  if (chosen && (!projects.includes(chosen) || !visible(chosen)))
610
603
  return refuse(response, who, 403, "That project is outside your access.", "/settings/learning");
611
- const selector = html `<form method="get" action="/settings/learning"><label>Project<select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? " selected" : ""}>${p.split("/").at(-1) ?? p}</option>`)}</select></label><button>Show project</button></form>`;
604
+ const selector = projectPicker("/settings/learning", projects.map(repo => ({ repo, name: projectName(repo) })), chosen);
612
605
  let content = html `<p>No project is available.</p>`;
613
606
  if (chosen) {
614
607
  try {
@@ -618,7 +611,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
618
611
  content = html `<p class="problem" role="alert">Learning is unavailable. Reload to retry. Task results are unchanged.</p>`;
619
612
  }
620
613
  }
621
- return sendScreen(response, 200, screen("Learning", html `<p><a href="/settings">Settings</a></p><h1>Learning</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings") }));
614
+ return sendScreen(response, 200, screen("Learning", settingsSubPage("agents", "Learning", html `${selector}${content}`), { chrome: chromeFor(chosen || project, "settings") }));
622
615
  }
623
616
  // Settings → Lead (onboarding): what runs the lead in one line, and the full form under Advanced.
624
617
  async function leadPage(ctx) {
@@ -638,7 +631,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
638
631
  const signedIn = runtime.localSignIn?.states.claude === "connected" ? "Claude Code" : runtime.localSignIn?.states.codex === "connected" ? "Codex" : null;
639
632
  const promises = openCommitments(store, who.name, 50).map(one => ({ id: one.id, what: one.what, when: conditionWords(store, one.condition), until: one.expiresAt }));
640
633
  return sendScreen(response, 200, screen("Lead", leadSettingsHtml({ config, facts, words: leadWords(), signedIn, command: agentSignInCommand(),
641
- said: url.searchParams.get("said"), saved: url.searchParams.get("saved") === "1", identity: leadIdentityOf(store, who.name), promises,
634
+ said: url.searchParams.get("said"), problem: url.searchParams.get("problem"), saved: url.searchParams.get("saved") === "1", identity: leadIdentityOf(store, who.name), promises,
642
635
  about: aboutYouOf(store, who.name), aboutSaved: url.searchParams.get("saved") === "about",
643
636
  subagents: store.subagents(consoleProjects().filter(repo => store.accountCanAccess(who.name, repo))).map(one => ({ id: one.id, label: labelOf(one), project: projectName(one.repo), paused: one.state === "paused" })) }), { chrome: chromeFor(project, "settings") }));
644
637
  }
@@ -683,66 +676,115 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
683
676
  }
684
677
  async function settingsHome(ctx) {
685
678
  const { url, who, request, response, now, project } = ctx;
686
- if (options.telegramTokenFile === undefined || restricted()) {
687
- return sendScreen(response, 200, screen("Settings", html `<h1>Settings</h1><p><a href="/settings/models">Models</a> · <a href="/settings/skills">Skills</a> · <a href="/settings/tools">Tools</a> · <a href="/settings/knowledge">Project knowledge</a> · <a href="/settings/telegram">Telegram</a></p><details><summary>Learning history</summary><a href="/settings/learning">Learning</a></details>`, { chrome: chromeFor(project, "settings") }));
688
- }
689
- const existing = loadBotToken({}, options.telegramTokenFile);
690
- const hasEnv = (envValue(process.env, "TELEGRAM_TOKEN") ?? "") !== "";
679
+ // The overview and its group pages (docs/plans/settings-revamp.md §3): one handler reads the facts once and draws
680
+ // the page asked for; each section's form posts to its own route, which proves the act again.
681
+ const group = GROUP_PATHS[url.pathname] ?? "hub";
682
+ const minimal = options.telegramTokenFile === undefined || restricted();
691
683
  const csrf = who.via === "cookie" ? who.session.csrf : "";
692
- const messaging = options.configDir === undefined
684
+ const operator = who.via === "cookie" && store.isInstanceOperator(who.name);
685
+ const existing = options.telegramTokenFile === undefined ? null : loadBotToken({}, options.telegramTokenFile);
686
+ const hasEnv = (envValue(process.env, "TELEGRAM_TOKEN") ?? "") !== "";
687
+ const messaging = options.configDir === undefined || minimal
693
688
  ? null
694
- : effectivePrimary(process.env, options.configDir, loadBotToken(process.env, options.telegramTokenFile) !== null);
695
- const push = {
696
- // The card lights only where a secure context exists: the stated
689
+ : effectivePrimary(process.env, options.configDir, options.telegramTokenFile !== undefined && loadBotToken(process.env, options.telegramTokenFile) !== null);
690
+ const push = minimal || csrf === "" ? null : {
691
+ // The section lights only where a secure context exists: the stated
697
692
  // public origin, or localhost development (arc 3 finding 2).
698
693
  available: options.publicUrl !== undefined || (request.headers.host ?? "").startsWith("localhost") || (request.headers.host ?? "").startsWith("127.0.0.1"),
699
- devices: who.via === "cookie" ? store.listPushSubscriptions(who.name) : [],
694
+ devices: store.listPushSubscriptions(who.name).filter(one => one.retiredAt === null || one.retiredReason === "gone")
695
+ .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 })),
700
696
  };
701
- const providerKeys = who.role !== "approver"
697
+ const providerKeys = who.role !== "approver" || minimal || csrf === ""
702
698
  ? null
703
- // ACP agents sign in their own way; Toolroll keeps no key for them, so the key card lists only the others.
704
- : await Promise.all(PROVIDER_IDS.filter(isKeyedProvider).map(async (provider) => ({
705
- provider,
706
- envName: PROVIDER_KEY_ENV[provider],
707
- ...keyStatus(provider, providerHome),
708
- ...(connectionKnown(provider) === null ? {} : { connection: connectionKnown(provider) }),
709
- ambient: (process.env[PROVIDER_KEY_ENV[provider]] ?? "") !== "",
710
- mode: readAuthMode(provider, providerHome) === "api-key" ? "api-key" : "subscription",
711
- subscriptionCapable: SUBSCRIPTION_CAPABLE[provider],
712
- })));
713
- const telegramConfigured = loadBotToken(process.env, options.telegramTokenFile) !== null;
714
- const digest = telegramConfigured
715
- ? (() => {
716
- const cadence = store.telegramDigest();
717
- return { everyMs: cadence.everyMs, lastSentAt: cadence.lastSentAt, held: store.countRoutinePending() };
718
- })()
719
- : null;
720
- return sendScreen(response, 200, settingsPage(chromeFor(project, "settings"), existing, hasEnv, csrf, url.searchParams.get("said"), messaging, push, providerKeys, digest, {
721
- ...store.permissionDefault(),
722
- canManage: who.role === "approver",
723
- }, {
724
- ...store.qualityDefault(),
725
- canManage: who.role === "approver",
726
- }, who.role === "approver" && csrf !== "" ? (() => {
727
- // v87: the mail server flows send email through; the password is never shown back.
728
- // v89: where Inbox triggers read it, and a connected Google account (its client secret is never shown back either).
729
- const email = readEmailSettings(options.configDir ?? null), google = (() => { try {
730
- return readGoogleMail(options.configDir ?? null);
699
+ // ACP agents sign in their own way; Toolroll keeps no key for them, so the key list holds only the others.
700
+ : await Promise.all(PROVIDER_IDS.filter(isKeyedProvider).map(async (provider) => {
701
+ const key = keyStatus(provider, providerHome), connection = connectionKnown(provider) ?? undefined;
702
+ const mode = readAuthMode(provider, providerHome) === "api-key" ? "api-key" : "subscription";
703
+ const ambient = (process.env[PROVIDER_KEY_ENV[provider]] ?? "") !== "";
704
+ const name = ASSISTANTS[provider]?.name ?? provider;
705
+ const status = connection !== undefined && connection.state === "connected"
706
+ ? { tone: "ok", words: [connectionWords(connection), connection.plan].filter(Boolean).join(" · ") }
707
+ : mode === "subscription"
708
+ ? { tone: connection === undefined ? "neutral" : "warn", words: connection === undefined ? "Uses its own sign-in" : connectionWords(connection) }
709
+ : connection?.state === "key-works" ? { tone: "ok", words: "API key works" } : connection?.state === "key-refused" ? { tone: "warn", words: "API key refused" }
710
+ : key.set ? { tone: "ok", words: "API key saved" } : ambient ? { tone: "ok", words: "Key from this computer’s environment" } : { tone: "off", words: "Not set up" };
711
+ return {
712
+ provider, name, tone: status.tone, words: status.words,
713
+ connection: connection === undefined ? null : { words: connectionWords(connection), facts: [connection.email, connection.plan, connection.method].filter(Boolean).join(" · ") },
714
+ usage: `${mode === "subscription" ? "Uses its own sign-in, so no API-key spend" : "Uses the API key"} · ${key.set ? `key stored${key.updatedAt === null ? "" : ` ${key.updatedAt.slice(0, 10)}`}` : ambient ? "key in this server's environment" : "no key stored"}`,
715
+ envName: PROVIDER_KEY_ENV[provider], subscriptionCapable: SUBSCRIPTION_CAPABLE[provider], mode, set: key.set,
716
+ };
717
+ }));
718
+ const telegramBot = options.telegramTokenFile === undefined ? null : loadBotToken(process.env, options.telegramTokenFile);
719
+ const digest = telegramBot === null || minimal ? null : (() => {
720
+ const cadence = store.telegramDigest();
721
+ return { everyMs: cadence.everyMs, held: store.countRoutinePending() };
722
+ })();
723
+ const chosen = minimal || csrf === "" ? null : store.notificationPreference(who.name);
724
+ const updates = minimal ? null : settingsUpdates(who.name, csrf);
725
+ const lastBackup = operator ? store.backupRuns(1)[0] ?? null : null;
726
+ const accounts = minimal || !operator ? null : store.accountFacts().filter(one => one.revokedAt === null);
727
+ const passwordless = minimal ? null : passwordlessSetting(store).on;
728
+ const facts = {
729
+ name: who.name, approver: who.role === "approver", operator, browser: who.via === "cookie", installation: !minimal,
730
+ said: url.searchParams.get("said"), problem: url.searchParams.get("problem"),
731
+ theme: requestContext.getStore()?.theme ?? "system", accent: requestContext.getStore()?.accent ?? DEFAULT_ACCENT,
732
+ version: updates?.current ?? (operator ? PACKAGE_VERSION : null),
733
+ permission: minimal ? null : (() => { const one = store.permissionDefault(); return { mode: one.mode, canManage: who.role === "approver" && csrf !== "", changed: one.updatedAt === null ? null : `Changed ${when(one.updatedAt)}${one.updatedBy === null ? "" : ` by ${one.updatedBy}`}` }; })(),
734
+ quality: minimal ? null : (() => { const one = store.qualityDefault(); return { mode: one.mode, canManage: who.role === "approver" && csrf !== "", changed: one.updatedAt === null ? null : `Changed ${when(one.updatedAt)}${one.updatedBy === null ? "" : ` by ${one.updatedBy}`}` }; })(),
735
+ providers: providerKeys,
736
+ workers: minimal ? null : settingsWorkers(store, now),
737
+ updates,
738
+ firstResult: (() => {
739
+ // The installation fact: when the first Ready result arrived, measured from the first account.
740
+ const at = store.firstSuccessAt(now), since = store.installationStartedAt();
741
+ return at === null || since === null ? null : firstResultWords(since, at);
742
+ })(),
743
+ chat: chosen === null ? null : { mode: chosen.mode, digestAt: chosen.digestAt, screenshots: chosen.screenshots, projects: notificationProjects(store, who.name) },
744
+ messaging,
745
+ push,
746
+ digest: digest === null || csrf === "" || chosen?.mode === "quiet" ? null : { every: digest.everyMs === null ? "off" : String(Math.round(digest.everyMs / 60_000)), held: digest.everyMs === null ? null : `${digest.held} routine update${digest.held === 1 ? "" : "s"} held` },
747
+ telegram: {
748
+ // The installation's bot: no part of it (not even its id or last characters) for a project-limited account.
749
+ state: minimal ? "" : hasEnv ? "from the environment" : existing === null ? "not set" : "saved",
750
+ current: minimal ? "" : hasEnv ? `set in the environment (${TOKEN_ENV}), which takes precedence over anything saved here` : existing === null ? "not set" : `saved: ${redactToken(existing.token)} (bot ${existing.botId})`,
751
+ delivery: minimal ? null : telegramDeliveryWords(store, telegramBot), failing: !minimal && telegramTrouble(store, telegramBot),
752
+ },
753
+ email: who.role === "approver" && csrf !== "" && !minimal ? (() => {
754
+ // v87: the mail server flows send email through; the password is never shown back.
755
+ // v89: where Inbox triggers read it, and a connected Google account (its client secret is never shown back either).
756
+ const email = readEmailSettings(options.configDir ?? null), google = (() => { try {
757
+ return readGoogleMail(options.configDir ?? null);
758
+ }
759
+ catch {
760
+ return null;
761
+ } })(), origin = consoleOrigin(request.headers.host);
762
+ const googleView = { connected: googleConnected(options.configDir ?? null)?.address ?? null, clientId: google?.clientId ?? "", redirect: origin === null ? null : `${origin}${GOOGLE_CALLBACK}` };
763
+ return email === null ? { set: false, host: "", port: 587, secure: false, user: "", from: "", imapHost: "", imapPort: 993, google: googleView }
764
+ : { set: true, host: email.host, port: email.port, secure: email.secure, user: email.user, from: email.from, imapHost: email.imap?.host ?? "", imapPort: email.imap?.port ?? 993, google: googleView };
765
+ })() : null,
766
+ phone: who.via === "cookie" && !minimal ? phoneSetup(who) ?? null : null,
767
+ projects: consoleProjects().map(repo => ({ repo, name: projectName(repo) })),
768
+ people: accounts === null ? null : { approvers: accounts.filter(one => one.role === "approver").map(one => one.name), viewers: accounts.filter(one => one.role !== "approver").map(one => one.name), invites: store.openInvites(now).length },
769
+ passwordless,
770
+ sso: operator ? (() => { try {
771
+ const sso = ssoSettings();
772
+ return sso === null ? null : sso.label ?? "your identity provider";
731
773
  }
732
774
  catch {
733
775
  return null;
734
- } })(), origin = consoleOrigin(request.headers.host);
735
- const googleView = { connected: googleConnected(options.configDir ?? null)?.address ?? null, clientId: google?.clientId ?? "", redirect: origin === null ? null : `${origin}${GOOGLE_CALLBACK}` };
736
- return email === null ? { set: false, host: "", port: 587, secure: false, user: "", from: "", imapHost: "", imapPort: 993, google: googleView }
737
- : { set: true, host: email.host, port: email.port, secure: email.secure, user: email.user, from: email.from, imapHost: email.imap?.host ?? "", imapPort: email.imap?.port ?? 993, google: googleView };
738
- })() : null, telegramDeliveryWords(store, loadBotToken(process.env, options.telegramTokenFile)), settingsWorkers(store, now), settingsUpdates(who.name, csrf), (() => {
739
- // The installation fact: when the first Ready result arrived, measured from the first account.
740
- const at = store.firstSuccessAt(now), since = store.installationStartedAt();
741
- return at === null || since === null ? null : firstResultWords(since, at);
742
- })(), (() => {
743
- const chosen = store.notificationPreference(who.name);
744
- return { mode: chosen.mode, digestAt: chosen.digestAt, screenshots: chosen.screenshots, projects: notificationProjects(store, who.name) };
745
- })(), telegramTrouble(store, loadBotToken(process.env, options.telegramTokenFile)), who.via === "cookie" ? phoneSetup(who) ?? null : null));
776
+ } })() : null,
777
+ access: { sessions: store.webSessions(who.name).length, tokens: store.apiTokens(who.name).filter(one => one.revokedAt === null && Date.parse(one.expiresAt) > now.getTime()).length },
778
+ backup: lastBackup === null ? null : lastBackup.ok === false
779
+ ? { failed: true, words: `Failed ${when(lastBackup.startedAt)}${lastBackup.error === null ? "" : `: ${lastBackup.error}`}` }
780
+ : { failed: false, words: lastBackup.finishedAt === null ? "Backing up now" : `Backed up ${when(lastBackup.finishedAt)}` },
781
+ };
782
+ const pushScript = pushScriptOf(facts);
783
+ const script = group === "hub" ? HUB_ANCHOR_SCRIPT : group === "alerts" && pushScript !== null ? pushScript : null;
784
+ return sendScreen(response, 200, screen(groupTitle(group), settingsGroupPage(group, facts), {
785
+ chrome: chromeFor(project, "settings"),
786
+ ...(script === null ? {} : { functional: { script, ...(group === "alerts" ? { fetches: true } : {}) } }),
787
+ }));
746
788
  }
747
789
  // A person says a provider's sign-in works again: its sign-in pause
748
790
  // lifts, its tasks may start, and one short message says so.
@@ -774,14 +816,14 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
774
816
  const body = readForm(posted, CONSOLE_FORMS.updatesDismiss);
775
817
  const version = body.get("version") ?? "";
776
818
  if (!/^[0-9A-Za-z.-]{1,60}$/.test(version))
777
- return refuse(response, who, 400, "Choose a version to dismiss.", "/settings#updates");
819
+ return refuse(response, who, 400, "Choose a version to dismiss.", "/settings/system#updates");
778
820
  response.setHeader("Set-Cookie", `so-update-seen=${version}; SameSite=Lax; Path=/; Max-Age=31536000`);
779
821
  if (body.get("quiet") === "1") {
780
822
  response.writeHead(204);
781
823
  response.end();
782
824
  return;
783
825
  }
784
- return redirect(response, safeReturn(body.get("return") ?? "/settings#updates"));
826
+ return redirect(response, safeReturn(body.get("return") ?? "/settings/system#updates"));
785
827
  }
786
828
  // The daily check's switch: an installation setting, so an operator's. (Its own address: POST /settings/updates starts an update.)
787
829
  async function updatesChecks(ctx) {
@@ -790,10 +832,10 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
790
832
  if (options.configDir === undefined)
791
833
  return refuse(response, who, 404, "Updates are not set up on this console.", "/settings");
792
834
  if (!store.isInstanceOperator(who.name))
793
- return refuse(response, who, 403, "Only an operator can change update checks.", "/settings#updates");
835
+ return refuse(response, who, 403, "Only an operator can change update checks.", "/settings/system#updates");
794
836
  const on = body.get("check") === "on";
795
837
  setUpdateChecks(options.configDir, on);
796
- return redirect(response, `/settings?said=${encodeURIComponent(on ? "Toolroll looks for a newer version once a day." : "Update checks are off.")}#updates`);
838
+ return redirect(response, `/settings/system?said=${encodeURIComponent(on ? "Toolroll looks for a newer version once a day." : "Update checks are off.")}#updates`);
797
839
  }
798
840
  async function skillsRevise(ctx) {
799
841
  const { response, posted } = ctx;
@@ -866,38 +908,44 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
866
908
  content = (skillsHtml(skillsView(store, repo, who.name), who.session.csrf, true, { error: message, draft: Object.fromEntries(['method', 'content', 'url', 'sample', 'sha'].map(k => [k, body.get(k) ?? ''])) }));
867
909
  }
868
910
  catch { /* Do not display unverified packages. */ }
869
- return sendScreen(response, 409, screen('Skills', html `<h1>Skills</h1>${content}`, { chrome: chromeFor(repo, 'settings'), functional: { script: skillsScript() } }));
911
+ return sendScreen(response, 409, screen('Skills', settingsSubPage("agents", "Skills", html `${content}`), { chrome: chromeFor(repo, 'settings'), functional: { script: skillsScript() } }));
870
912
  }
871
913
  };
872
- // v105: set or remove a monthly budget. An instance operator, with a step-up; the ledger keeps before → after.
914
+ // v105: set or remove a monthly budget. An instance operator, with a step-up; the ledger keeps before → after. The
915
+ // amount is checked before the password (nothing is written before the password is proved), so a refused amount
916
+ // shows next to its field with the person's draft kept.
873
917
  async function spendBudget(ctx) {
874
918
  const { who, response, now, posted } = ctx;
875
919
  const body = readForm(posted, CONSOLE_FORMS.spendBudget);
876
- const back = sayTo(response, "/settings/spend");
877
- if (!authenticateApprover(who, body.get("password") ?? "").ok)
878
- return back("problem", "Enter your Toolroll password to change a budget.");
879
- const target = /^(installation|project|person|subagent):(.+)$/.exec(body.get("target") ?? "");
920
+ const targetField = body.get("target") ?? "";
921
+ const remove = body.get("action") === "remove";
922
+ const draft = { usd: body.get("usd") ?? "", stop: body.get("stop") === "1" ? "1" : "0" };
923
+ const done = (words) => redirect(response, `/spend?said=${encodeURIComponent(words)}`);
924
+ const refused = (words, field) => redirect(response, `/spend?${new URLSearchParams({ edit: targetField === "" ? "new" : targetField, ...draft, ...(field ? { field } : {}), problem: words }).toString()}`);
925
+ const target = /^(installation|project|person|subagent):(.+)$/.exec(targetField);
880
926
  if (target === null)
881
- return back("problem", "Choose what the budget is for.");
927
+ return refused("Choose what the limit is for. Nothing changed.");
882
928
  const scope = target[1], key = target[2];
883
929
  const known = scope === "installation" ? key === "*"
884
930
  : scope === "project" ? consoleProjects().includes(key)
885
931
  : scope === "person" ? store.accountFacts().some(one => one.name === key && one.revokedAt === null)
886
932
  : store.subagents([...new Set([...managedRepos(), ...store.knownRepos()])]).some(one => String(one.id) === key);
887
933
  if (!known)
888
- return back("problem", "That isn't a project, person or subagent here.");
934
+ return refused("That isn't a project, person or subagent here. Nothing changed.");
935
+ const dollars = Number((body.get("usd") ?? "").replace(/[$,\s]/g, ""));
936
+ if (!remove && (!Number.isFinite(dollars) || dollars < 1 || dollars > 10_000_000))
937
+ return refused("Use a whole number of dollars, at least $1, like 40. Nothing changed.", "usd");
938
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
939
+ return refused("That password didn't match. Nothing changed.");
889
940
  const label = budgetLabel({ scope, key }).replace(/'s$/, "");
890
- if (body.get("action") === "remove") {
941
+ if (remove) {
891
942
  const existing = store.budgets().find(one => one.scope === scope && one.key === key);
892
943
  if (existing === undefined || !store.removeBudget(existing.id, who.name, now))
893
- return back("problem", "That budget is already gone.");
894
- return back("said", `${label}: budget removed.`);
944
+ return refused("That limit is already gone. Nothing changed.");
945
+ return done(`${label}: limit removed.`);
895
946
  }
896
- const dollars = Number(body.get("usd") ?? "");
897
- if (!Number.isFinite(dollars) || dollars < 1 || dollars > 10_000_000)
898
- return back("problem", "A monthly limit is a whole number of dollars, at least $1.");
899
947
  const saved = store.setBudget({ scope, key, limitMicrousd: Math.round(dollars) * 1_000_000, hardStop: body.get("stop") === "1" }, who.name, now);
900
- return back("said", `${label}: ${spendUsd(saved.limitMicrousd)} a month${saved.hardStop ? ", API work stops at 100%" : ", alerts only"}.`);
948
+ return done(`${label}: ${spendUsd(saved.limitMicrousd)} a month${saved.hardStop ? ", new API work pauses at the limit" : ", alerts only"}.`);
901
949
  }
902
950
  // Settings → Project → Builds at once: an approver for the project sets it; the ledger keeps before → after.
903
951
  async function projectConcurrency(ctx) {
@@ -979,7 +1027,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
979
1027
  const view = projectViewOf(repo, who);
980
1028
  if ((body.get("name") ?? "").trim() !== view.name)
981
1029
  return back("problem", `Type ${view.name} exactly to delete it.`);
982
- const confirm = (problem) => sendScreen(response, 200, screen(`Delete ${view.name}?`, html `<p><a href="/settings/project?repo=${encodeURIComponent(repo)}">${view.name}</a></p><h1>Delete ${view.name}?</h1>${projectDeleteConfirmHtml(view, problem)}`, { chrome: chromeFor(repo, "settings"), forceSensitive: true }));
1030
+ const confirm = (problem) => sendScreen(response, 200, screen(`Delete ${view.name}?`, settingsSubPage("projects", `Delete ${view.name}?`, html `<p class="k-note"><a class="k-link" href="/settings/project?repo=${encodeURIComponent(repo)}">Back to ${view.name}, and delete nothing</a></p>${projectDeleteConfirmHtml(view, problem)}`), { chrome: chromeFor(repo, "settings"), forceSensitive: true }));
983
1031
  if (view.running.length > 0)
984
1032
  return back("problem", `Nothing was deleted: ${view.running.join(", ")}. Stop it, then try again.`);
985
1033
  if (body.get("step") !== "delete")
@@ -1007,7 +1055,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1007
1055
  if (!authenticateApprover(who, body.get("password") ?? "").ok)
1008
1056
  return back("problem", "That password didn't match. Nothing changed.");
1009
1057
  const chosen = body.getAll("provider");
1010
- const checked = checkPolicy({ providers: chosen.length === 4 ? null : chosen, models: parseList(body.get("models")), tools: parseList(body.get("tools")), ceiling: body.get("ceiling") ?? "" });
1058
+ const checked = checkPolicy({ providers: chosenProviders(chosen), models: parseList(body.get("models")), tools: parseList(body.get("tools")), ceiling: body.get("ceiling") ?? "" });
1011
1059
  if (!checked.ok)
1012
1060
  return back("problem", `${checked.problem} Nothing changed.`);
1013
1061
  const before = policyParts(store.orgPolicy());
@@ -1402,7 +1450,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1402
1450
  setYouiyouxIntake(store, repo, on, who.name, clock());
1403
1451
  return back("said", on ? `youiyoux requests in ${basename(repo)} become tasks again.` : `youiyoux requests in ${basename(repo)} are off.`);
1404
1452
  }
1405
- // Settings → Integrations → Send test: one harmless read-only check of one integration, now.
1453
+ // Settings → Integrations → Check connection: one harmless read-only check of one integration, now.
1406
1454
  async function integrationsTest(ctx) {
1407
1455
  const { who, response, posted } = ctx;
1408
1456
  const body = readForm(posted, CONSOLE_FORMS.integrationsTest);
@@ -1678,7 +1726,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1678
1726
  if (!visible(repo) || !store.accountCanAccess(who.name, repo) || !consoleProjects().includes(repo))
1679
1727
  return refuse(response, who, 403, 'That project is outside your access.', '/settings/knowledge');
1680
1728
  const refreshed = await repositoryContext({ repo, query: '', refresh: true, cacheRoot: join(dirname(evidenceRoot), 'repository-context') });
1681
- return sendScreen(response, refreshed.index.status === 'ready' ? 200 : 503, screen('Project context', html `<h1>Project context</h1><p>${refreshed.index.status === 'ready' ? 'Code index refreshed.' : 'The index is unavailable. Source search still works.'}</p><a class="button-link" href="/settings/knowledge?repo=${encodeURIComponent(repo)}">Open knowledge</a>`, { chrome: chromeFor(repo, 'settings') }));
1729
+ return sendScreen(response, refreshed.index.status === 'ready' ? 200 : 503, screen('Project context', settingsSubPage("agents", "Project context", html `<p>${refreshed.index.status === 'ready' ? 'Code index refreshed.' : 'The index is unavailable. Source search still works.'}</p><a class="button-link" href="/settings/knowledge?repo=${encodeURIComponent(repo)}">Open knowledge</a>`), { chrome: chromeFor(repo, 'settings') }));
1682
1730
  }
1683
1731
  async function knowledgeProposal(ctx) {
1684
1732
  const { who, response, now, posted } = ctx;
@@ -1692,7 +1740,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1692
1740
  decideProposal(store, { repo, actor: who.name, id: Number(body.get('proposal')), decision: decision }, now);
1693
1741
  }
1694
1742
  catch (error) {
1695
- return sendScreen(response, 409, screen('Knowledge', html `<h1>Knowledge</h1><p role="alert">${error instanceof Error ? error.message : 'That proposal could not be decided.'}</p><p><a href="/settings/knowledge?repo=${encodeURIComponent(repo)}">Back to knowledge</a></p>`, { chrome: chromeFor(repo, 'settings') }));
1743
+ return sendScreen(response, 409, screen('Knowledge', settingsSubPage("agents", "Knowledge", html `<p role="alert">${error instanceof Error ? error.message : 'That proposal could not be decided.'}</p><p><a href="/settings/knowledge?repo=${encodeURIComponent(repo)}">Back to knowledge</a></p>`), { chrome: chromeFor(repo, 'settings') }));
1696
1744
  }
1697
1745
  return redirect(response, `/settings/knowledge?repo=${encodeURIComponent(repo)}&saved=1`);
1698
1746
  }
@@ -1720,7 +1768,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1720
1768
  catch {
1721
1769
  content = html `<p role="alert">Project decisions are unavailable. Your unsaved draft is below.</p><pre class="knowledge">${JSON.stringify(draft, null, 2)}</pre>`;
1722
1770
  }
1723
- return sendScreen(response, 409, screen('Knowledge', html `<h1>Knowledge</h1>${content}`, { chrome: chromeFor(repo, 'settings') }));
1771
+ return sendScreen(response, 409, screen('Knowledge', settingsSubPage("agents", "Knowledge", html `${content}`), { chrome: chromeFor(repo, 'settings') }));
1724
1772
  }
1725
1773
  return redirect(response, `/settings/knowledge?repo=${encodeURIComponent(repo)}&saved=1`);
1726
1774
  }
@@ -1746,7 +1794,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1746
1794
  content = (knowledgeHtml(knowledgeView(store, repo, who.name), who.session.csrf, true, draft, error instanceof Error ? error.message : 'Save failed. Your draft is below.'));
1747
1795
  }
1748
1796
  catch { /* never render unverified saved context */ }
1749
- return sendScreen(response, 409, screen('Knowledge', html `<h1>Knowledge</h1>${content}`, { chrome: chromeFor(repo, 'settings') }));
1797
+ return sendScreen(response, 409, screen('Knowledge', settingsSubPage("agents", "Knowledge", html `${content}`), { chrome: chromeFor(repo, 'settings') }));
1750
1798
  }
1751
1799
  return redirect(response, `/settings/knowledge?repo=${encodeURIComponent(repo)}&saved=1`);
1752
1800
  }
@@ -1764,7 +1812,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1764
1812
  changeLearning(store, evidenceRoot, { repo, actor: who.name, identity: body.get("identity") ?? "", revision: Number(body.get("revision")), action: action, lesson: Number(body.get("lesson")), version: Number(body.get("version")), sha: body.get("sha") ?? "" }, clock());
1765
1813
  }
1766
1814
  catch (error) {
1767
- return sendScreen(response, 409, screen("Learning", html `<section class="learning"><h1>Learning</h1><p class="problem" role="alert">${error instanceof Error ? error.message : "Learning could not be changed. Reload to retry."}</p><a class="button-link" href="/settings/learning?repo=${encodeURIComponent(repo)}">Reload Learning</a></section>`));
1815
+ return sendScreen(response, 409, screen("Learning", settingsSubPage("agents", "Learning", html `<section class="learning"><p class="problem" role="alert">${error instanceof Error ? error.message : "Learning could not be changed. Reload to retry."}</p><a class="button-link" href="/settings/learning?repo=${encodeURIComponent(repo)}">Reload Learning</a></section>`)));
1768
1816
  }
1769
1817
  return redirect(response, `/settings/learning?repo=${encodeURIComponent(repo)}`);
1770
1818
  }
@@ -1784,7 +1832,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1784
1832
  const nonceKey = `project-instructions:${project}`;
1785
1833
  if (step === "preview") {
1786
1834
  const nonce = mintApprovalNonce(who.name, nonceKey, preview.fingerprint);
1787
- return sendScreen(response, 200, screen("Review agent instructions", html `<h1>Review agent instructions</h1><p>${preview.plan.skillPath}</p><pre class="recap">${preview.content}</pre>${postForm("/control/instructions-approve", html `${reauth(html `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>`)}<button>Add these instructions</button>`, { hidden: { repo: project, nonce, fingerprint: preview.fingerprint } })}`, { chrome: chromeFor(project, "settings") }));
1835
+ return sendScreen(response, 200, screen("Review agent instructions", settingsSubPage("projects", "Review agent instructions", html `<p>${preview.plan.skillPath}</p><pre class="recap">${preview.content}</pre>${postForm("/control/instructions-approve", html `${reauth(html `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>`)}<button>Add these instructions</button>`, { hidden: { repo: project, nonce, fingerprint: preview.fingerprint } })}`), { chrome: chromeFor(project, "settings") }));
1788
1836
  }
1789
1837
  if (who.via === "cookie" && !consumeApprovalNonce(body.get("nonce") ?? "", who.name, nonceKey, body.get("fingerprint") ?? ""))
1790
1838
  return refuse(response, who, 409, "This instruction preview expired. Review it again.", "/control");
@@ -2080,7 +2128,7 @@ ${postForm("/settings/chat-approval/save", html `\
2080
2128
  return refuse(response, who, 400, "primary must be one of the configured services");
2081
2129
  }
2082
2130
  savePrimary(options.configDir, wanted);
2083
- return redirect(response, "/settings");
2131
+ return redirect(response, `/settings/alerts?said=${encodeURIComponent("Alerts go through the chosen app now.")}#alert-service`);
2084
2132
  }
2085
2133
  async function permissionDefaultSave(ctx) {
2086
2134
  const { who, response, now, posted } = ctx;
@@ -2094,10 +2142,10 @@ ${postForm("/settings/chat-approval/save", html `\
2094
2142
  // Widening every new task to Full access takes a password (chat-management.md §0a.3): an ordinary step-up, so a
2095
2143
  // signed-in session does under Passwordless. Back to Auto narrows, and needs none.
2096
2144
  if (wanted === "bypassPermissions" && store.permissionDefault().mode !== "bypassPermissions" && !authenticateApprover(who, body.get("password") ?? "").ok) {
2097
- return redirect(response, `/settings?said=${encodeURIComponent("Enter your Toolroll password to start new tasks with Full access. Nothing changed.")}`);
2145
+ return redirect(response, `/settings/approval?problem=${encodeURIComponent("Enter your Toolroll password to start new tasks with Full access. Nothing changed.")}#defaults`);
2098
2146
  }
2099
2147
  store.setPermissionDefault(wanted, who.name, now);
2100
- return redirect(response, `/settings?said=${encodeURIComponent(wanted === "bypassPermissions"
2148
+ return redirect(response, `/settings/approval?said=${encodeURIComponent(wanted === "bypassPermissions"
2101
2149
  ? "New tasks now start with Full access. Approved tasks keep their setting."
2102
2150
  : "New tasks now start with Auto. Approved tasks keep their setting.")}`);
2103
2151
  }
@@ -2111,7 +2159,7 @@ ${postForm("/settings/chat-approval/save", html `\
2111
2159
  return refuse(response, who, 400, "quality must be Default or Strict / release", "/settings");
2112
2160
  }
2113
2161
  store.setQualityDefault(wanted, who.name, now);
2114
- return redirect(response, `/settings?said=${encodeURIComponent(wanted === "strict"
2162
+ return redirect(response, `/settings/approval?said=${encodeURIComponent(wanted === "strict"
2115
2163
  ? "New tasks now use Strict / release. Approved tasks keep their setting."
2116
2164
  : "New tasks now use Default quality. Approved tasks keep their setting.")}`);
2117
2165
  }
@@ -2134,7 +2182,7 @@ ${postForm("/settings/chat-approval/save", html `\
2134
2182
  : after.screenshots !== before.screenshots
2135
2183
  ? after.screenshots === "off" ? "Results arrive without screenshots." : `Results arrive with ${after.screenshots === "first" ? "their first screenshot" : "up to 4 screenshots"}.`
2136
2184
  : after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
2137
- return redirect(response, `/settings?said=${encodeURIComponent(said)}`);
2185
+ return redirect(response, `/settings/alerts?said=${encodeURIComponent(said)}#notifications`);
2138
2186
  }
2139
2187
  async function notificationsMute(ctx) {
2140
2188
  const { who, response, now, posted } = ctx;
@@ -2146,7 +2194,7 @@ ${postForm("/settings/chat-approval/save", html `\
2146
2194
  return refuse(response, who, 400, "that project isn't one you can see", "/settings");
2147
2195
  const muted = body.get("pings") !== "on";
2148
2196
  store.setProjectMuted(who.name, repo, muted, now);
2149
- return redirect(response, `/settings?said=${encodeURIComponent(muted ? `${project.name} muted. It still shows in Tasks and your evening digest.` : `${project.name} pings you again.`)}#notifications`);
2197
+ return redirect(response, `/settings/alerts?said=${encodeURIComponent(muted ? `${project.name} muted. It still shows in Tasks and your evening digest.` : `${project.name} pings you again.`)}#notifications`);
2150
2198
  }
2151
2199
  async function telegramDigestSave(ctx) {
2152
2200
  const { who, response, now, posted } = ctx;
@@ -2161,7 +2209,7 @@ ${postForm("/settings/chat-approval/save", html `\
2161
2209
  return refuse(response, who, 400, "the digest cadence is one of the listed choices", "/settings");
2162
2210
  const minutes = allowed[wanted] ?? null;
2163
2211
  store.setTelegramDigest(minutes === null ? null : minutes * 60_000, who.name, now);
2164
- return redirect(response, `/settings?said=${encodeURIComponent(minutes === null ? "Digest off: each update arrives as it happens." : `Digest every ${minutes >= 60 ? `${minutes / 60}h` : `${minutes}m`}. Anything that needs you still arrives at once.`)}`);
2212
+ return redirect(response, `/settings/alerts?said=${encodeURIComponent(minutes === null ? "Digest off: each update arrives as it happens." : `Digest every ${minutes >= 60 ? `${minutes / 60}h` : `${minutes}m`}. Anything that needs you still arrives at once.`)}`);
2165
2213
  }
2166
2214
  const providerKeyAct = (op) => async (ctx) => {
2167
2215
  const { who, response, posted } = ctx;
@@ -2176,11 +2224,11 @@ ${postForm("/settings/chat-approval/save", html `\
2176
2224
  if (op === "clear") {
2177
2225
  const cleared = clearProviderKey(provider, providerHome);
2178
2226
  const clearedMode = readAuthMode(provider, providerHome);
2179
- return redirect(response, `/settings?said=${encodeURIComponent(!cleared
2227
+ return redirect(response, `/settings/agents?said=${encodeURIComponent(!cleared
2180
2228
  ? "no stored key to remove"
2181
2229
  : clearedMode === "subscription"
2182
2230
  ? `the ${provider} key is removed — ${provider} uses its own login, so builds are unaffected`
2183
- : `the ${provider} key is removed — an environment variable, if one exists, takes over`)}`);
2231
+ : `the ${provider} key is removed — an environment variable, if one exists, takes over`)}#providers`);
2184
2232
  }
2185
2233
  // Validate EVERYTHING before mutating anything (Codex round 5,
2186
2234
  // finding 2): an invalid key must never persist a mode change it
@@ -2208,7 +2256,7 @@ ${postForm("/settings/chat-approval/save", html `\
2208
2256
  setAuthMode(provider, wantedMode, providerHome);
2209
2257
  const modeNote = modeChanged ? ` \u00b7 ${provider} now uses ${wantedMode === "api-key" ? "the API key" : "its own subscription / login"}` : "";
2210
2258
  if (value === "") {
2211
- return redirect(response, `/settings?said=${encodeURIComponent(modeChanged ? `${provider} now uses ${wantedMode === "api-key" ? "the API key" : "its own subscription / login"}` : `no change — ${provider} already uses ${currentMode === "api-key" ? "the API key" : "its own subscription / login"}`)}`);
2259
+ return redirect(response, `/settings/agents?said=${encodeURIComponent(modeChanged ? `${provider} now uses ${wantedMode === "api-key" ? "the API key" : "its own subscription / login"}` : `no change — ${provider} already uses ${currentMode === "api-key" ? "the API key" : "its own subscription / login"}`)}#providers`);
2212
2260
  }
2213
2261
  saveProviderKey(provider, value, providerHome); // known plausible
2214
2262
  // Verify right now, so a paste gets an immediate yes/no instead of a
@@ -2217,7 +2265,7 @@ ${postForm("/settings/chat-approval/save", html `\
2217
2265
  const checked = readAuthMode(provider, providerHome) === "api-key" ? await connectionCheck(provider, true) : null;
2218
2266
  const verdict = checked?.verdict ?? await verifyProviderKey(provider, value);
2219
2267
  const stored = `the ${provider} key is stored`;
2220
- return redirect(response, `/settings?said=${encodeURIComponent((verdict.ok ? `${stored} and verified — it works` : `${stored}. ${verdictWords(provider, verdict)}`) + modeNote)}`);
2268
+ return redirect(response, `/settings/agents?said=${encodeURIComponent((verdict.ok ? `${stored} and verified — it works` : `${stored}. ${verdictWords(provider, verdict)}`) + modeNote)}#providers`);
2221
2269
  };
2222
2270
  async function telegramTokenSave(ctx) {
2223
2271
  const { who, response, posted } = ctx;
@@ -2226,16 +2274,9 @@ ${postForm("/settings/chat-approval/save", html `\
2226
2274
  const body = readForm(posted, CONSOLE_FORMS.telegramToken);
2227
2275
  const value = body.get("token") ?? "";
2228
2276
  const saved = saveBotToken(options.telegramTokenFile, value);
2229
- if (!saved.ok) {
2230
- const existing = loadBotToken({}, options.telegramTokenFile);
2231
- const hasEnv = (envValue(process.env, "TELEGRAM_TOKEN") ?? "") !== "";
2232
- const csrf = who.via === "cookie" ? who.session.csrf : "";
2233
- return sendScreen(response, 400, settingsPage(chromeFor(who.via === "cookie" ? who.session.project : runtime.defaultProject, "settings"), existing, hasEnv, csrf, saved.message, options.configDir === undefined ? null : effectivePrimary(process.env, options.configDir, loadBotToken(process.env, options.telegramTokenFile) !== null), null, null, null, {
2234
- ...store.permissionDefault(),
2235
- canManage: who.role === "approver",
2236
- }));
2237
- }
2238
- return redirect(response, "/settings");
2277
+ if (!saved.ok)
2278
+ return redirect(response, `/settings/alerts?problem=${encodeURIComponent(saved.message)}#telegram-token`);
2279
+ return redirect(response, `/settings/alerts?said=${encodeURIComponent("Bot token saved. Pair your phone on the Telegram page.")}#telegram-token`);
2239
2280
  }
2240
2281
  // v87: the mail server Send email steps use, and a test email to its own address.
2241
2282
  const emailAct = (op) => async (ctx) => {
@@ -2243,10 +2284,10 @@ ${postForm("/settings/chat-approval/save", html `\
2243
2284
  if (who.role !== "approver" || options.configDir === undefined)
2244
2285
  return refuse(response, who, 404, "There's no page at this address.", "/chat");
2245
2286
  const body = readForm(posted, CONSOLE_FORMS.email);
2246
- const said = (words) => redirect(response, `/settings?said=${encodeURIComponent(words)}#email`);
2287
+ const said = (words, key = "said") => redirect(response, `/settings/alerts?${key}=${encodeURIComponent(words)}#email`);
2247
2288
  if (op === "save") {
2248
2289
  const saved = saveEmailSettings(options.configDir, { host: body.get("host"), port: body.get("port"), secure: body.get("secure"), user: body.get("user"), from: body.get("from"), password: body.get("password"), imapHost: body.get("imapHost"), imapPort: body.get("imapPort") });
2249
- return said(saved.ok ? saved.said : saved.message);
2290
+ return saved.ok ? said(saved.said) : said(saved.message, "problem");
2250
2291
  }
2251
2292
  if (op === "read-test") {
2252
2293
  // Sign in and open the inbox read-only, as an Inbox trigger's first check does: nothing is read or changed.
@@ -2269,7 +2310,7 @@ ${postForm("/settings/chat-approval/save", html `\
2269
2310
  if (who.role !== "approver" || options.configDir === undefined)
2270
2311
  return refuse(response, who, 404, "There's no page at this address.", "/chat");
2271
2312
  const body = readForm(posted, CONSOLE_FORMS.google);
2272
- const said = (words) => redirect(response, `/settings?said=${encodeURIComponent(words)}#email`);
2313
+ const said = (words) => redirect(response, `/settings/alerts?said=${encodeURIComponent(words)}#email`);
2273
2314
  if (op === "disconnect") {
2274
2315
  await disconnectGoogle(options.configDir, options.googleFetch ?? fetch);
2275
2316
  return said("Google disconnected. Email goes through the mail server again, if one is set up.");
@@ -2333,15 +2374,8 @@ ${postForm("/settings/chat-approval/save", html `\
2333
2374
  const nonce = mintApprovalNonce(who.name, "mode-sign", `${project}:${digest}`);
2334
2375
  const signed = Object.fromEntries(["name", "days", "publication", "auto-approve", "plan-auto", "chat-approve"]
2335
2376
  .map(field => [field, body.get(field) ?? ""]));
2336
- const bodyHtml = html `<h1 style="overflow-wrap:anywhere">Sign the ${name} mode for ${project}</h1>\
2337
- ${postForm("/mode/sign", html `\
2338
- <p><strong>Your password signs exactly this:</strong></p>\
2339
- ${modeWords(terms)
2340
- .map(words => html `<p class="recap" style="margin-top:.4rem">${words}</p>`)}\
2341
- <label>Your password, typed again<input type="password" name="token" autocomplete="current-password"></label>\
2342
- <div class="sticky-actions"><button type="submit">Sign it</button></div>`, { attrs: { class: "card approve-form" }, hidden: { nonce, digest, ...signed, expiry } })}\
2343
- <p class="meta"><a href="/mode">back — sign nothing</a></p>`;
2344
- return sendScreen(response, 200, screen("mode", bodyHtml, { chrome: chromeFor(project, "mode") }));
2377
+ const bodyHtml = modeCeremonyHtml({ name, project: projectName(project), words: modeWords(terms), chatApprove: signed["chat-approve"] === "1", hidden: { nonce, digest, ...signed, expiry } });
2378
+ return sendScreen(response, 200, screen("Sign the mode", bodyHtml, { chrome: chromeFor(project, "mode") }));
2345
2379
  }
2346
2380
  // /mode/sign: the fixed expiry rides the form; the digest is
2347
2381
  // RE-DERIVED from the posted fields — drift is a 409, never a guess.
@@ -2369,7 +2403,7 @@ ${modeWords(terms)
2369
2403
  absoluteExpiry: signedTerms.absoluteExpiry,
2370
2404
  publication: signedTerms.publication,
2371
2405
  }, now);
2372
- return redirect(response, "/mode");
2406
+ return redirect(response, `/mode?said=${encodeURIComponent(`The ${signedTerms.name} mode is signed for ${projectName(project)}.`)}`);
2373
2407
  };
2374
2408
  async function modeRevoke(ctx) {
2375
2409
  const { who, request, response, now } = ctx;
@@ -2509,7 +2543,7 @@ ${modeWords(terms)
2509
2543
  async function googleCallback(ctx) {
2510
2544
  const { request, response, url } = ctx;
2511
2545
  const done = (said) => {
2512
- const back = `/settings?said=${encodeURIComponent(said)}#email`;
2546
+ const back = `/settings/alerts?said=${encodeURIComponent(said)}#email`;
2513
2547
  response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", "x-frame-options": "DENY", "set-cookie": signInSpent(GOOGLE_CALLBACK),
2514
2548
  "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'" });
2515
2549
  response.end(htmlString(html `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="0;url=${back}"><title>Toolroll</title>${HANDOFF_STYLE}<p>${said} <a href="${back}">Back to Settings</a></p>`));
@@ -2626,6 +2660,12 @@ ${modeWords(terms)
2626
2660
  "settings.learning": learningPage,
2627
2661
  "settings.telegram": telegramPage,
2628
2662
  "settings.page": settingsHome,
2663
+ "settings.group-general": settingsHome.bind(null),
2664
+ "settings.group-agents": settingsHome.bind(null),
2665
+ "settings.group-alerts": settingsHome.bind(null),
2666
+ "settings.group-projects": settingsHome.bind(null),
2667
+ "settings.group-access": settingsHome.bind(null),
2668
+ "settings.group-system": settingsHome.bind(null),
2629
2669
  "provider.resume": providerResume,
2630
2670
  "settings.updates-dismiss": updatesDismiss,
2631
2671
  "session.forget-password": forgetPassword,
@@ -2828,29 +2868,10 @@ ${modeWords(terms)
2828
2868
  const changed = setReleaseApproval(store, repo, true, who.name, now);
2829
2869
  return back("said", changed.changed ? "Releases approve themselves once CI and the release check pass." : "Saved.");
2830
2870
  }
2831
- // Settings → Spend: the monthly limits editor and the models in use. An instance operator's page; changing a limit
2832
- // posts to /spend/budget with a step-up.
2871
+ // Settings → Spend moved onto the Spend page (/spend), limits first; the old address keeps working.
2833
2872
  async function spendSettingsPage(ctx) {
2834
- const { url, response, now } = ctx;
2835
- const current = monthOf(now);
2836
- const subagents = store.subagents([...new Set([...managedRepos(), ...store.knownRepos()])]);
2837
- const subagentNames = subagentNamesOf(store.handle);
2838
- const projects = consoleProjects();
2839
- const people = store.accountFacts().filter(one => one.revokedAt === null).map(one => one.name);
2840
- const models = ["plan", "build", "review"].map(phase => {
2841
- const agent = resolvePhaseAgent(store, phase, null, {});
2842
- return { role: phase === "plan" ? "Planner" : phase === "build" ? "Builder" : "Reviewer", model: agent.ok ? `${agent.spec.provider} · ${agent.spec.model ?? "its default model"}` : "Not set" };
2843
- });
2844
- const view = {
2845
- budgets: budgetStates(store.budgets(), spendItems(store.handle, current.from, current.to)), subagentNames, models,
2846
- targets: [
2847
- { value: "installation:*", label: "Everything", group: "Everything" },
2848
- ...projects.map(repo => ({ value: `project:${repo}`, label: projectName(repo), group: "Projects" })),
2849
- ...people.map(name => ({ value: `person:${name}`, label: name, group: "People" })),
2850
- ...subagents.map(one => ({ value: `subagent:${one.id}`, label: `${subagentNames.get(one.id) ?? one.handle} (${projectName(one.repo)})`, group: "Subagents" })),
2851
- ],
2852
- };
2853
- return sendScreen(response, 200, screen("Spend settings", spendSettingsHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }), { chrome: chromeFor(null, "settings") }));
2873
+ const said = ctx.url.searchParams.get("said"), problem = ctx.url.searchParams.get("problem");
2874
+ return redirect(ctx.response, `/spend${problem !== null ? `?problem=${encodeURIComponent(problem)}` : said !== null ? `?said=${encodeURIComponent(said)}` : ""}`);
2854
2875
  }
2855
2876
  // Settings → Models (docs/plans/spend-dashboard.md, slice 3): the one editor. Both posts are an instance operator's,
2856
2877
  // browser-only step-ups: the password (or a Passwordless session, or an identity-provider re-check) is proved here,