toolroll 0.9.83 → 0.9.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +4 -3
  3. package/dist/assignment-ui.js +5 -1
  4. package/dist/browser/workspace.css +1 -1
  5. package/dist/browser/workspace.js +22 -22
  6. package/dist/browser-crew.js +5 -1
  7. package/dist/browser-workspace.d.ts +7 -0
  8. package/dist/contracts/console-api.d.ts +2 -1
  9. package/dist/contracts/console-api.js +5 -1
  10. package/dist/control-setup.d.ts +9 -0
  11. package/dist/control-setup.js +31 -4
  12. package/dist/control-ui.d.ts +7 -1
  13. package/dist/control-ui.js +3 -2
  14. package/dist/desktop-update.d.ts +1 -1
  15. package/dist/dispatch.d.ts +15 -0
  16. package/dist/dispatch.js +26 -4
  17. package/dist/first-run.d.ts +5 -2
  18. package/dist/first-run.js +13 -2
  19. package/dist/folder-look.d.ts +3 -0
  20. package/dist/folder-look.js +8 -4
  21. package/dist/guides.js +4 -3
  22. package/dist/model-catalog.d.ts +5 -0
  23. package/dist/model-catalog.js +11 -2
  24. package/dist/model-choices.d.ts +188 -0
  25. package/dist/model-choices.js +405 -0
  26. package/dist/model-names.d.ts +6 -0
  27. package/dist/model-names.js +34 -0
  28. package/dist/models-ui.d.ts +69 -5
  29. package/dist/models-ui.js +53 -10
  30. package/dist/needs-you.d.ts +8 -0
  31. package/dist/needs-you.js +8 -0
  32. package/dist/operate.d.ts +21 -0
  33. package/dist/operate.js +96 -26
  34. package/dist/project-admission.d.ts +4 -1
  35. package/dist/project-admission.js +23 -2
  36. package/dist/project-identity.d.ts +9 -2
  37. package/dist/project-identity.js +80 -33
  38. package/dist/project-knowledge.d.ts +6 -6
  39. package/dist/serve.js +60 -13
  40. package/dist/server/chat.js +5 -4
  41. package/dist/server/chrome.d.ts +3 -0
  42. package/dist/server/chrome.js +5 -2
  43. package/dist/server/console.css +5 -2
  44. package/dist/server/flows.js +4 -0
  45. package/dist/server/http.d.ts +1 -0
  46. package/dist/server/http.js +6 -0
  47. package/dist/server/pages.js +42 -6
  48. package/dist/server/people-tokens.js +40 -20
  49. package/dist/server/render-pages.d.ts +10 -0
  50. package/dist/server/render-pages.js +29 -14
  51. package/dist/server/render-people.d.ts +14 -2
  52. package/dist/server/render-people.js +35 -12
  53. package/dist/server/render-settings.js +1 -0
  54. package/dist/server/render-tasks.js +7 -14
  55. package/dist/server/request-context.d.ts +2 -0
  56. package/dist/server/route-table.js +8 -1
  57. package/dist/server/settings.d.ts +58 -1
  58. package/dist/server/settings.js +198 -69
  59. package/dist/server/signup-gate.d.ts +9 -0
  60. package/dist/server/signup-gate.js +40 -0
  61. package/dist/server/tasks.js +12 -2
  62. package/dist/settings-kit-script.d.ts +21 -0
  63. package/dist/settings-kit-script.js +67 -0
  64. package/dist/settings-kit.d.ts +137 -0
  65. package/dist/settings-kit.js +186 -0
  66. package/dist/settings-proofs.d.ts +64 -0
  67. package/dist/settings-proofs.js +101 -0
  68. package/dist/setup-guide.js +4 -2
  69. package/dist/spend-report.d.ts +87 -0
  70. package/dist/spend-report.js +265 -0
  71. package/dist/spend-ui.d.ts +29 -8
  72. package/dist/spend-ui.js +74 -39
  73. package/dist/task-sizing.d.ts +5 -3
  74. package/dist/task-sizing.js +4 -4
  75. package/package.json +1 -1
@@ -133,12 +133,15 @@ get("pages", "workbench", "/workbench", free);
133
133
  get("tasks", "board", "/board", { limited: "collection" });
134
134
  get("tasks", "review", "/review", { limited: "collection" });
135
135
  get("settings", "spend", "/spend", { ...free, ...anyOperator("An instance operator sees spend.", "/") });
136
+ get("settings", "settings.spend", "/settings/spend", { ...free, ...anyOperator("An instance operator sets spending limits.", "/settings") });
136
137
  get("pages", "ledger", "/ledger", { limited: "unscoped", ...free });
137
138
  get("pages", "activity", "/activity");
138
139
  get("tasks", "done", "/done", { limited: "collection" });
139
140
  get("settings", "mode", "/mode");
140
141
  get("people", "people.page", "/people", { limited: "unscoped", ...free });
141
142
  get("pages", "system", "/system");
