toolroll 0.9.83 → 0.9.85
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 +69 -0
- package/README.md +94 -1192
- package/dist/agent-fence.d.ts +3 -3
- package/dist/agent-fence.js +3 -3
- package/dist/agent-onboard.d.ts +2 -0
- package/dist/agent-onboard.js +3 -0
- package/dist/assignment-ui.js +5 -1
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -31
- package/dist/browser-crew.js +5 -1
- package/dist/browser-workspace.d.ts +12 -0
- package/dist/chat-channel.d.ts +10 -1
- package/dist/chat-channel.js +35 -5
- package/dist/chat-decide.d.ts +16 -4
- package/dist/chat-decide.js +239 -31
- package/dist/chat-messages.d.ts +5 -0
- package/dist/chat-messages.js +1 -1
- package/dist/chat-quiet.js +3 -2
- package/dist/chat-review.d.ts +4 -0
- package/dist/chat-review.js +13 -4
- package/dist/chat-terms.d.ts +9 -0
- package/dist/chat-terms.js +46 -0
- package/dist/contracts/console-api.d.ts +4 -2
- package/dist/contracts/console-api.js +7 -2
- package/dist/contracts/lead-tools.d.ts +30 -0
- package/dist/contracts/lead-tools.js +3 -0
- package/dist/contracts/youiyoux-request.d.ts +248 -0
- package/dist/contracts/youiyoux-request.js +152 -0
- package/dist/control-setup.d.ts +9 -0
- package/dist/control-setup.js +31 -4
- package/dist/control-ui.d.ts +7 -1
- package/dist/control-ui.js +3 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.d.ts +15 -0
- package/dist/dispatch.js +26 -4
- package/dist/first-run.d.ts +5 -2
- package/dist/first-run.js +13 -2
- package/dist/flow-cadence.d.ts +8 -2
- package/dist/flow-cadence.js +16 -3
- package/dist/flow-card-task.d.ts +9 -0
- package/dist/flow-card-task.js +15 -6
- package/dist/folder-look.d.ts +3 -0
- package/dist/folder-look.js +8 -4
- package/dist/guides.js +4 -3
- package/dist/integrations-ui.js +9 -4
- package/dist/integrations.d.ts +9 -1
- package/dist/integrations.js +43 -2
- package/dist/landed-cleanup.d.ts +4 -0
- package/dist/landed-cleanup.js +3 -2
- package/dist/lead-capabilities.js +2 -1
- package/dist/lead-cli.js +3 -1
- package/dist/lead-tools.d.ts +2 -0
- package/dist/lead-tools.js +30 -4
- package/dist/lead.d.ts +2 -0
- package/dist/lead.js +3 -3
- package/dist/model-catalog.d.ts +5 -0
- package/dist/model-catalog.js +11 -2
- package/dist/model-choices.d.ts +188 -0
- package/dist/model-choices.js +405 -0
- package/dist/model-names.d.ts +6 -0
- package/dist/model-names.js +34 -0
- package/dist/models-ui.d.ts +69 -5
- package/dist/models-ui.js +53 -10
- package/dist/needs-you.d.ts +8 -0
- package/dist/needs-you.js +8 -0
- package/dist/operate.d.ts +22 -2
- package/dist/operate.js +107 -41
- package/dist/project-activity.d.ts +20 -0
- package/dist/project-activity.js +23 -0
- package/dist/project-admission.d.ts +4 -1
- package/dist/project-admission.js +23 -2
- package/dist/project-cli.d.ts +1 -0
- package/dist/project-cli.js +36 -19
- package/dist/project-identity.d.ts +9 -2
- package/dist/project-identity.js +80 -33
- package/dist/project-knowledge.d.ts +9 -9
- package/dist/project-outcomes.d.ts +119 -0
- package/dist/project-outcomes.js +79 -0
- package/dist/result-actions.d.ts +4 -0
- package/dist/result-actions.js +41 -9
- package/dist/result-review.d.ts +9 -0
- package/dist/result-review.js +45 -5
- package/dist/serve.js +60 -13
- package/dist/server/chat.js +5 -4
- package/dist/server/chrome.d.ts +3 -0
- package/dist/server/chrome.js +5 -2
- package/dist/server/console.css +17 -2
- package/dist/server/flows.js +4 -0
- package/dist/server/http.d.ts +1 -0
- package/dist/server/http.js +6 -0
- package/dist/server/pages.js +53 -7
- package/dist/server/people-tokens.js +40 -20
- package/dist/server/render-chat.js +3 -2
- package/dist/server/render-pages.d.ts +12 -2
- package/dist/server/render-pages.js +36 -16
- package/dist/server/render-people.d.ts +14 -2
- package/dist/server/render-people.js +35 -12
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +20 -7
- package/dist/server/render-settings.js +1 -0
- package/dist/server/render-tasks.d.ts +1 -0
- package/dist/server/render-tasks.js +22 -19
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/route-table.js +9 -1
- package/dist/server/settings.d.ts +58 -1
- package/dist/server/settings.js +216 -70
- package/dist/server/signup-gate.d.ts +9 -0
- package/dist/server/signup-gate.js +40 -0
- package/dist/server/task-views.js +6 -0
- package/dist/server/tasks.js +54 -8
- package/dist/settings-kit-script.d.ts +21 -0
- package/dist/settings-kit-script.js +67 -0
- package/dist/settings-kit.d.ts +137 -0
- package/dist/settings-kit.js +186 -0
- package/dist/settings-proofs.d.ts +64 -0
- package/dist/settings-proofs.js +101 -0
- package/dist/setup-guide.js +4 -2
- package/dist/spend-report.d.ts +87 -0
- package/dist/spend-report.js +265 -0
- package/dist/spend-ui.d.ts +29 -8
- package/dist/spend-ui.js +74 -39
- package/dist/store.d.ts +46 -3
- package/dist/store.js +165 -6
- package/dist/task-sizing.d.ts +5 -3
- package/dist/task-sizing.js +4 -4
- package/dist/task-source.d.ts +13 -1
- package/dist/task-source.js +17 -1
- package/dist/telegram-lead.js +7 -5
- package/dist/telegram.js +1 -1
- package/dist/toolroll-update.js +2 -2
- package/dist/webhooks.d.ts +6 -0
- package/dist/webhooks.js +25 -0
- package/dist/work-index.d.ts +4 -0
- package/dist/work-index.js +32 -0
- package/dist/youiyoux-intake.d.ts +234 -0
- package/dist/youiyoux-intake.js +908 -0
- package/package.json +11 -8
package/dist/server/settings.js
CHANGED
|
@@ -17,7 +17,7 @@ import { grantTool } from "../subagent-tools.js";
|
|
|
17
17
|
import { learningHtml } from "../workspace-ui.js";
|
|
18
18
|
import { adapterPolicy } from "./route-policy.js";
|
|
19
19
|
import { createHash } from "node:crypto";
|
|
20
|
-
import { dirname, join } from "node:path";
|
|
20
|
+
import { basename, dirname, join } from "node:path";
|
|
21
21
|
import { INSTALLATION_SCOPE } from "../agentconfig.js";
|
|
22
22
|
import { rulesSummary } from "../approval-rules-ui.js";
|
|
23
23
|
import { installMethod } from "../install-method.js";
|
|
@@ -26,7 +26,7 @@ import { modeTermsFromJson } from "../modes.js";
|
|
|
26
26
|
import { envValue } from "../names.js";
|
|
27
27
|
import { REMOTE_MESSAGES } from "../operate-remote.js";
|
|
28
28
|
import { canonicalProject, projectName } from "../project.js";
|
|
29
|
-
import { ALL_CREDENTIAL_ENV } from "../provider.js";
|
|
29
|
+
import { ALL_CREDENTIAL_ENV, MONEY_CAPABILITIES } from "../provider.js";
|
|
30
30
|
import { updateChecksOff } from "../releases.js";
|
|
31
31
|
import { authenticateAccount } from "../scope.js";
|
|
32
32
|
import { budgetLabel, monthOf } from "../spend.js";
|
|
@@ -35,7 +35,7 @@ import { handlersOf } from './handler-registry.js';
|
|
|
35
35
|
import { refuse, screen } from "./chrome.js";
|
|
36
36
|
import { HANDOFF_STYLE, redirect, respond, safeReturn, sayTo, signInSpent, startedHere } from "./http.js";
|
|
37
37
|
import { html, htmlString, joinHtml, postForm, reauth } from "../html.js";
|
|
38
|
-
import { browserCaller } from "./session.js";
|
|
38
|
+
import { browserCaller, sessionWithinLifetime } from "./session.js";
|
|
39
39
|
/** settings handlers, moved without changing their route bodies. */
|
|
40
40
|
import { DEFAULT_ACCENT, normalHex } from "../accent-colors.js";
|
|
41
41
|
import { resolvePhaseAgent } from "../agentconfig.js";
|
|
@@ -64,6 +64,7 @@ import { startersHtml } from "../flow-starters-ui.js";
|
|
|
64
64
|
import { starterOf, startersFor, switchOnStarter } from "../flow-starters.js";
|
|
65
65
|
import { disconnectGoogle, googleConnected, googleConsent, readGoogleMail, saveGoogleClient } from "../google-mail.js";
|
|
66
66
|
import { integrationsHtml } from "../integrations-ui.js";
|
|
67
|
+
import { setYouiyouxIntake } from "../youiyoux-intake.js";
|
|
67
68
|
import { clearProviderKey, keyStatus, plausibleKey, PROVIDER_KEY_ENV, readAuthMode, readProviderKey, saveProviderKey, setAuthMode, SUBSCRIPTION_CAPABLE, verdictWords, verifyProviderKey } from "../keys.js";
|
|
68
69
|
import { decisionsHtml, framedSection, knowledgeHtml, MEMORY_INTRO, memorySearchHtml, proposalsHtml } from "../knowledge-ui.js";
|
|
69
70
|
import { aboutYouOf, checkAboutYou, saveAboutYou } from "../lead-about.js";
|
|
@@ -74,6 +75,9 @@ import { startConnect } from "../mcp-connect.js";
|
|
|
74
75
|
import { decideProposal, listProposals, memoryStatus } from "../memory-pass.js";
|
|
75
76
|
import { checkModels, isNewModel, modelOptions, modelWords, RUNTIME_TOOLS, runtimeStates, seenModels, setWatch, updateRuntime, watchState } from "../model-catalog.js";
|
|
76
77
|
import { modelsHtml, modelsScript } from "../models-ui.js";
|
|
78
|
+
import { ALL_SLOTS, applyModelChoices, browserWords, checkScope, DEFAULT_PRESET, isPresetName, newerModels, PRESETS, presetChange, presetInUse, previewModelChange, recentRuns, rowAt, slotKey, SLOTS, slotWords, stateDigest, switchChange } from "../model-choices.js";
|
|
79
|
+
import { HEADROOM_FIVE_HOUR_LIMIT, HEADROOM_WEEKLY_LIMIT, exactModelId } from "../phase-routing.js";
|
|
80
|
+
import { RISKY_MEANS, SIZE_MEANS, SIZING_CLAUDE_MODEL } from "../task-sizing.js";
|
|
77
81
|
import { CHAT_APPROVE_ALL, MODE_MAX_DAYS, modeDigestOf, modeTermsJson, modeWords, presetTerms } from "../modes.js";
|
|
78
82
|
import { monitoringChange, readMonitoring, saveMonitoring } from "../monitoring-settings.js";
|
|
79
83
|
import { monitoringHtml, signingSecretHtml } from "../monitoring-ui.js";
|
|
@@ -109,7 +113,9 @@ import { addProjectInstructions, ASSISTANTS, detectPreparation, modelChoices, pr
|
|
|
109
113
|
import { skillsHtml, skillsScript } from "../skills-ui.js";
|
|
110
114
|
import { checkSlackCredentials, clearSlackCredentials, loadSlackCredentials, saveSlackCredentials, SLACK_MANIFEST, SlackError } from "../slack-api.js";
|
|
111
115
|
import { slackSettingsHtml } from "../slack-settings.js";
|
|
112
|
-
import {
|
|
116
|
+
import { spendHtml, spendSettingsHtml } from "../spend-ui.js";
|
|
117
|
+
import { spendReport, spendReportCsv, spendRows } from "../spend-report.js";
|
|
118
|
+
import { limitsHtml, limitsView } from "../limits-ui.js";
|
|
113
119
|
import { budgetStates, monthNamed, spendItems, usd as spendUsd, subagentNames as subagentNamesOf } from "../spend.js";
|
|
114
120
|
import { removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from "../sso-settings.js";
|
|
115
121
|
import { ssoSettingsHtml } from "../sso-ui.js";
|
|
@@ -133,8 +139,9 @@ import { personChip, ROLE_TITLES } from "./chrome.js";
|
|
|
133
139
|
import { chatReturnWithSaid, goOutside } from "./http.js";
|
|
134
140
|
import { leadSettingsHtml, notificationProjects, SETTINGS_AUTOSAVE_SCRIPT, settingsPage, settingsWorkers, telegramDeliveryWords, telegramTrouble } from "./render-settings.js";
|
|
135
141
|
export function createSettingsHandlers(runtime) {
|
|
136
|
-
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,
|
|
137
|
-
//
|
|
142
|
+
const { identities, store, providerHome, managedRepos, consoleProjects, sendScreen, chromeFor, connectionCheck, connectionKnown, lastChecks, checkRelease, clock, modelCatalog, modelSeams, toolsViewOf, codexServers, projectViewOf, sessions, restricted, options, integrations, consoleOrigin, evidenceRoot, storagePool, visible, ssoSettings, checkLocalAgents, chatKeyFor, chatCatalog, chatCatalogKnown, leadWords, agentSignInCommand, settingsUpdates, phoneSetup, authenticateApprover, authenticateCredential, deletedRepos, projectOf, providerFor, admissionList, toolHome, connectVisits, mintApprovalNonce, consumeApprovalNonce, googleVisits } = runtime;
|
|
143
|
+
// What agent work cost, by month: API dollars, plan usage and unknown cost apart (spend-report.ts). An instance
|
|
144
|
+
// operator's page. Its limits show read-only; they change on Settings → Spend.
|
|
138
145
|
async function spendPage(ctx) {
|
|
139
146
|
const { url, who, response, now } = ctx;
|
|
140
147
|
const current = monthOf(now);
|
|
@@ -142,26 +149,17 @@ export function createSettingsHandlers(runtime) {
|
|
|
142
149
|
const month = asked === null ? current : monthNamed(asked);
|
|
143
150
|
if (month === null)
|
|
144
151
|
return refuse(response, who, 400, "Choose a month like 2026-09.", "/spend");
|
|
145
|
-
const
|
|
146
|
-
const subagents = store.subagents([...new Set([...managedRepos(), ...store.knownRepos()])]);
|
|
152
|
+
const rows = spendRows(store.handle, month.from, month.to);
|
|
147
153
|
const subagentNames = subagentNamesOf(store.handle);
|
|
148
154
|
if (url.searchParams.get("format") === "csv") {
|
|
149
155
|
response.writeHead(200, { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="standing-orders-spend-${month.name}.csv"`, "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
150
|
-
return void response.end(
|
|
156
|
+
return void response.end(spendReportCsv(rows, subagentNames));
|
|
151
157
|
}
|
|
152
158
|
const shift = (by) => { const at = new Date(`${month.name}-01T00:00:00.000Z`); at.setUTCMonth(at.getUTCMonth() + by); return at.toISOString().slice(0, 7); };
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
items, budgets: budgetStates(store.budgets(), items), subagentNames, csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
158
|
-
targets: [
|
|
159
|
-
{ value: "installation:*", label: "Everything", group: "Everything" },
|
|
160
|
-
...projects.map(repo => ({ value: `project:${repo}`, label: projectName(repo), group: "Projects" })),
|
|
161
|
-
...people.map(name => ({ value: `person:${name}`, label: name, group: "People" })),
|
|
162
|
-
...subagents.map(one => ({ value: `subagent:${one.id}`, label: `${subagentNames.get(one.id) ?? one.handle} (${projectName(one.repo)})`, group: "Subagents" })),
|
|
163
|
-
],
|
|
164
|
-
};
|
|
159
|
+
// Limits read what the budget gate counts today (spend.ts spendItems), for the current month.
|
|
160
|
+
const budgets = month.name === current.name ? budgetStates(store.budgets(), spendItems(store.handle, current.from, current.to)) : null;
|
|
161
|
+
const windows = month.name !== current.name ? html `` : limitsHtml(limitsView(store.providerLimits(), [], { project: projectName, subagent: id => subagentNames.get(id) ?? `Subagent ${id}` }, now));
|
|
162
|
+
const view = { month: month.name, previous: shift(-1), next: month.name === current.name ? null : shift(1), report: spendReport(rows), budgets, windows, subagentNames };
|
|
165
163
|
return sendScreen(response, 200, screen("Spend", spendHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }), { chrome: chromeFor(null, "spend") }));
|
|
166
164
|
}
|
|
167
165
|
async function modePage(ctx) {
|
|
@@ -248,7 +246,13 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
|
|
|
248
246
|
const instructions = previewProjectInstructions(project);
|
|
249
247
|
// The model list as last loaded; Refresh models posts, and the background checks keep it current.
|
|
250
248
|
const catalog = asked === "openrouter" ? modelCatalog.known(readProviderKey("openrouter", providerHome)) ?? { ok: false, problem: "The model list has not loaded yet." } : null;
|
|
251
|
-
|
|
249
|
+
// With nothing saved, setup preselects what the installation builds routine tasks with, else "Sized by job"'s
|
|
250
|
+
// routine builder (Sonnet 5.5) for Claude: an exact id, never a floating name.
|
|
251
|
+
const installationBuild = store.phaseConfig(INSTALLATION_SCOPE, "build");
|
|
252
|
+
const routineDefault = PRESETS[DEFAULT_PRESET].rows["build"] ?? "";
|
|
253
|
+
const preselected = installationBuild?.provider === asked && installationBuild.model !== null && models.some(one => one.value === installationBuild.model) ? installationBuild.model
|
|
254
|
+
: asked === "claude" && models.some(one => one.value === routineDefault) ? routineDefault : models[0]?.value ?? "";
|
|
255
|
+
const inputs = { provider: asked, model: saved?.provider === asked ? saved.model ?? "" : preselected, command: setup?.command ?? (saved === null ? preparation?.command ?? "" : ""), seconds: String((setup?.timeoutMs ?? 300_000) / 1000) };
|
|
252
256
|
return sendScreen(response, 200, screen("Project setup", controlSetupHtml({ repo: project, csrf: who.via === "cookie" ? who.session.csrf : "", provider: asked, inputs, models, connection, catalog, task, preparation,
|
|
253
257
|
instructions: instructions.ok ? { installed: instructions.installed } : { installed: false, message: instructions.message } }), { chrome: chromeFor(project, "settings"), ...(catalog === null ? {} : { functional: { script: openRouterPickerScript() } }) }));
|
|
254
258
|
};
|
|
@@ -301,7 +305,9 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
|
|
|
301
305
|
const now = clock();
|
|
302
306
|
// The page shows the last check and when it ran (docs/plans/get-side-effects.md): Check now, or the watch loop's
|
|
303
307
|
// automatic checks, fetch the lists and may queue a notice; opening the page never does.
|
|
304
|
-
|
|
308
|
+
// Only an instance operator changes models (docs/plans/spend-dashboard.md D4); a preset or a switch is previewed
|
|
309
|
+
// here, read-only, and applied by its own password-checked POST.
|
|
310
|
+
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);
|
|
305
311
|
return sendScreen(response, 200, screen("Models", html `<p><a href="/settings">Settings</a></p><h1>Models</h1>${modelsHtml(view)}`, { chrome: chromeFor(project, "settings"), functional: { script: modelsScript() } }));
|
|
306
312
|
}
|
|
307
313
|
async function skillsPage(ctx) {
|
|
@@ -867,7 +873,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
|
|
|
867
873
|
async function spendBudget(ctx) {
|
|
868
874
|
const { who, response, now, posted } = ctx;
|
|
869
875
|
const body = readForm(posted, CONSOLE_FORMS.spendBudget);
|
|
870
|
-
const back = sayTo(response, "/spend");
|
|
876
|
+
const back = sayTo(response, "/settings/spend");
|
|
871
877
|
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
872
878
|
return back("problem", "Enter your Toolroll password to change a budget.");
|
|
873
879
|
const target = /^(installation|project|person|subagent):(.+)$/.exec(body.get("target") ?? "");
|
|
@@ -1381,6 +1387,21 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
|
|
|
1381
1387
|
const switched = switchOnStarter(store, starter, repo, who.name, clock(), options.configDir ?? null);
|
|
1382
1388
|
return switched.ok ? back("said", switched.said) : back("problem", switched.said);
|
|
1383
1389
|
}
|
|
1390
|
+
// Settings → Integrations → youiyoux → Turn off / Turn on: whether a project's change requests become tasks.
|
|
1391
|
+
async function integrationsYouiyoux(ctx) {
|
|
1392
|
+
const { who, response, posted } = ctx;
|
|
1393
|
+
const body = readForm(posted, CONSOLE_FORMS.integrationsYouiyoux);
|
|
1394
|
+
const repo = body.get("repo") ?? "";
|
|
1395
|
+
const back = sayTo(response, "/settings/integrations");
|
|
1396
|
+
const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])];
|
|
1397
|
+
if (store.isDemo())
|
|
1398
|
+
return refuse(response, who, 403, "An approver turns youiyoux requests on or off.", "/settings/integrations");
|
|
1399
|
+
if (!known.includes(repo) || !visible(repo))
|
|
1400
|
+
return refuse(response, who, 404, "No such project.", "/settings/integrations");
|
|
1401
|
+
const on = body.get("on") === "1";
|
|
1402
|
+
setYouiyouxIntake(store, repo, on, who.name, clock());
|
|
1403
|
+
return back("said", on ? `youiyoux requests in ${basename(repo)} become tasks again.` : `youiyoux requests in ${basename(repo)} are off.`);
|
|
1404
|
+
}
|
|
1384
1405
|
// Settings → Integrations → Send test: one harmless read-only check of one integration, now.
|
|
1385
1406
|
async function integrationsTest(ctx) {
|
|
1386
1407
|
const { who, response, posted } = ctx;
|
|
@@ -1648,38 +1669,6 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
|
|
|
1648
1669
|
const updated = await updateRuntime(store, tool, who.name, now, modelSeams);
|
|
1649
1670
|
return back(updated.message, !updated.ok);
|
|
1650
1671
|
}
|
|
1651
|
-
if (action === "agent") {
|
|
1652
|
-
const phase = body.get("phase") ?? "", agent = body.get("agent") ?? "";
|
|
1653
|
-
if (!["plan", "build", "review", "repair"].includes(phase))
|
|
1654
|
-
return back("Choose a role.", true);
|
|
1655
|
-
const role = ROLE_TITLES[phase];
|
|
1656
|
-
if (agent === "inherit") {
|
|
1657
|
-
if (phase !== "repair")
|
|
1658
|
-
return back("Choose a model.", true);
|
|
1659
|
-
store.clearPhaseConfig(INSTALLATION_SCOPE, "repair");
|
|
1660
|
-
return back("Repairs now use the builder's agent.");
|
|
1661
|
-
}
|
|
1662
|
-
const split = agent.indexOf("|");
|
|
1663
|
-
const provider = split < 0 ? "" : agent.slice(0, split), model = split < 0 ? "" : agent.slice(split + 1);
|
|
1664
|
-
if (!isProviderId(provider) || !validModelId(model))
|
|
1665
|
-
return back("Choose a model from the list.", true);
|
|
1666
|
-
const valid = validateSpec({ provider, model });
|
|
1667
|
-
if (!valid.ok)
|
|
1668
|
-
return back(valid.problem, true);
|
|
1669
|
-
if (phase === "review" && provider === "gemini")
|
|
1670
|
-
return back("Gemini cannot review yet. Choose Claude or Codex.", true);
|
|
1671
|
-
const build = store.phaseConfig(INSTALLATION_SCOPE, "build");
|
|
1672
|
-
if (phase === "repair" && build !== null && build.provider !== provider)
|
|
1673
|
-
return back(`Repairs must use the builder's provider (${build.provider}).`, true);
|
|
1674
|
-
store.setPhaseConfig(INSTALLATION_SCOPE, phase, provider, model, who.name, now);
|
|
1675
|
-
const repair = store.phaseConfig(INSTALLATION_SCOPE, "repair");
|
|
1676
|
-
let extra = "";
|
|
1677
|
-
if (phase === "build" && repair !== null && repair.provider !== provider) {
|
|
1678
|
-
store.clearPhaseConfig(INSTALLATION_SCOPE, "repair");
|
|
1679
|
-
extra = " Repairs now follow the builder.";
|
|
1680
|
-
}
|
|
1681
|
-
return back(`${role} now uses ${modelWords(store, provider, model)} for new tasks.${extra}`);
|
|
1682
|
-
}
|
|
1683
1672
|
return refuse(response, who, 404, "No such models action.", "/settings/models");
|
|
1684
1673
|
};
|
|
1685
1674
|
async function knowledgeRefresh(ctx) {
|
|
@@ -1813,7 +1802,8 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
|
|
|
1813
1802
|
const nonceKey = `project-setup:${project}`;
|
|
1814
1803
|
if (step === "preview") {
|
|
1815
1804
|
const nonce = mintApprovalNonce(who.name, nonceKey, preview.fingerprint);
|
|
1816
|
-
|
|
1805
|
+
const models = preview.models === null ? null : { label: PRESETS[DEFAULT_PRESET].label, rows: preview.models.rows.map(row => ({ role: slotWords(row.slot), model: row.after?.model ?? (row.slot.phase === "repair" ? "same as the builder" : "not set") })) };
|
|
1806
|
+
return sendScreen(response, 200, screen("Approve project setup", setupPreviewHtml(inputs, { csrf, repo: project, fingerprint: preview.fingerprint, nonce }, models), { chrome: chromeFor(project, "settings") }));
|
|
1817
1807
|
}
|
|
1818
1808
|
if (who.via === "cookie" && !consumeApprovalNonce(body.get("nonce") ?? "", who.name, nonceKey, body.get("fingerprint") ?? ""))
|
|
1819
1809
|
return refuse(response, who, 409, "This setup preview expired. Review it again.", "/control");
|
|
@@ -2240,7 +2230,7 @@ ${postForm("/settings/chat-approval/save", html `\
|
|
|
2240
2230
|
const existing = loadBotToken({}, options.telegramTokenFile);
|
|
2241
2231
|
const hasEnv = (envValue(process.env, "TELEGRAM_TOKEN") ?? "") !== "";
|
|
2242
2232
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
2243
|
-
return sendScreen(response, 400, settingsPage(chromeFor(who.via === "cookie" ? who.session.project : 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, {
|
|
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, {
|
|
2244
2234
|
...store.permissionDefault(),
|
|
2245
2235
|
canManage: who.role === "approver",
|
|
2246
2236
|
}));
|
|
@@ -2433,8 +2423,9 @@ ${modeWords(terms)
|
|
|
2433
2423
|
store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, who.name, now);
|
|
2434
2424
|
return redirect(response, "/chat");
|
|
2435
2425
|
}
|
|
2436
|
-
/** Settings → Models, read from the saved catalog and the
|
|
2437
|
-
|
|
2426
|
+
/** Settings → Models, read from the saved catalog and the role rows at the scope being edited (the installation,
|
|
2427
|
+
* or one project and what it inherits). */
|
|
2428
|
+
function modelsView(canManage, csrf, now, said, problem, query = new URLSearchParams()) {
|
|
2438
2429
|
const runtimes = runtimeStates(store);
|
|
2439
2430
|
const installed = new Set(runtimes.map(one => one.tool));
|
|
2440
2431
|
const openrouter = (readProviderKey("openrouter", providerHome) ?? process.env["OPENROUTER_API_KEY"] ?? "") !== "";
|
|
@@ -2444,23 +2435,74 @@ ${modeWords(terms)
|
|
|
2444
2435
|
{ label: "Gemini", provider: "gemini", on: installed.has("gemini") },
|
|
2445
2436
|
{ label: "OpenRouter", provider: "openrouter", on: openrouter },
|
|
2446
2437
|
];
|
|
2447
|
-
const
|
|
2448
|
-
const
|
|
2449
|
-
|
|
2450
|
-
|
|
2438
|
+
const projects = consoleProjects();
|
|
2439
|
+
const asked = query.get("scope") ?? INSTALLATION_SCOPE;
|
|
2440
|
+
const scope = asked !== INSTALLATION_SCOPE && projects.includes(asked) ? asked : INSTALLATION_SCOPE;
|
|
2441
|
+
const repo = scope === INSTALLATION_SCOPE ? null : scope;
|
|
2442
|
+
const said_ = (pair) => `${ASSISTANTS[pair.provider]?.name ?? pair.provider} · ${modelWords(store, pair.provider, pair.model)}`;
|
|
2443
|
+
const build = resolvePhaseAgent(store, "build", repo, {});
|
|
2444
|
+
const roles = SLOTS.map(slot => {
|
|
2445
|
+
const own = rowAt(store, scope, slot);
|
|
2446
|
+
const inherited = repo === null ? null : rowAt(store, INSTALLATION_SCOPE, slot);
|
|
2447
|
+
const unset = repo !== null ? (inherited === null ? "Same as the installation (not set)" : `Same as the installation (${inherited.model ?? inherited.provider})`)
|
|
2448
|
+
: slot.phase === "repair" ? "Same as the builder" : slot.phase === "review" ? "Same as the planner"
|
|
2449
|
+
: slot.tier === "light" ? "Not set: small tasks use the routine builder" : slot.tier === "strong" ? "Not set: uses the routine one" : "Not set";
|
|
2450
|
+
const words = own !== null ? said_(own) : inherited !== null ? `Inherits the installation's: ${said_(inherited)}`
|
|
2451
|
+
: slot.phase === "repair" ? "Follows the builder" : slot.phase === "review" ? "Follows the planner" : slot.tier !== "routine" ? "Uses the routine one" : "Not set";
|
|
2452
|
+
const groups = makers.filter(one => one.on && !(slot.phase === "review" && one.provider === "gemini") && !(slot.phase === "repair" && build.ok && one.provider !== build.spec.provider))
|
|
2451
2453
|
.map(one => ({ label: one.label, provider: one.provider, options: modelOptions(store, one.provider, now, providerHome) }));
|
|
2452
|
-
return {
|
|
2453
|
-
phase, label: ROLE_TITLES[phase], groups, ...(phase === "repair" ? { inherit: true } : {}),
|
|
2454
|
-
current: row === null ? (phase === "repair" ? "inherit" : "") : `${row.provider}|${row.model ?? ""}`,
|
|
2455
|
-
words: row === null ? (phase === "repair" ? "Same as the builder" : "Not set") : `${ASSISTANTS[row.provider]?.name ?? row.provider} · ${modelWords(store, row.provider, row.model)}`,
|
|
2456
|
-
};
|
|
2454
|
+
return { key: slotKey(slot), label: slotWords(slot), current: own === null ? "" : `${own.provider}|${own.model ?? ""}`, words, unset, groups, tier: slot.tier !== "routine" };
|
|
2457
2455
|
});
|
|
2456
|
+
const inUse = presetInUse(store, scope);
|
|
2457
|
+
const unchosen = repo === null && ALL_SLOTS.every(slot => rowAt(store, INSTALLATION_SCOPE, slot) === null);
|
|
2458
|
+
const checked = checkScope(store, repo);
|
|
2459
|
+
// A preset or a switch, previewed: the exact rows it moves, with what a change of provider brings along.
|
|
2460
|
+
const pairWords = (pair, slot) => pair === null ? (repo !== null ? "same as the installation" : slot.phase === "repair" ? "same as the builder" : slot.phase === "review" ? "same as the planner" : slot.tier === "light" ? "routine builder" : slot.tier === "strong" ? "routine one" : "not set") : (pair.model ?? `${pair.provider} default`);
|
|
2461
|
+
const switchFrom = query.get("switch"), switchTo = query.get("to");
|
|
2462
|
+
const presetAsked = query.get("preset");
|
|
2463
|
+
// The rows, what they inherit, and both digests come from one snapshot (model-choices.ts previewModelChange).
|
|
2464
|
+
const previewOf = (title, change, hidden) => {
|
|
2465
|
+
const snapshot = previewModelChange(store, scope, change);
|
|
2466
|
+
const rows = snapshot.rows.map(row => {
|
|
2467
|
+
const notes = [];
|
|
2468
|
+
const was = row.before?.provider ?? row.inherited?.provider ?? null;
|
|
2469
|
+
if (row.after !== null && was !== null && was !== row.after.provider && isProviderId(row.after.provider)) {
|
|
2470
|
+
const name = ASSISTANTS[row.after.provider].name;
|
|
2471
|
+
notes.push(`Billed the way ${name} is signed in on this computer.`);
|
|
2472
|
+
notes.push(MONEY_CAPABILITIES[row.after.provider].nativeDollarCapFlag === null ? `${name} can't hold a per-task dollar cap.` : `${name} can hold a per-task dollar cap.`);
|
|
2473
|
+
notes.push(`Builds reach ${name}'s hosts instead of ${ASSISTANTS[was]?.name ?? was}'s.`);
|
|
2474
|
+
}
|
|
2475
|
+
return { role: slotWords(row.slot), before: pairWords(row.before, row.slot), after: pairWords(row.after, row.slot), notes };
|
|
2476
|
+
});
|
|
2477
|
+
return { title, rows, hidden: { scope, digest: snapshot.state, change: snapshot.change, ...hidden } };
|
|
2478
|
+
};
|
|
2479
|
+
const preview = !canManage ? null
|
|
2480
|
+
: isPresetName(presetAsked) ? previewOf(`Use ${PRESETS[presetAsked].label}`, presetChange(presetAsked), { preset: presetAsked })
|
|
2481
|
+
: switchFrom !== null && switchTo !== null && exactModelId(switchFrom) && exactModelId(switchTo) ? previewOf(`Switch to ${switchTo}`, switchChange(store, scope, switchFrom, switchTo), { switch: switchFrom, to: switchTo })
|
|
2482
|
+
// A new installation with nothing chosen yet opens on the default, ready to apply.
|
|
2483
|
+
: unchosen ? previewOf(`Start with ${PRESETS[DEFAULT_PRESET].label}`, presetChange(DEFAULT_PRESET), { preset: DEFAULT_PRESET })
|
|
2484
|
+
: null;
|
|
2458
2485
|
const chat = store.getChatConfig();
|
|
2459
2486
|
const chatName = chat === null ? "" : chat.provider === "claude-subscription" ? "Claude membership" : chat.provider === "codex-subscription" ? "Codex membership" : chat.provider === "anthropic-api" ? "Anthropic API" : "OpenRouter";
|
|
2487
|
+
const capital = (words) => `${words.charAt(0).toUpperCase()}${words.slice(1)}`;
|
|
2488
|
+
const alsoSet = store.listPhaseTierAlternates(INSTALLATION_SCOPE).length + (repo === null ? 0 : store.listPhaseTierAlternates(repo).length) > 0;
|
|
2460
2489
|
return {
|
|
2461
2490
|
runtimes, watch: watchState(store), roles, csrf, canManage, said, problem,
|
|
2462
2491
|
chat: chat === null ? null : { words: `Leads and chat use ${chatName} · ${modelWords(store, chat.provider, chat.model)}` },
|
|
2463
2492
|
fresh: ["claude", "codex", "gemini"].flatMap(source => seenModels(store, source)).filter(model => isNewModel(model, now)).slice(0, 12),
|
|
2493
|
+
scope: { value: scope, label: repo === null ? "All projects" : projectName(repo), digest: stateDigest(store, scope),
|
|
2494
|
+
options: [{ value: INSTALLATION_SCOPE, label: "All projects" }, ...projects.map(one => ({ value: one, label: projectName(one) }))] },
|
|
2495
|
+
inUse: unchosen ? "nothing chosen yet" : inUse === null ? null : PRESETS[inUse].label,
|
|
2496
|
+
presets: ["sized", "opus"].map(name => ({ name, label: PRESETS[name].label, words: PRESETS[name].words, inUse: inUse === name, recommended: name === DEFAULT_PRESET })),
|
|
2497
|
+
newer: newerModels(store, scope).map(one => ({ from: one.from, to: one.to.id, name: one.to.name, roles: one.slots.map(slotWords).join(", ").toLowerCase() })),
|
|
2498
|
+
preview, unresolved: checked.kind === "ok" ? null : checked.kind === "incomplete" ? `some roles have no model yet. Choose them above, or use ${PRESETS[DEFAULT_PRESET].label}.` : browserWords(checked.problem),
|
|
2499
|
+
sizing: { small: capital(SIZE_MEANS.small), routine: capital(SIZE_MEANS.medium), large: SIZE_MEANS.large, risky: RISKY_MEANS,
|
|
2500
|
+
classifier: readProviderKey("openrouter", providerHome) !== null ? "A quick AI check (Jev, through your OpenRouter key) reads its title and description." : `A quick AI check (${SIZING_CLAUDE_MODEL}, on this computer's Claude sign-in) reads its title and description.` },
|
|
2501
|
+
routing: `When a task is filed and Claude has used over ${HEADROOM_FIVE_HOUR_LIMIT}% of its 5-hour window or ${HEADROOM_WEEKLY_LIMIT}% of its week, ${alsoSet ? "the other provider you set for that size is preferred if it has more room" : "an alternate provider with more room would be preferred, but none is set"}; with no room anywhere, the first choice is kept. This is a preference at filing time, not a limit.`,
|
|
2502
|
+
recent: recentRuns(store).map(one => ({ taskId: one.taskId, runId: one.runId, role: one.role,
|
|
2503
|
+
chosen: one.chosen === null ? "its default" : /^(opus|sonnet|haiku|fable)$/.test(one.chosen) ? `floating ${one.chosen}` : one.chosen,
|
|
2504
|
+
ranOn: one.ranOn ?? "not reported" })),
|
|
2505
|
+
waiting: canManage ? store.scopesAwaitingApproval(null, 10, admissionList()).map(one => ({ taskId: one.taskId, title: one.title })) : [],
|
|
2464
2506
|
};
|
|
2465
2507
|
}
|
|
2466
2508
|
/** Google's redirect back: the code becomes a saved refresh token, and a page sends the person back to Settings. */
|
|
@@ -2546,6 +2588,7 @@ ${modeWords(terms)
|
|
|
2546
2588
|
}
|
|
2547
2589
|
const registrations = handlersOf("settings", {
|
|
2548
2590
|
"spend": spendPage,
|
|
2591
|
+
"settings.spend": spendSettingsPage,
|
|
2549
2592
|
"mode": modePage,
|
|
2550
2593
|
"control": controlPage("setup"),
|
|
2551
2594
|
"control.connection": controlPage("connection"),
|
|
@@ -2615,6 +2658,7 @@ ${modeWords(terms)
|
|
|
2615
2658
|
"settings.backups-send": backupsSave,
|
|
2616
2659
|
"settings.data-send": dataExport,
|
|
2617
2660
|
"settings.integrations-test-send": integrationsTest,
|
|
2661
|
+
"settings.integrations-youiyoux": integrationsYouiyoux,
|
|
2618
2662
|
"settings.monitoring-send": monitoringSave,
|
|
2619
2663
|
"settings.tools-connect-send": toolsConnect,
|
|
2620
2664
|
"settings.tools-change-send": toolsChange,
|
|
@@ -2623,7 +2667,8 @@ ${modeWords(terms)
|
|
|
2623
2667
|
"settings.models-check": modelsAct("check"),
|
|
2624
2668
|
"settings.models-watch": modelsAct("watch"),
|
|
2625
2669
|
"settings.models-update": modelsAct("update"),
|
|
2626
|
-
"settings.models-agent":
|
|
2670
|
+
"settings.models-agent": modelsSave,
|
|
2671
|
+
"settings.models-apply": modelsApply,
|
|
2627
2672
|
"settings.models-act": modelsAct("other"),
|
|
2628
2673
|
"settings.knowledge-refresh": knowledgeRefresh,
|
|
2629
2674
|
"settings.knowledge-proposal": knowledgeProposal,
|
|
@@ -2783,5 +2828,106 @@ ${modeWords(terms)
|
|
|
2783
2828
|
const changed = setReleaseApproval(store, repo, true, who.name, now);
|
|
2784
2829
|
return back("said", changed.changed ? "Releases approve themselves once CI and the release check pass." : "Saved.");
|
|
2785
2830
|
}
|
|
2831
|
+
// Settings → Spend: the monthly limits editor and the models in use. An instance operator's page; changing a limit
|
|
2832
|
+
// posts to /spend/budget with a step-up.
|
|
2833
|
+
async function spendSettingsPage(ctx) {
|
|
2834
|
+
const { url, response, now } = ctx;
|
|
2835
|
+
const current = monthOf(now);
|
|
2836
|
+
const subagents = store.subagents([...new Set([...managedRepos(), ...store.knownRepos()])]);
|
|
2837
|
+
const subagentNames = subagentNamesOf(store.handle);
|
|
2838
|
+
const projects = consoleProjects();
|
|
2839
|
+
const people = store.accountFacts().filter(one => one.revokedAt === null).map(one => one.name);
|
|
2840
|
+
const models = ["plan", "build", "review"].map(phase => {
|
|
2841
|
+
const agent = resolvePhaseAgent(store, phase, null, {});
|
|
2842
|
+
return { role: phase === "plan" ? "Planner" : phase === "build" ? "Builder" : "Reviewer", model: agent.ok ? `${agent.spec.provider} · ${agent.spec.model ?? "its default model"}` : "Not set" };
|
|
2843
|
+
});
|
|
2844
|
+
const view = {
|
|
2845
|
+
budgets: budgetStates(store.budgets(), spendItems(store.handle, current.from, current.to)), subagentNames, models,
|
|
2846
|
+
targets: [
|
|
2847
|
+
{ value: "installation:*", label: "Everything", group: "Everything" },
|
|
2848
|
+
...projects.map(repo => ({ value: `project:${repo}`, label: projectName(repo), group: "Projects" })),
|
|
2849
|
+
...people.map(name => ({ value: `person:${name}`, label: name, group: "People" })),
|
|
2850
|
+
...subagents.map(one => ({ value: `subagent:${one.id}`, label: `${subagentNames.get(one.id) ?? one.handle} (${projectName(one.repo)})`, group: "Subagents" })),
|
|
2851
|
+
],
|
|
2852
|
+
};
|
|
2853
|
+
return sendScreen(response, 200, screen("Spend settings", spendSettingsHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }), { chrome: chromeFor(null, "settings") }));
|
|
2854
|
+
}
|
|
2855
|
+
// Settings → Models (docs/plans/spend-dashboard.md, slice 3): the one editor. Both posts are an instance operator's,
|
|
2856
|
+
// browser-only step-ups: the password (or a Passwordless session, or an identity-provider re-check) is proved here,
|
|
2857
|
+
// and model-choices.ts re-proves the same session, credential generation and operator standing inside the one
|
|
2858
|
+
// transaction that checks the form against what is stored, validates the result and writes and ledgers every row.
|
|
2859
|
+
function modelsAuthority(who, method) {
|
|
2860
|
+
const session = who.session;
|
|
2861
|
+
return { actor: who.name, generation: session.generation, method, live: () => sessions.holds(session) && sessions.savedNow(session) && sessionWithinLifetime(session, Date.now()) };
|
|
2862
|
+
}
|
|
2863
|
+
function modelsBack(response, scope) {
|
|
2864
|
+
return sayTo(response, `/settings/models${scope === INSTALLATION_SCOPE ? "" : `?scope=${encodeURIComponent(scope)}`}`);
|
|
2865
|
+
}
|
|
2866
|
+
function modelsSaid(result, what) {
|
|
2867
|
+
if (!result.ok)
|
|
2868
|
+
return { key: "problem", words: browserWords(result.problem) };
|
|
2869
|
+
const count = result.diff.length;
|
|
2870
|
+
const done = count === 0 ? "Nothing changed." : `${what}: ${count} change${count === 1 ? "" : "s"} saved for tasks filed from now on.`;
|
|
2871
|
+
return { key: "said", words: result.unresolved === null ? done : `${done} Some roles have no model yet, so new tasks can't be filed here.` };
|
|
2872
|
+
}
|
|
2873
|
+
/** One save of the whole editor: every role at the scope, sets and clears, as one change. */
|
|
2874
|
+
async function modelsSave(ctx) {
|
|
2875
|
+
const { response, now, posted } = ctx;
|
|
2876
|
+
const who = browserCaller(ctx.who);
|
|
2877
|
+
const body = readForm(posted, CONSOLE_FORMS.modelsChange);
|
|
2878
|
+
const scope = body.get("scope") ?? INSTALLATION_SCOPE;
|
|
2879
|
+
const back = modelsBack(response, scope);
|
|
2880
|
+
if (["scope", "password", "digest", ...SLOTS.map(slotKey)].some(field => body.sent.getAll(field).length > 1))
|
|
2881
|
+
return back("problem", "That form was sent twice over. Nothing changed.");
|
|
2882
|
+
const proved = authenticateApprover(who, body.get("password") ?? "");
|
|
2883
|
+
if (!proved.ok)
|
|
2884
|
+
return back("problem", "Enter your Toolroll password to change models.");
|
|
2885
|
+
const change = [];
|
|
2886
|
+
for (const slot of SLOTS) {
|
|
2887
|
+
const value = body.sent.get(slotKey(slot));
|
|
2888
|
+
if (value === null)
|
|
2889
|
+
continue;
|
|
2890
|
+
if (value === "clear") {
|
|
2891
|
+
change.push({ slot, to: null });
|
|
2892
|
+
continue;
|
|
2893
|
+
}
|
|
2894
|
+
const split = value.indexOf("|");
|
|
2895
|
+
if (split < 1)
|
|
2896
|
+
return back("problem", "Choose a model from the list. Nothing changed.");
|
|
2897
|
+
change.push({ slot, to: { provider: value.slice(0, split), model: value.slice(split + 1) } });
|
|
2898
|
+
}
|
|
2899
|
+
// Repairs resume the builder's session: a builder moved to another provider takes repair with it.
|
|
2900
|
+
const builder = change.find(one => one.slot.phase === "build" && one.slot.tier === "routine")?.to ?? null;
|
|
2901
|
+
const repair = change.findIndex(one => one.slot.phase === "repair");
|
|
2902
|
+
let follows = false;
|
|
2903
|
+
if (builder !== null && repair >= 0 && change[repair].to !== null && change[repair].to.provider !== builder.provider) {
|
|
2904
|
+
change[repair] = { slot: change[repair].slot, to: null };
|
|
2905
|
+
follows = true;
|
|
2906
|
+
}
|
|
2907
|
+
const result = applyModelChoices(store, { scope, change, expected: { state: body.get("digest") ?? "" }, strict: true, knownProject: repo => consoleProjects().includes(repo) }, modelsAuthority(who, proved.method), now);
|
|
2908
|
+
const said = modelsSaid(result, scope === INSTALLATION_SCOPE ? "All projects" : projectName(scope));
|
|
2909
|
+
return back(said.key, follows && result.ok ? `${said.words} Repairs now follow the builder.` : said.words);
|
|
2910
|
+
}
|
|
2911
|
+
/** A preset or a switch, applied exactly as previewed: the change's own digest rides the form. */
|
|
2912
|
+
async function modelsApply(ctx) {
|
|
2913
|
+
const { response, now, posted } = ctx;
|
|
2914
|
+
const who = browserCaller(ctx.who);
|
|
2915
|
+
const body = readForm(posted, CONSOLE_FORMS.modelsChange);
|
|
2916
|
+
const scope = body.get("scope") ?? INSTALLATION_SCOPE;
|
|
2917
|
+
const back = modelsBack(response, scope);
|
|
2918
|
+
if (["scope", "password", "digest", "preset", "switch", "to", "change"].some(field => body.sent.getAll(field).length > 1))
|
|
2919
|
+
return back("problem", "That form was sent twice over. Nothing changed.");
|
|
2920
|
+
const proved = authenticateApprover(who, body.get("password") ?? "");
|
|
2921
|
+
if (!proved.ok)
|
|
2922
|
+
return back("problem", "Enter your Toolroll password to change models.");
|
|
2923
|
+
const preset = body.get("preset"), from = body.get("switch"), to = body.get("to");
|
|
2924
|
+
const change = isPresetName(preset) ? presetChange(preset)
|
|
2925
|
+
: from !== null && to !== null && exactModelId(from) && exactModelId(to) ? switchChange(store, scope, from, to) : null;
|
|
2926
|
+
if (change === null)
|
|
2927
|
+
return back("problem", "Choose a preset or a newer model. Nothing changed.");
|
|
2928
|
+
const result = applyModelChoices(store, { scope, change, expected: { state: body.get("digest") ?? "", change: body.get("change") ?? "" }, strict: true, knownProject: repo => consoleProjects().includes(repo) }, modelsAuthority(who, proved.method), now);
|
|
2929
|
+
const said = modelsSaid(result, isPresetName(preset) ? PRESETS[preset].label : `Switched to ${to}`);
|
|
2930
|
+
return back(said.key, said.words);
|
|
2931
|
+
}
|
|
2786
2932
|
return { registrations, modelsView };
|
|
2787
2933
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
export declare const SIGNUP_COOKIE = "so-signup";
|
|
3
|
+
/** A fresh Set-Cookie value for the page that shows the form. */
|
|
4
|
+
export declare function signupCookie(secure: string): string;
|
|
5
|
+
/** Whether this post came from the console's own first-account page. */
|
|
6
|
+
export declare function signupFromHere(request: IncomingMessage, allowedHost: (host: string | undefined) => boolean): boolean;
|
|
7
|
+
/** The typed code against the printed one, in constant time over fixed-length digests: any input (empty, too long,
|
|
8
|
+
* non-ASCII) is an ordinary wrong code, never a throw. */
|
|
9
|
+
export declare function setupCodeMatches(typed: string, printed: string): boolean;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** The first-account form's same-site gate and setup-code comparison (Settings revamp S3, Codex round 1).
|
|
2
|
+
*
|
|
3
|
+
* The form is signed out, so it has no session CSRF token. Instead, the page that shows it sets a signed, HttpOnly,
|
|
4
|
+
* SameSite=Strict cookie scoped to /signup; a browser never sends it on a post from another site. A post must carry
|
|
5
|
+
* it, and its provenance must name this console: same-origin Fetch Metadata, or an Origin (or Referer) on an allowed
|
|
6
|
+
* host. Neither (`Origin: null`, nothing else) fails closed; the form's page is served `Referrer-Policy: same-origin`
|
|
7
|
+
* so its own post carries the real Origin. A refusal here never spends a setup-code try. */
|
|
8
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
9
|
+
export const SIGNUP_COOKIE = "so-signup";
|
|
10
|
+
const SECRET = randomBytes(32);
|
|
11
|
+
const NONCE = /^[A-Za-z0-9_-]{24}$/;
|
|
12
|
+
const macOf = (nonce) => createHmac("sha256", SECRET).update(`signup:${nonce}`).digest();
|
|
13
|
+
/** A fresh Set-Cookie value for the page that shows the form. */
|
|
14
|
+
export function signupCookie(secure) {
|
|
15
|
+
const nonce = randomBytes(18).toString("base64url");
|
|
16
|
+
return `${SIGNUP_COOKIE}=${nonce}.${macOf(nonce).toString("base64url")}; HttpOnly; SameSite=Strict; Path=/signup${secure}`;
|
|
17
|
+
}
|
|
18
|
+
/** Whether this post came from the console's own first-account page. */
|
|
19
|
+
export function signupFromHere(request, allowedHost) {
|
|
20
|
+
const site = request.headers["sec-fetch-site"];
|
|
21
|
+
if (site !== undefined && site !== "same-origin" && site !== "none")
|
|
22
|
+
return false;
|
|
23
|
+
const origin = request.headers.origin, referer = request.headers.referer;
|
|
24
|
+
const named = typeof origin === "string" && origin !== "null" ? origin : typeof referer === "string" ? referer : null;
|
|
25
|
+
if (named !== null && !allowedHost(named.replace(/^https?:\/\//, "").split("/")[0]))
|
|
26
|
+
return false;
|
|
27
|
+
if (site !== "same-origin" && named === null)
|
|
28
|
+
return false;
|
|
29
|
+
const held = new RegExp(`(?:^|;\\s*)${SIGNUP_COOKIE}=([^;.]*)\\.([^;]*)`).exec(request.headers.cookie ?? "");
|
|
30
|
+
if (held === null || !NONCE.test(held[1]))
|
|
31
|
+
return false;
|
|
32
|
+
const given = Buffer.from(held[2], "base64url"), wanted = macOf(held[1]);
|
|
33
|
+
return given.length === wanted.length && timingSafeEqual(given, wanted);
|
|
34
|
+
}
|
|
35
|
+
/** The typed code against the printed one, in constant time over fixed-length digests: any input (empty, too long,
|
|
36
|
+
* non-ASCII) is an ordinary wrong code, never a throw. */
|
|
37
|
+
export function setupCodeMatches(typed, printed) {
|
|
38
|
+
const digest = (text) => createHash("sha256").update(text, "utf8").digest();
|
|
39
|
+
return typed !== "" && timingSafeEqual(digest(typed), digest(printed));
|
|
40
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { feedbackImageProblem } from "../result-actions.js";
|
|
2
|
+
import { parseFeedbackImage } from "../result-review.js";
|
|
1
3
|
import { releaseViewOf } from "../release-view.js";
|
|
2
4
|
import { releaseCiRunningOf } from "../release-ci-hold.js";
|
|
3
5
|
import { releaseObligationsOf } from "../release-recheck.js";
|
|
@@ -229,6 +231,7 @@ export function createTaskViews(runtime) {
|
|
|
229
231
|
line: one.line === null || one.line === undefined ? null : Number(one.line),
|
|
230
232
|
note: String(one.note ?? ""),
|
|
231
233
|
author: String(one.author ?? "?"),
|
|
234
|
+
image: parseFeedbackImage(one.image) || null,
|
|
232
235
|
})),
|
|
233
236
|
};
|
|
234
237
|
}
|
|
@@ -1119,6 +1122,9 @@ export function createTaskViews(runtime) {
|
|
|
1119
1122
|
outsideTouches: files.filter(one => one.outsideTouches).map(one => one.path),
|
|
1120
1123
|
fileAnchors,
|
|
1121
1124
|
comments: runtime.store.liveDiffComments(run.id).filter(isRevisionFeedback),
|
|
1125
|
+
// The same check that refuses a saved note's screenshot at revision time decides when it can be removed.
|
|
1126
|
+
unusableImageNotes: runtime.store.liveDiffComments(run.id).filter(one => isRevisionFeedback(one) && one.image != null
|
|
1127
|
+
&& feedbackImageProblem(runtime.store, runtime.evidenceRoot, run.id, one.image) !== null).map(one => one.id),
|
|
1122
1128
|
pastComments: runtime.store.allDiffComments(run.id).filter(one => one.reviewerRun === null && (one.consumedBy !== null || one.supersededBy !== null)),
|
|
1123
1129
|
reviewerFindings: runtime.store.allDiffComments(run.id).filter(one => one.reviewerRun !== null),
|
|
1124
1130
|
automaticReview: buildReviewOf(runtime.store, run.id),
|