toolroll 0.9.86 → 0.9.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/dist/agents-board.d.ts +12 -0
- package/dist/agents-board.js +26 -0
- package/dist/approval-rules-ui.d.ts +4 -1
- package/dist/approval-rules-ui.js +31 -15
- package/dist/assignment-ui.js +1 -1
- package/dist/backup-ui.d.ts +5 -4
- package/dist/backup-ui.js +65 -32
- package/dist/browser/terminal.css +1 -1
- package/dist/browser/terminal.js +57 -54
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +35 -70
- package/dist/browser-crew.js +1 -1
- package/dist/browser-workspace.d.ts +329 -183
- package/dist/browser-workspace.js +10 -9
- package/dist/chat-controls.d.ts +1 -1
- package/dist/chat-controls.js +1 -1
- package/dist/chat-settings-page.d.ts +29 -9
- package/dist/chat-settings-page.js +59 -28
- package/dist/check-levels-ui.d.ts +2 -1
- package/dist/check-levels-ui.js +67 -58
- package/dist/contracts/cli.d.ts +43 -0
- package/dist/contracts/cli.js +4 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/credentials-ui.d.ts +5 -3
- package/dist/credentials-ui.js +58 -31
- package/dist/design/kit.css +685 -0
- package/dist/design/kit.d.ts +208 -0
- package/dist/design/kit.js +177 -0
- package/dist/design/tokens.css +19 -1
- package/dist/discord-settings.js +8 -4
- package/dist/export-ui.d.ts +6 -4
- package/dist/export-ui.js +18 -8
- package/dist/flow-changed.d.ts +14 -0
- package/dist/flow-changed.js +53 -0
- package/dist/flow-gallery-ui.d.ts +41 -4
- package/dist/flow-gallery-ui.js +95 -63
- package/dist/flow-starters-ui.d.ts +3 -9
- package/dist/flow-starters-ui.js +12 -23
- package/dist/flow-unattended-words.d.ts +20 -0
- package/dist/flow-unattended-words.js +24 -0
- package/dist/flow-unattended.d.ts +9 -5
- package/dist/flow-unattended.js +45 -23
- package/dist/flows-cli.js +7 -3
- package/dist/flows-ui.d.ts +12 -6
- package/dist/flows-ui.js +141 -30
- package/dist/integrations-ui.d.ts +6 -4
- package/dist/integrations-ui.js +34 -40
- package/dist/integrations.d.ts +2 -2
- package/dist/integrations.js +4 -4
- package/dist/kits-ui.d.ts +8 -10
- package/dist/kits-ui.js +29 -33
- package/dist/kits.js +1 -1
- package/dist/knowledge-ui.js +76 -19
- package/dist/ledger-view.d.ts +2 -0
- package/dist/ledger-view.js +41 -38
- package/dist/models-ui.d.ts +8 -3
- package/dist/models-ui.js +82 -59
- package/dist/monitoring-ui.d.ts +4 -2
- package/dist/monitoring-ui.js +44 -13
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +8 -0
- package/dist/operate-remote-arguments.js +1 -0
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +38 -4
- package/dist/passwordless.d.ts +1 -1
- package/dist/passwordless.js +1 -1
- package/dist/people-audit-ui.d.ts +2 -1
- package/dist/people-audit-ui.js +25 -23
- package/dist/policy-ui.d.ts +3 -2
- package/dist/policy-ui.js +27 -21
- package/dist/policy.d.ts +3 -0
- package/dist/policy.js +14 -1
- package/dist/project-delete-ui.d.ts +4 -2
- package/dist/project-delete-ui.js +135 -126
- package/dist/project-knowledge.d.ts +10 -10
- package/dist/publish.d.ts +11 -0
- package/dist/publish.js +16 -3
- package/dist/pull-request-ui.d.ts +2 -1
- package/dist/pull-request-ui.js +39 -28
- package/dist/recipe-ui.d.ts +4 -2
- package/dist/recipe-ui.js +14 -4
- package/dist/release-adapters.d.ts +3 -0
- package/dist/release-adapters.js +22 -2
- package/dist/release-cli.js +81 -9
- package/dist/release.d.ts +21 -0
- package/dist/release.js +40 -4
- package/dist/releases.js +1 -1
- package/dist/request-budget-ui.d.ts +4 -2
- package/dist/request-budget-ui.js +30 -19
- package/dist/retention-ui.d.ts +4 -3
- package/dist/retention-ui.js +15 -12
- package/dist/scope.d.ts +3 -0
- package/dist/scope.js +6 -0
- package/dist/serve.d.ts +0 -1
- package/dist/serve.js +51 -7
- package/dist/server/agents-computers.d.ts +9 -0
- package/dist/server/agents-computers.js +67 -0
- package/dist/server/chat.js +16 -16
- package/dist/server/chrome.d.ts +1 -1
- package/dist/server/chrome.js +19 -9
- package/dist/server/console.css +2 -2
- package/dist/server/flows.js +64 -36
- package/dist/server/home.d.ts +35 -0
- package/dist/server/home.js +104 -0
- package/dist/server/http.d.ts +3 -0
- package/dist/server/http.js +5 -0
- package/dist/server/kit-standalone.d.ts +22 -0
- package/dist/server/kit-standalone.js +69 -0
- package/dist/server/kit.d.ts +405 -0
- package/dist/server/kit.js +361 -0
- package/dist/server/pages.d.ts +2 -1
- package/dist/server/pages.js +213 -174
- package/dist/server/people-tokens.js +26 -85
- package/dist/server/render-history.d.ts +51 -0
- package/dist/server/render-history.js +228 -0
- package/dist/server/render-new-task.d.ts +29 -0
- package/dist/server/render-new-task.js +96 -0
- package/dist/server/render-next.d.ts +63 -0
- package/dist/server/render-next.js +109 -0
- package/dist/server/render-pages.d.ts +22 -190
- package/dist/server/render-pages.js +123 -575
- package/dist/server/render-people.d.ts +24 -3
- package/dist/server/render-people.js +93 -72
- package/dist/server/render-projects.d.ts +103 -0
- package/dist/server/render-projects.js +247 -0
- package/dist/server/render-results.d.ts +5 -24
- package/dist/server/render-results.js +48 -185
- package/dist/server/render-settings.d.ts +5 -74
- package/dist/server/render-settings.js +102 -493
- package/dist/server/render-tasks.d.ts +35 -5
- package/dist/server/render-tasks.js +114 -85
- package/dist/server/route-table.js +20 -5
- package/dist/server/settings.js +276 -255
- package/dist/server/task-views.d.ts +8 -0
- package/dist/server/task-views.js +85 -2
- package/dist/server/tasks.d.ts +8 -0
- package/dist/server/tasks.js +95 -40
- package/dist/server/terminals.js +1 -1
- package/dist/server/welcome.d.ts +22 -0
- package/dist/server/welcome.js +69 -0
- package/dist/settings-groups-ui.d.ts +279 -0
- package/dist/settings-groups-ui.js +615 -0
- package/dist/settings-kit.d.ts +42 -6
- package/dist/settings-kit.js +69 -7
- package/dist/setup-guide.d.ts +2 -2
- package/dist/skills-ui.js +59 -7
- package/dist/slack-settings.js +12 -5
- package/dist/spend-ui.d.ts +45 -26
- package/dist/spend-ui.js +124 -56
- package/dist/sso-ui.d.ts +5 -2
- package/dist/sso-ui.js +63 -31
- package/dist/storage-ui.d.ts +3 -2
- package/dist/storage-ui.js +56 -50
- package/dist/store.d.ts +112 -3
- package/dist/store.js +292 -20
- package/dist/surface.js +5 -1
- package/dist/teams-settings.d.ts +2 -1
- package/dist/teams-settings.js +24 -41
- package/dist/telegram-settings.d.ts +0 -4
- package/dist/telegram-settings.js +52 -25
- package/dist/toolroll-update-ui.d.ts +8 -4
- package/dist/toolroll-update-ui.js +55 -45
- package/dist/tools-ui.d.ts +5 -3
- package/dist/tools-ui.js +107 -42
- package/dist/work-index.js +11 -7
- package/dist/workspace-ui.d.ts +1 -1
- package/dist/workspace-ui.js +7 -4
- package/package.json +1 -1
package/dist/server/settings.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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 {
|
|
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,
|
|
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)
|
|
144
|
-
// operator's page.
|
|
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),
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
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, "
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
:
|
|
176
|
-
|
|
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
|
|
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",
|
|
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 =
|
|
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',
|
|
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",
|
|
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 =
|
|
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 ?
|
|
368
|
-
return sendScreen(response, 200, screen("Project",
|
|
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 →
|
|
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
|
|
381
|
-
|
|
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",
|
|
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
|
|
392
|
-
//
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
|
539
|
-
|
|
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 =
|
|
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
|
|
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',
|
|
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 =
|
|
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",
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
const
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
704
|
-
: await Promise.all(PROVIDER_IDS.filter(isKeyedProvider).map(async (provider) =>
|
|
705
|
-
provider,
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
} })()
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
})
|
|
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
|
|
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
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
const
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
894
|
-
return
|
|
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
|
|
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)}"
|
|
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
|
|
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 →
|
|
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',
|
|
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 `<
|
|
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
|
|
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
|
|
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"><
|
|
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",
|
|
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,
|
|
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?
|
|
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
|
-
|
|
2231
|
-
|
|
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
|
|
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
|
|
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 =
|
|
2337
|
-
|
|
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,
|
|
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
|
|
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
|
|
2835
|
-
|
|
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,
|