143
+ // Where every "Connect a builder" leads: read-only, open to a project-limited person (the handler shows only a visible project).
144
+ get("pages", "start-builder", "/start-builder", { limited: "unscoped", ...free });
142
145
  get("tasks", "tasks", "/tasks", { limited: "collection", ...free }); // a redirect to /work now: never sent to the project opener first
143
146
  get("tasks", "queue", "/queue");
144
147
  get("pages", "peek", "/peek", free);
@@ -334,8 +337,12 @@ for (const one of ["policy", "approval", "request-limits", "sessions", "sign-in"
334
337
  }
335
338
  post("settings", "settings.flows-on", "/settings/flows/on", { limited: "collection", ...approver("An approver switches starter flows on.", "/settings/flows") });
336
339
  const MODELS = approver("Sign in as an approver to change models.", "/settings/models");
337
- for (const one of ["check", "watch", "update", "agent"])
340
+ for (const one of ["check", "watch", "update"])
338
341
  post("settings", `settings.models-${one}`, `/settings/models/${one}`, MODELS);
342
+ // Changing which model a role uses: an instance operator's browser step-up (the handler proves the password, or the
343
+ // Passwordless session; model-choices.ts re-proves the session and standing inside its one transaction).
344
+ for (const one of ["agent", "apply"])
345
+ post("settings", `settings.models-${one}`, `/settings/models/${one}`, { scope: "step-up", ...operator("An instance operator changes models.", "/settings/models") });
339
346
  // Any other models action answers the models area's own refusal.
340
347
  post("settings", "settings.models-act", "^\\/settings\\/models\\/.*$", { sample: "/settings/models/unknown", ...MODELS });
341
348
  post("settings", "settings.knowledge-refresh", "/settings/knowledge/refresh", { project: "form", limited: "unscoped", ...approver("Sign in as an approver to refresh project context.", "/settings/knowledge") });
@@ -2,7 +2,7 @@ import type { ServerRuntime } from './runtime.js';
2
2
  import { type RoleView } from "../models-ui.js";
3
3
  export declare function createSettingsHandlers(runtime: ServerRuntime): {
4
4
  registrations: import("./handler-registry.js").Registration[];
5
- modelsView: (canManage: boolean, csrf: string, now: Date, said: string | null, problem: string | null) => {
5
+ modelsView: (canManage: boolean, csrf: string, now: Date, said: string | null, problem: string | null, query?: URLSearchParams) => {
6
6
  runtimes: import("../model-catalog.js").RuntimeState[];
7
7
  watch: import("../model-catalog.js").WatchState;
8
8
  roles: RoleView[];
@@ -14,5 +14,62 @@ export declare function createSettingsHandlers(runtime: ServerRuntime): {
14
14
  words: string;
15
15
  } | null;
16
16
  fresh: import("../model-catalog.js").SeenModel[];
17
+ scope: {
18
+ value: string;
19
+ label: string;
20
+ digest: string;
21
+ options: {
22
+ value: string;
23
+ label: string;
24
+ }[];
25
+ };
26
+ inUse: string | null;
27
+ presets: {
28
+ name: "opus" | "sized";
29
+ label: string;
30
+ words: string;
31
+ inUse: boolean;
32
+ recommended: boolean;
33
+ }[];
34
+ newer: {
35
+ from: string;
36
+ to: string;
37
+ name: string;
38
+ roles: string;
39
+ }[];
40
+ preview: {
41
+ title: string;
42
+ rows: {
43
+ role: string;
44
+ before: string;
45
+ after: string;
46
+ notes: string[];
47
+ }[];
48
+ hidden: {
49
+ scope: string;
50
+ digest: string;
51
+ change: string;
52
+ };
53
+ } | null;
54
+ unresolved: string | null;
55
+ sizing: {
56
+ small: string;
57
+ routine: string;
58
+ large: string;
59
+ risky: string;
60
+ classifier: string;
61
+ };
62
+ routing: string;
63
+ recent: {
64
+ taskId: string;
65
+ runId: number;
66
+ role: string;
67
+ chosen: string;
68
+ ranOn: string;
69
+ }[];
70
+ waiting: {
71
+ taskId: string;
72
+ title: string;
73
+ }[];
17
74
  };
18
75
  };
@@ -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";
@@ -74,6 +74,9 @@ import { startConnect } from "../mcp-connect.js";
74
74
  import { decideProposal, listProposals, memoryStatus } from "../memory-pass.js";
75
75
  import { checkModels, isNewModel, modelOptions, modelWords, RUNTIME_TOOLS, runtimeStates, seenModels, setWatch, updateRuntime, watchState } from "../model-catalog.js";
76
76
  import { modelsHtml, modelsScript } from "../models-ui.js";
77
+ 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";
78
+ import { HEADROOM_FIVE_HOUR_LIMIT, HEADROOM_WEEKLY_LIMIT, exactModelId } from "../phase-routing.js";
79
+ import { RISKY_MEANS, SIZE_MEANS, SIZING_CLAUDE_MODEL } from "../task-sizing.js";
77
80
  import { CHAT_APPROVE_ALL, MODE_MAX_DAYS, modeDigestOf, modeTermsJson, modeWords, presetTerms } from "../modes.js";
78
81
  import { monitoringChange, readMonitoring, saveMonitoring } from "../monitoring-settings.js";
79
82
  import { monitoringHtml, signingSecretHtml } from "../monitoring-ui.js";
@@ -109,7 +112,9 @@ import { addProjectInstructions, ASSISTANTS, detectPreparation, modelChoices, pr
109
112
  import { skillsHtml, skillsScript } from "../skills-ui.js";
110
113
  import { checkSlackCredentials, clearSlackCredentials, loadSlackCredentials, saveSlackCredentials, SLACK_MANIFEST, SlackError } from "../slack-api.js";
111
114
  import { slackSettingsHtml } from "../slack-settings.js";
112
- import { spendCsv, spendHtml } from "../spend-ui.js";
115
+ import { spendHtml, spendSettingsHtml } from "../spend-ui.js";
116
+ import { spendReport, spendReportCsv, spendRows } from "../spend-report.js";
117
+ import { limitsHtml, limitsView } from "../limits-ui.js";
113
118
  import { budgetStates, monthNamed, spendItems, usd as spendUsd, subagentNames as subagentNamesOf } from "../spend.js";
114
119
  import { removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from "../sso-settings.js";
115
120
  import { ssoSettingsHtml } from "../sso-ui.js";
@@ -133,8 +138,9 @@ import { personChip, ROLE_TITLES } from "./chrome.js";
133
138
  import { chatReturnWithSaid, goOutside } from "./http.js";
134
139
  import { leadSettingsHtml, notificationProjects, SETTINGS_AUTOSAVE_SCRIPT, settingsPage, settingsWorkers, telegramDeliveryWords, telegramTrouble } from "./render-settings.js";
135
140
  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, defaultProject, googleVisits } = runtime;
137
- // v105: what agent work cost, by month, and the monthly budgets. An instance operator's page.
141
+ 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;
142
+ // What agent work cost, by month: API dollars, plan usage and unknown cost apart (spend-report.ts). An instance
143
+ // operator's page. Its limits show read-only; they change on Settings → Spend.
138
144
  async function spendPage(ctx) {
139
145
  const { url, who, response, now } = ctx;
140
146
  const current = monthOf(now);
@@ -142,26 +148,17 @@ export function createSettingsHandlers(runtime) {
142
148
  const month = asked === null ? current : monthNamed(asked);
143
149
  if (month === null)
144
150
  return refuse(response, who, 400, "Choose a month like 2026-09.", "/spend");
145
- const items = spendItems(store.handle, month.from, month.to);
146
- const subagents = store.subagents([...new Set([...managedRepos(), ...store.knownRepos()])]);
151
+ const rows = spendRows(store.handle, month.from, month.to);
147
152
  const subagentNames = subagentNamesOf(store.handle);
148
153
  if (url.searchParams.get("format") === "csv") {
149
154
  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(spendCsv(items, subagentNames));
155
+ return void response.end(spendReportCsv(rows, subagentNames));
151
156
  }
152
157
  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
- const projects = consoleProjects();
154
- const people = store.accountFacts().filter(one => one.revokedAt === null).map(one => one.name);
155
- const view = {
156
- month: month.name, previous: shift(-1), next: month.name === current.name ? null : shift(1),
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
- };
158
+ // Limits read what the budget gate counts today (spend.ts spendItems), for the current month.
159
+ const budgets = month.name === current.name ? budgetStates(store.budgets(), spendItems(store.handle, current.from, current.to)) : null;
160
+ const windows = month.name !== current.name ? html `` : limitsHtml(limitsView(store.providerLimits(), [], { project: projectName, subagent: id => subagentNames.get(id) ?? `Subagent ${id}` }, now));
161
+ const view = { month: month.name, previous: shift(-1), next: month.name === current.name ? null : shift(1), report: spendReport(rows), budgets, windows, subagentNames };
165
162
  return sendScreen(response, 200, screen("Spend", spendHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }), { chrome: chromeFor(null, "spend") }));
166
163
  }
167
164
  async function modePage(ctx) {
@@ -248,7 +245,13 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
248
245
  const instructions = previewProjectInstructions(project);
249
246
  // The model list as last loaded; Refresh models posts, and the background checks keep it current.
250
247
  const catalog = asked === "openrouter" ? modelCatalog.known(readProviderKey("openrouter", providerHome)) ?? { ok: false, problem: "The model list has not loaded yet." } : null;
251
- const inputs = { provider: asked, model: saved?.provider === asked ? saved.model ?? "" : models[0]?.value ?? "", command: setup?.command ?? (saved === null ? preparation?.command ?? "" : ""), seconds: String((setup?.timeoutMs ?? 300_000) / 1000) };
248
+ // With nothing saved, setup preselects what the installation builds routine tasks with, else "Sized by job"'s
249
+ // routine builder (Sonnet 5.5) for Claude: an exact id, never a floating name.
250
+ const installationBuild = store.phaseConfig(INSTALLATION_SCOPE, "build");
251
+ const routineDefault = PRESETS[DEFAULT_PRESET].rows["build"] ?? "";
252
+ const preselected = installationBuild?.provider === asked && installationBuild.model !== null && models.some(one => one.value === installationBuild.model) ? installationBuild.model
253
+ : asked === "claude" && models.some(one => one.value === routineDefault) ? routineDefault : models[0]?.value ?? "";
254
+ 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
255
  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
256
  instructions: instructions.ok ? { installed: instructions.installed } : { installed: false, message: instructions.message } }), { chrome: chromeFor(project, "settings"), ...(catalog === null ? {} : { functional: { script: openRouterPickerScript() } }) }));
254
257
  };
@@ -301,7 +304,9 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
301
304
  const now = clock();
302
305
  // The page shows the last check and when it ran (docs/plans/get-side-effects.md): Check now, or the watch loop's
303
306
  // automatic checks, fetch the lists and may queue a notice; opening the page never does.
304
- const view = modelsView(who.role === "approver", who.via === "cookie" ? who.session.csrf : "", now, url.searchParams.get("said"), url.searchParams.get("problem"));
307
+ // Only an instance operator changes models (docs/plans/spend-dashboard.md D4); a preset or a switch is previewed
308
+ // here, read-only, and applied by its own password-checked POST.
309
+ 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
310
  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
311
  }
307
312
  async function skillsPage(ctx) {
@@ -867,7 +872,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
867
872
  async function spendBudget(ctx) {
868
873
  const { who, response, now, posted } = ctx;
869
874
  const body = readForm(posted, CONSOLE_FORMS.spendBudget);
870
- const back = sayTo(response, "/spend");
875
+ const back = sayTo(response, "/settings/spend");
871
876
  if (!authenticateApprover(who, body.get("password") ?? "").ok)
872
877
  return back("problem", "Enter your Toolroll password to change a budget.");
873
878
  const target = /^(installation|project|person|subagent):(.+)$/.exec(body.get("target") ?? "");
@@ -1648,38 +1653,6 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1648
1653
  const updated = await updateRuntime(store, tool, who.name, now, modelSeams);
1649
1654
  return back(updated.message, !updated.ok);
1650
1655
  }
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
1656
  return refuse(response, who, 404, "No such models action.", "/settings/models");
1684
1657
  };
1685
1658
  async function knowledgeRefresh(ctx) {
@@ -1813,7 +1786,8 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1813
1786
  const nonceKey = `project-setup:${project}`;
1814
1787
  if (step === "preview") {
1815
1788
  const nonce = mintApprovalNonce(who.name, nonceKey, preview.fingerprint);
1816
- return sendScreen(response, 200, screen("Approve project setup", setupPreviewHtml(inputs, { csrf, repo: project, fingerprint: preview.fingerprint, nonce }), { chrome: chromeFor(project, "settings") }));
1789
+ 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") })) };
1790
+ return sendScreen(response, 200, screen("Approve project setup", setupPreviewHtml(inputs, { csrf, repo: project, fingerprint: preview.fingerprint, nonce }, models), { chrome: chromeFor(project, "settings") }));
1817
1791
  }
1818
1792
  if (who.via === "cookie" && !consumeApprovalNonce(body.get("nonce") ?? "", who.name, nonceKey, body.get("fingerprint") ?? ""))
1819
1793
  return refuse(response, who, 409, "This setup preview expired. Review it again.", "/control");
@@ -2240,7 +2214,7 @@ ${postForm("/settings/chat-approval/save", html `\
2240
2214
  const existing = loadBotToken({}, options.telegramTokenFile);
2241
2215
  const hasEnv = (envValue(process.env, "TELEGRAM_TOKEN") ?? "") !== "";
2242
2216
  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, {
2217
+ 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
2218
  ...store.permissionDefault(),
2245
2219
  canManage: who.role === "approver",
2246
2220
  }));
@@ -2433,8 +2407,9 @@ ${modeWords(terms)
2433
2407
  store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, who.name, now);
2434
2408
  return redirect(response, "/chat");
2435
2409
  }
2436
- /** Settings → Models, read from the saved catalog and the installation's role rows. */
2437
- function modelsView(canManage, csrf, now, said, problem) {
2410
+ /** Settings → Models, read from the saved catalog and the role rows at the scope being edited (the installation,
2411
+ * or one project and what it inherits). */
2412
+ function modelsView(canManage, csrf, now, said, problem, query = new URLSearchParams()) {
2438
2413
  const runtimes = runtimeStates(store);
2439
2414
  const installed = new Set(runtimes.map(one => one.tool));
2440
2415
  const openrouter = (readProviderKey("openrouter", providerHome) ?? process.env["OPENROUTER_API_KEY"] ?? "") !== "";
@@ -2444,23 +2419,74 @@ ${modeWords(terms)
2444
2419
  { label: "Gemini", provider: "gemini", on: installed.has("gemini") },
2445
2420
  { label: "OpenRouter", provider: "openrouter", on: openrouter },
2446
2421
  ];
2447
- const build = store.phaseConfig(INSTALLATION_SCOPE, "build");
2448
- const roles = ["plan", "build", "review", "repair"].map(phase => {
2449
- const row = store.phaseConfig(INSTALLATION_SCOPE, phase);
2450
- const groups = makers.filter(one => one.on && !(phase === "review" && one.provider === "gemini") && !(phase === "repair" && build !== null && one.provider !== build.provider))
2422
+ const projects = consoleProjects();
2423
+ const asked = query.get("scope") ?? INSTALLATION_SCOPE;
2424
+ const scope = asked !== INSTALLATION_SCOPE && projects.includes(asked) ? asked : INSTALLATION_SCOPE;
2425
+ const repo = scope === INSTALLATION_SCOPE ? null : scope;
2426
+ const said_ = (pair) => `${ASSISTANTS[pair.provider]?.name ?? pair.provider} · ${modelWords(store, pair.provider, pair.model)}`;
2427
+ const build = resolvePhaseAgent(store, "build", repo, {});
2428
+ const roles = SLOTS.map(slot => {
2429
+ const own = rowAt(store, scope, slot);
2430
+ const inherited = repo === null ? null : rowAt(store, INSTALLATION_SCOPE, slot);
2431
+ const unset = repo !== null ? (inherited === null ? "Same as the installation (not set)" : `Same as the installation (${inherited.model ?? inherited.provider})`)
2432
+ : slot.phase === "repair" ? "Same as the builder" : slot.phase === "review" ? "Same as the planner"
2433
+ : slot.tier === "light" ? "Not set: small tasks use the routine builder" : slot.tier === "strong" ? "Not set: uses the routine one" : "Not set";
2434
+ const words = own !== null ? said_(own) : inherited !== null ? `Inherits the installation's: ${said_(inherited)}`
2435
+ : slot.phase === "repair" ? "Follows the builder" : slot.phase === "review" ? "Follows the planner" : slot.tier !== "routine" ? "Uses the routine one" : "Not set";
2436
+ const groups = makers.filter(one => one.on && !(slot.phase === "review" && one.provider === "gemini") && !(slot.phase === "repair" && build.ok && one.provider !== build.spec.provider))
2451
2437
  .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
- };
2438
+ return { key: slotKey(slot), label: slotWords(slot), current: own === null ? "" : `${own.provider}|${own.model ?? ""}`, words, unset, groups, tier: slot.tier !== "routine" };
2457
2439
  });
2440
+ const inUse = presetInUse(store, scope);
2441
+ const unchosen = repo === null && ALL_SLOTS.every(slot => rowAt(store, INSTALLATION_SCOPE, slot) === null);
2442
+ const checked = checkScope(store, repo);
2443
+ // A preset or a switch, previewed: the exact rows it moves, with what a change of provider brings along.
2444
+ 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`);
2445
+ const switchFrom = query.get("switch"), switchTo = query.get("to");
2446
+ const presetAsked = query.get("preset");
2447
+ // The rows, what they inherit, and both digests come from one snapshot (model-choices.ts previewModelChange).
2448
+ const previewOf = (title, change, hidden) => {
2449
+ const snapshot = previewModelChange(store, scope, change);
2450
+ const rows = snapshot.rows.map(row => {
2451
+ const notes = [];
2452
+ const was = row.before?.provider ?? row.inherited?.provider ?? null;
2453
+ if (row.after !== null && was !== null && was !== row.after.provider && isProviderId(row.after.provider)) {
2454
+ const name = ASSISTANTS[row.after.provider].name;
2455
+ notes.push(`Billed the way ${name} is signed in on this computer.`);
2456
+ 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.`);
2457
+ notes.push(`Builds reach ${name}'s hosts instead of ${ASSISTANTS[was]?.name ?? was}'s.`);
2458
+ }
2459
+ return { role: slotWords(row.slot), before: pairWords(row.before, row.slot), after: pairWords(row.after, row.slot), notes };
2460
+ });
2461
+ return { title, rows, hidden: { scope, digest: snapshot.state, change: snapshot.change, ...hidden } };
2462
+ };
2463
+ const preview = !canManage ? null
2464
+ : isPresetName(presetAsked) ? previewOf(`Use ${PRESETS[presetAsked].label}`, presetChange(presetAsked), { preset: presetAsked })
2465
+ : switchFrom !== null && switchTo !== null && exactModelId(switchFrom) && exactModelId(switchTo) ? previewOf(`Switch to ${switchTo}`, switchChange(store, scope, switchFrom, switchTo), { switch: switchFrom, to: switchTo })
2466
+ // A new installation with nothing chosen yet opens on the default, ready to apply.
2467
+ : unchosen ? previewOf(`Start with ${PRESETS[DEFAULT_PRESET].label}`, presetChange(DEFAULT_PRESET), { preset: DEFAULT_PRESET })
2468
+ : null;
2458
2469
  const chat = store.getChatConfig();
2459
2470
  const chatName = chat === null ? "" : chat.provider === "claude-subscription" ? "Claude membership" : chat.provider === "codex-subscription" ? "Codex membership" : chat.provider === "anthropic-api" ? "Anthropic API" : "OpenRouter";
2471
+ const capital = (words) => `${words.charAt(0).toUpperCase()}${words.slice(1)}`;
2472
+ const alsoSet = store.listPhaseTierAlternates(INSTALLATION_SCOPE).length + (repo === null ? 0 : store.listPhaseTierAlternates(repo).length) > 0;
2460
2473
  return {
2461
2474
  runtimes, watch: watchState(store), roles, csrf, canManage, said, problem,
2462
2475
  chat: chat === null ? null : { words: `Leads and chat use ${chatName} · ${modelWords(store, chat.provider, chat.model)}` },
2463
2476
  fresh: ["claude", "codex", "gemini"].flatMap(source => seenModels(store, source)).filter(model => isNewModel(model, now)).slice(0, 12),
2477
+ scope: { value: scope, label: repo === null ? "All projects" : projectName(repo), digest: stateDigest(store, scope),
2478
+ options: [{ value: INSTALLATION_SCOPE, label: "All projects" }, ...projects.map(one => ({ value: one, label: projectName(one) }))] },
2479
+ inUse: unchosen ? "nothing chosen yet" : inUse === null ? null : PRESETS[inUse].label,
2480
+ presets: ["sized", "opus"].map(name => ({ name, label: PRESETS[name].label, words: PRESETS[name].words, inUse: inUse === name, recommended: name === DEFAULT_PRESET })),
2481
+ newer: newerModels(store, scope).map(one => ({ from: one.from, to: one.to.id, name: one.to.name, roles: one.slots.map(slotWords).join(", ").toLowerCase() })),
2482
+ 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),
2483
+ sizing: { small: capital(SIZE_MEANS.small), routine: capital(SIZE_MEANS.medium), large: SIZE_MEANS.large, risky: RISKY_MEANS,
2484
+ 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.` },
2485
+ 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.`,
2486
+ recent: recentRuns(store).map(one => ({ taskId: one.taskId, runId: one.runId, role: one.role,
2487
+ chosen: one.chosen === null ? "its default" : /^(opus|sonnet|haiku|fable)$/.test(one.chosen) ? `floating ${one.chosen}` : one.chosen,
2488
+ ranOn: one.ranOn ?? "not reported" })),
2489
+ waiting: canManage ? store.scopesAwaitingApproval(null, 10, admissionList()).map(one => ({ taskId: one.taskId, title: one.title })) : [],
2464
2490
  };
2465
2491
  }
2466
2492
  /** Google's redirect back: the code becomes a saved refresh token, and a page sends the person back to Settings. */
@@ -2546,6 +2572,7 @@ ${modeWords(terms)
2546
2572
  }
2547
2573
  const registrations = handlersOf("settings", {
2548
2574
  "spend": spendPage,
2575
+ "settings.spend": spendSettingsPage,
2549
2576
  "mode": modePage,
2550
2577
  "control": controlPage("setup"),
2551
2578
  "control.connection": controlPage("connection"),
@@ -2623,7 +2650,8 @@ ${modeWords(terms)
2623
2650
  "settings.models-check": modelsAct("check"),
2624
2651
  "settings.models-watch": modelsAct("watch"),
2625
2652
  "settings.models-update": modelsAct("update"),
2626
- "settings.models-agent": modelsAct("agent"),
2653
+ "settings.models-agent": modelsSave,
2654
+ "settings.models-apply": modelsApply,
2627
2655
  "settings.models-act": modelsAct("other"),
2628
2656
  "settings.knowledge-refresh": knowledgeRefresh,
2629
2657
  "settings.knowledge-proposal": knowledgeProposal,
@@ -2783,5 +2811,106 @@ ${modeWords(terms)
2783
2811
  const changed = setReleaseApproval(store, repo, true, who.name, now);
2784
2812
  return back("said", changed.changed ? "Releases approve themselves once CI and the release check pass." : "Saved.");
2785
2813
  }
2814
+ // Settings → Spend: the monthly limits editor and the models in use. An instance operator's page; changing a limit
2815
+ // posts to /spend/budget with a step-up.
2816
+ async function spendSettingsPage(ctx) {
2817
+ const { url, response, now } = ctx;
2818
+ const current = monthOf(now);
2819
+ const subagents = store.subagents([...new Set([...managedRepos(), ...store.knownRepos()])]);
2820
+ const subagentNames = subagentNamesOf(store.handle);
2821
+ const projects = consoleProjects();
2822
+ const people = store.accountFacts().filter(one => one.revokedAt === null).map(one => one.name);
2823
+ const models = ["plan", "build", "review"].map(phase => {
2824
+ const agent = resolvePhaseAgent(store, phase, null, {});
2825
+ return { role: phase === "plan" ? "Planner" : phase === "build" ? "Builder" : "Reviewer", model: agent.ok ? `${agent.spec.provider} · ${agent.spec.model ?? "its default model"}` : "Not set" };
2826
+ });
2827
+ const view = {
2828
+ budgets: budgetStates(store.budgets(), spendItems(store.handle, current.from, current.to)), subagentNames, models,
2829
+ targets: [
2830
+ { value: "installation:*", label: "Everything", group: "Everything" },
2831
+ ...projects.map(repo => ({ value: `project:${repo}`, label: projectName(repo), group: "Projects" })),
2832
+ ...people.map(name => ({ value: `person:${name}`, label: name, group: "People" })),
2833
+ ...subagents.map(one => ({ value: `subagent:${one.id}`, label: `${subagentNames.get(one.id) ?? one.handle} (${projectName(one.repo)})`, group: "Subagents" })),
2834
+ ],
2835
+ };
2836
+ return sendScreen(response, 200, screen("Spend settings", spendSettingsHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }), { chrome: chromeFor(null, "settings") }));
2837
+ }
2838
+ // Settings → Models (docs/plans/spend-dashboard.md, slice 3): the one editor. Both posts are an instance operator's,
2839
+ // browser-only step-ups: the password (or a Passwordless session, or an identity-provider re-check) is proved here,
2840
+ // and model-choices.ts re-proves the same session, credential generation and operator standing inside the one
2841
+ // transaction that checks the form against what is stored, validates the result and writes and ledgers every row.
2842
+ function modelsAuthority(who, method) {
2843
+ const session = who.session;
2844
+ return { actor: who.name, generation: session.generation, method, live: () => sessions.holds(session) && sessions.savedNow(session) && sessionWithinLifetime(session, Date.now()) };
2845
+ }
2846
+ function modelsBack(response, scope) {
2847
+ return sayTo(response, `/settings/models${scope === INSTALLATION_SCOPE ? "" : `?scope=${encodeURIComponent(scope)}`}`);
2848
+ }
2849
+ function modelsSaid(result, what) {
2850
+ if (!result.ok)
2851
+ return { key: "problem", words: browserWords(result.problem) };
2852
+ const count = result.diff.length;
2853
+ const done = count === 0 ? "Nothing changed." : `${what}: ${count} change${count === 1 ? "" : "s"} saved for tasks filed from now on.`;
2854
+ return { key: "said", words: result.unresolved === null ? done : `${done} Some roles have no model yet, so new tasks can't be filed here.` };
2855
+ }
2856
+ /** One save of the whole editor: every role at the scope, sets and clears, as one change. */
2857
+ async function modelsSave(ctx) {
2858
+ const { response, now, posted } = ctx;
2859
+ const who = browserCaller(ctx.who);
2860
+ const body = readForm(posted, CONSOLE_FORMS.modelsChange);
2861
+ const scope = body.get("scope") ?? INSTALLATION_SCOPE;
2862
+ const back = modelsBack(response, scope);
2863
+ if (["scope", "password", "digest", ...SLOTS.map(slotKey)].some(field => body.sent.getAll(field).length > 1))
2864
+ return back("problem", "That form was sent twice over. Nothing changed.");
2865
+ const proved = authenticateApprover(who, body.get("password") ?? "");
2866
+ if (!proved.ok)
2867
+ return back("problem", "Enter your Toolroll password to change models.");
2868
+ const change = [];
2869
+ for (const slot of SLOTS) {
2870
+ const value = body.sent.get(slotKey(slot));
2871
+ if (value === null)
2872
+ continue;
2873
+ if (value === "clear") {
2874
+ change.push({ slot, to: null });
2875
+ continue;
2876
+ }
2877
+ const split = value.indexOf("|");
2878
+ if (split < 1)
2879
+ return back("problem", "Choose a model from the list. Nothing changed.");
2880
+ change.push({ slot, to: { provider: value.slice(0, split), model: value.slice(split + 1) } });
2881
+ }
2882
+ // Repairs resume the builder's session: a builder moved to another provider takes repair with it.
2883
+ const builder = change.find(one => one.slot.phase === "build" && one.slot.tier === "routine")?.to ?? null;
2884
+ const repair = change.findIndex(one => one.slot.phase === "repair");
2885
+ let follows = false;
2886
+ if (builder !== null && repair >= 0 && change[repair].to !== null && change[repair].to.provider !== builder.provider) {
2887
+ change[repair] = { slot: change[repair].slot, to: null };
2888
+ follows = true;
2889
+ }
2890
+ const result = applyModelChoices(store, { scope, change, expected: { state: body.get("digest") ?? "" }, strict: true, knownProject: repo => consoleProjects().includes(repo) }, modelsAuthority(who, proved.method), now);
2891
+ const said = modelsSaid(result, scope === INSTALLATION_SCOPE ? "All projects" : projectName(scope));
2892
+ return back(said.key, follows && result.ok ? `${said.words} Repairs now follow the builder.` : said.words);
2893
+ }
2894
+ /** A preset or a switch, applied exactly as previewed: the change's own digest rides the form. */
2895
+ async function modelsApply(ctx) {
2896
+ const { response, now, posted } = ctx;
2897
+ const who = browserCaller(ctx.who);
2898
+ const body = readForm(posted, CONSOLE_FORMS.modelsChange);
2899
+ const scope = body.get("scope") ?? INSTALLATION_SCOPE;
2900
+ const back = modelsBack(response, scope);
2901
+ if (["scope", "password", "digest", "preset", "switch", "to", "change"].some(field => body.sent.getAll(field).length > 1))
2902
+ return back("problem", "That form was sent twice over. Nothing changed.");
2903
+ const proved = authenticateApprover(who, body.get("password") ?? "");
2904
+ if (!proved.ok)
2905
+ return back("problem", "Enter your Toolroll password to change models.");
2906
+ const preset = body.get("preset"), from = body.get("switch"), to = body.get("to");
2907
+ const change = isPresetName(preset) ? presetChange(preset)
2908
+ : from !== null && to !== null && exactModelId(from) && exactModelId(to) ? switchChange(store, scope, from, to) : null;
2909
+ if (change === null)
2910
+ return back("problem", "Choose a preset or a newer model. Nothing changed.");
2911
+ 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);
2912
+ const said = modelsSaid(result, isPresetName(preset) ? PRESETS[preset].label : `Switched to ${to}`);
2913
+ return back(said.key, said.words);
2914
+ }
2786
2915
  return { registrations, modelsView };
2787
2916
  }
@@ -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
+ }
@@ -27,7 +27,8 @@ import { codingWorkspaceHtml, codingWorkspaceScript } from '../coding-ui.js';
27
27
  import { CodingActionError } from '../coding-workspace.js';
28
28
  import { checkResponse, CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
29
29
  import { hasForbiddenControls, validateNote } from "../decision.js";
30
- import { diagnoseTaskDispatch } from "../dispatch.js";
30
+ import { builderReadyFor, diagnoseTaskDispatch, firstRunBuilderRepo } from "../dispatch.js";
31
+ import { withBuilderStep } from "../first-run.js";
31
32
  import { evidencePack, evidencePackHtml } from "../evidence-pack.js";
32
33
  import { readVerifiedArtifact, readVerifiedReport, writeEvidenceFile } from "../evidence.js";
33
34
  import { computeGaps } from "../gaps.js";
@@ -175,7 +176,8 @@ export function createTasksHandlers(runtime) {
175
176
  })),
176
177
  cancelledBlockers: cancelled,
177
178
  gaps: project === null ? [] : computeGaps(store, project, now).filter(gap => gap.unblocks.length > 0).slice(0, 10),
178
- wizard: firstRunStepsNow(now),
179
+ // The builder step speaks for the same project as Chat's (firstRunBuilderRepo), as dispatch decides.
180
+ wizard: withBuilderStep(firstRunStepsNow(now), builderReadyFor(store, firstRunBuilderRepo(store, project, visible, managedRepos()), now)),
179
181
  worker: (() => {
180
182
  // The one fact the inbox must never hide (install review): with
181
183
  // no worker answering, nothing here will ever build, and every
@@ -1020,6 +1022,8 @@ export function createTasksHandlers(runtime) {
1020
1022
  }
1021
1023
  let repo = effective;
1022
1024
  let admitted = unscopedMode ? null : admissionList() ?? [];
1025
+ // What this filing is decided on: re-checked inside the write below, after every await (project-identity.ts).
1026
+ const decidedOn = identities.generation();
1023
1027
  const rootMode = !unscopedMode && ceiling.roots.length > 0;
1024
1028
  if (rootMode && effective !== "") {
1025
1029
  // Resolved off the console's thread (project-identity.ts), then proved against the ceiling as that identity.
@@ -1052,6 +1056,10 @@ export function createTasksHandlers(runtime) {
1052
1056
  const after = (body.get("after") ?? "").trim();
1053
1057
  let chainProblem = null;
1054
1058
  const made = store.transact(() => {
1059
+ // A project folder's identity changed while this request waited (a root or link now leads elsewhere): what was
1060
+ // proved above may no longer hold, so nothing is filed on it.
1061
+ if (identities.generation() !== decidedOn || (rootMode && repo !== "" && !visible(repo)))
1062
+ return null;
1055
1063
  const filed = fileTaskProposal(store, {
1056
1064
  ...(id === "" ? {} : { id }),
1057
1065
  title,
@@ -1083,6 +1091,8 @@ export function createTasksHandlers(runtime) {
1083
1091
  applyModeToNewFiling(store, filed.id, who.name, now);
1084
1092
  return filed;
1085
1093
  });
1094
+ if (made === null)
1095
+ return newTaskScreen(ctx, 409, "a project folder changed while this was being filed — check the project and send it again", draft);
1086
1096
  if (!made.ok) {
1087
1097
  return newTaskScreen(ctx, made.reason === "backlog-full" ? 429 : 400, made.message, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body.sent });
1088
1098
  }