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
@@ -2,20 +2,31 @@
2
2
  * role picked from live lists, and the models that just arrived. */
3
3
  import { type Html } from "./html.js";
4
4
  import type { ModelOption, RuntimeState, SeenModel, WatchState } from "./model-catalog.js";
5
- export declare const MODELS_CSS = ".models{max-width:780px;min-width:0;overflow-wrap:anywhere}.models .card{padding:16px 20px;margin:12px 0;min-width:0}.models h2{margin:24px 0 8px}.models form{margin:0}.models label{display:grid;gap:6px}.models select,.models input[type=search]{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.models button,.models summary,.models .button-link{min-height:44px}.models button{white-space:nowrap}.models summary{padding:12px 0;cursor:pointer}.models .tool{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.models .role{display:grid;grid-template-columns:minmax(0,1fr);gap:8px}.models .role-row{display:flex;gap:12px;align-items:end;flex-wrap:wrap}.models .role-row select{flex:1 1 240px;width:auto;min-width:0}.models .role-name{font-weight:600}.models .new{font-weight:600}.models ul{padding-left:20px}.models .meta{font-size:.86rem}@media(max-width:600px){.models .card{padding:14px}}";
5
+ export declare const MODELS_CSS = ".models{max-width:780px;min-width:0;overflow-wrap:anywhere}.models .card{padding:16px 20px;margin:12px 0;min-width:0}.models h2{margin:24px 0 8px}.models form{margin:0}.models label{display:grid;gap:6px}.models select,.models input[type=search]{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.models button,.models summary,.models .button-link{min-height:44px}.models button{white-space:nowrap}.models summary{padding:12px 0;cursor:pointer}.models .tool{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.models .role{display:grid;grid-template-columns:minmax(0,1fr);gap:8px}.models .role-row{display:flex;gap:12px;align-items:end;flex-wrap:wrap}.models .role-row select{flex:1 1 240px;width:auto;min-width:0}.models .role-name{font-weight:600}.models .new{font-weight:600}.models ul{padding-left:20px}.models .meta{font-size:.86rem}.models .role-read{display:grid;gap:2px;padding:8px 0}.models .editor{display:grid;gap:14px}.models .editor .role select{width:100%}.models .tiers{border-top:1px solid var(--so-line)}.models .tiers .role{margin:0 0 14px}.models .mono{font-family:var(--font-mono,ui-monospace,monospace);font-size:.84rem}.models .preset{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.models .preset p{margin:4px 0 0}.models .preset>div{flex:1 1 280px;min-width:0}.models .button-link{display:inline-flex;align-items:center;padding:0 14px;border:1px solid var(--so-input-line);border-radius:8px;text-decoration:none;color:var(--so-ink);font-weight:600}.models table{width:100%;border-collapse:collapse}.models th,.models td{text-align:left;vertical-align:top;padding:8px 6px;border-bottom:1px solid var(--so-line)}.models .diff th{width:38%;font-weight:600}.models .diff ul{margin:4px 0 0}.models .preview{border-color:var(--so-input-line)}.models .preview form{display:grid;gap:10px;margin-top:12px}.models .preview .cancel{justify-self:start;display:inline-flex;align-items:center;min-height:44px}.models .sizing dt{font-weight:600;margin-top:8px}.models .sizing dd{margin:2px 0 0}.models .editor label:has(input[type=password]){margin-top:4px}@media(max-width:600px){.models .card{padding:14px}.models .diff th,.models .diff td{display:block;width:auto;border:0;padding:4px 0}.models .diff tr{display:block;border-bottom:1px solid var(--so-line);padding:6px 0}.models .recent td{display:block;border:0;padding:2px 0}.models .recent tr{display:block;border-bottom:1px solid var(--so-line);padding:6px 0}.models .recent thead{display:none}}";
6
+ /** One row of the editor: a role at the scope being edited. */
6
7
  export type RoleView = {
7
- phase: "plan" | "build" | "review" | "repair";
8
+ /** The slot's form field (model-choices.ts slotKey). */
9
+ key: string;
8
10
  label: string;
9
- /** "provider|model", or "inherit" for a repair that follows the builder. */
11
+ /** "provider|model", or "" when this scope has no row of its own. */
10
12
  current: string;
13
+ /** What runs now, in words, including what is inherited. */
11
14
  words: string;
15
+ /** What "no row here" means for this role: "Same as the builder", "Same as the installation (…)", "Not set". */
16
+ unset: string;
12
17
  groups: {
13
18
  label: string;
14
19
  provider: string;
15
20
  options: ModelOption[];
16
21
  }[];
17
- /** Repair only: offer "Same as the builder". */
18
- inherit?: boolean;
22
+ /** Behind the tiers disclosure. */
23
+ tier: boolean;
24
+ };
25
+ export type DiffView = {
26
+ role: string;
27
+ before: string;
28
+ after: string;
29
+ notes: string[];
19
30
  };
20
31
  export type ModelsView = {
21
32
  runtimes: RuntimeState[];
@@ -29,6 +40,59 @@ export type ModelsView = {
29
40
  canManage: boolean;
30
41
  said: string | null;
31
42
  problem: string | null;
43
+ /** The scope being edited, the scopes on offer, and the digest of what this page shows. */
44
+ scope: {
45
+ value: string;
46
+ label: string;
47
+ options: {
48
+ value: string;
49
+ label: string;
50
+ }[];
51
+ digest: string;
52
+ };
53
+ /** "Sized by job", "Opus for everything", or null for a custom set. */
54
+ inUse: string | null;
55
+ presets: {
56
+ name: string;
57
+ label: string;
58
+ words: string;
59
+ inUse: boolean;
60
+ recommended: boolean;
61
+ }[];
62
+ /** Newer models the watch has seen for exact ids in use here. */
63
+ newer: {
64
+ from: string;
65
+ to: string;
66
+ name: string;
67
+ roles: string;
68
+ }[];
69
+ /** A preset or switch's exact diff, shown before it applies. */
70
+ preview: {
71
+ title: string;
72
+ rows: DiffView[];
73
+ hidden: Record<string, string>;
74
+ } | null;
75
+ /** What still keeps tasks from being filed at this scope, or null. */
76
+ unresolved: string | null;
77
+ sizing: {
78
+ small: string;
79
+ routine: string;
80
+ large: string;
81
+ risky: string;
82
+ classifier: string;
83
+ };
84
+ routing: string;
85
+ recent: {
86
+ taskId: string;
87
+ runId: number;
88
+ role: string;
89
+ chosen: string;
90
+ ranOn: string;
91
+ }[];
92
+ waiting: {
93
+ taskId: string;
94
+ title: string;
95
+ }[];
32
96
  };
33
97
  export declare function modelsHtml(view: ModelsView): Html;
34
98
  /** Filters one long picker as you type; the saved choice always stays listed. */
package/dist/models-ui.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /** Settings → Models: the CLIs on this computer, the default agent for each
2
2
  * role picked from live lists, and the models that just arrived. */
3
- import { html, postForm } from "./html.js";
3
+ import { html, postForm, reauth } from "./html.js";
4
4
  import { priceWords } from "./model-catalog.js";
5
5
  import { whenUtc } from "./when-html.js";
6
6
  const when = (iso) => iso === null ? "never" : whenUtc(iso);
7
- export const MODELS_CSS = `.models{max-width:780px;min-width:0;overflow-wrap:anywhere}.models .card{padding:16px 20px;margin:12px 0;min-width:0}.models h2{margin:24px 0 8px}.models form{margin:0}.models label{display:grid;gap:6px}.models select,.models input[type=search]{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.models button,.models summary,.models .button-link{min-height:44px}.models button{white-space:nowrap}.models summary{padding:12px 0;cursor:pointer}.models .tool{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.models .role{display:grid;grid-template-columns:minmax(0,1fr);gap:8px}.models .role-row{display:flex;gap:12px;align-items:end;flex-wrap:wrap}.models .role-row select{flex:1 1 240px;width:auto;min-width:0}.models .role-name{font-weight:600}.models .new{font-weight:600}.models ul{padding-left:20px}.models .meta{font-size:.86rem}@media(max-width:600px){.models .card{padding:14px}}`;
7
+ export const MODELS_CSS = `.models{max-width:780px;min-width:0;overflow-wrap:anywhere}.models .card{padding:16px 20px;margin:12px 0;min-width:0}.models h2{margin:24px 0 8px}.models form{margin:0}.models label{display:grid;gap:6px}.models select,.models input[type=search]{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.models button,.models summary,.models .button-link{min-height:44px}.models button{white-space:nowrap}.models summary{padding:12px 0;cursor:pointer}.models .tool{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.models .role{display:grid;grid-template-columns:minmax(0,1fr);gap:8px}.models .role-row{display:flex;gap:12px;align-items:end;flex-wrap:wrap}.models .role-row select{flex:1 1 240px;width:auto;min-width:0}.models .role-name{font-weight:600}.models .new{font-weight:600}.models ul{padding-left:20px}.models .meta{font-size:.86rem}.models .role-read{display:grid;gap:2px;padding:8px 0}.models .editor{display:grid;gap:14px}.models .editor .role select{width:100%}.models .tiers{border-top:1px solid var(--so-line)}.models .tiers .role{margin:0 0 14px}.models .mono{font-family:var(--font-mono,ui-monospace,monospace);font-size:.84rem}.models .preset{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.models .preset p{margin:4px 0 0}.models .preset>div{flex:1 1 280px;min-width:0}.models .button-link{display:inline-flex;align-items:center;padding:0 14px;border:1px solid var(--so-input-line);border-radius:8px;text-decoration:none;color:var(--so-ink);font-weight:600}.models table{width:100%;border-collapse:collapse}.models th,.models td{text-align:left;vertical-align:top;padding:8px 6px;border-bottom:1px solid var(--so-line)}.models .diff th{width:38%;font-weight:600}.models .diff ul{margin:4px 0 0}.models .preview{border-color:var(--so-input-line)}.models .preview form{display:grid;gap:10px;margin-top:12px}.models .preview .cancel{justify-self:start;display:inline-flex;align-items:center;min-height:44px}.models .sizing dt{font-weight:600;margin-top:8px}.models .sizing dd{margin:2px 0 0}.models .editor label:has(input[type=password]){margin-top:4px}@media(max-width:600px){.models .card{padding:14px}.models .diff th,.models .diff td{display:block;width:auto;border:0;padding:4px 0}.models .diff tr{display:block;border-bottom:1px solid var(--so-line);padding:6px 0}.models .recent td{display:block;border:0;padding:2px 0}.models .recent tr{display:block;border-bottom:1px solid var(--so-line);padding:6px 0}.models .recent thead{display:none}}`;
8
8
  function toolRow(one, manage) {
9
9
  const state = one.installed === null ? "Version unknown"
10
10
  : one.behind ? html `${one.installed} · <strong>${one.latest} available</strong>`
@@ -16,18 +16,17 @@ function toolRow(one, manage) {
16
16
  }
17
17
  function roleForm(role, manage) {
18
18
  if (!manage)
19
- return html `<div class="card role"><strong>${role.label}</strong><span>${role.words}</span></div>`;
19
+ return html `<div class="role-read"><span class="role-name">${role.label}</span><span>${role.words}</span></div>`;
20
20
  const known = role.groups.some(group => group.options.some(option => `${group.provider}|${option.value}` === role.current));
21
- const saved = !known && role.current !== "inherit" && role.current !== "" ? html `<option value="${role.current}" selected>${role.words} · current</option>` : "";
21
+ const saved = !known && role.current !== "" ? html `<option value="${role.current}" selected>${role.words} · current</option>` : "";
22
22
  const groups = role.groups.map(group => html `<optgroup label="${group.label}">${group.options.map(option => {
23
23
  const value = `${group.provider}|${option.value}`;
24
24
  return html `<option value="${value}"${value === role.current ? html ` selected` : ""}>${option.label}</option>`;
25
25
  })}</optgroup>`);
26
- const inherit = role.inherit ? html `<option value="inherit"${role.current === "inherit" ? html ` selected` : ""}>Same as the builder</option>` : "";
27
- const long = role.groups.reduce((sum, group) => sum + group.options.length, 0) > 40;
26
+ const unset = html `<option value="clear"${role.current === "" ? html ` selected` : ""}>${role.unset}</option>`;
28
27
  const count = role.groups.reduce((sum, group) => sum + group.options.length, 0);
29
- const id = `role-${role.phase}`;
30
- return postForm("/settings/models/agent", html `<label class="role-name" for="${id}">${role.label}</label>${long ? html `<input type="search" data-model-filter hidden aria-label="Search ${role.label.toLowerCase()} models" placeholder="Search ${count} models" autocomplete="off">` : ""}<div class="role-row"><select id="${id}" name="agent">${inherit}${saved}${groups}</select><button type="submit" class="secondary">Save</button></div>`, { attrs: { class: "card role" }, hidden: { phase: role.phase } });
28
+ const id = `role-${role.key}`;
29
+ return html `<div class="role"><label class="role-name" for="${id}">${role.label}</label>${count > 40 ? html `<input type="search" data-model-filter hidden aria-label="Search ${role.label.toLowerCase()} models" placeholder="Search ${count} models" autocomplete="off">` : ""}<select id="${id}" name="${role.key}">${unset}${saved}${groups}</select>${role.current === "" ? html `<span class="meta">${role.words}</span>` : ""}</div>`;
31
30
  }
32
31
  export function modelsHtml(view) {
33
32
  const manage = view.canManage && view.csrf !== "";
@@ -44,11 +43,11 @@ export function modelsHtml(view) {
44
43
  const watch = manage
45
44
  ? html `<details><summary>Automatic checks · ${view.watch.enabled ? "On" : "Off"}</summary>${postForm("/settings/models/watch", html `<p>Check every 6 hours and send a message when a new model or CLI update appears. It uses public lists and sends nothing about you or your projects.</p><button type="submit" class="secondary">${view.watch.enabled ? "Turn off" : "Turn on"}</button>`, { hidden: { enabled: view.watch.enabled ? "0" : "1" } })}</details>`
46
45
  : "";
47
- return html `<section class="models">${status}${problem}<h2>AI tools</h2>${tools}<p class="meta">Checked ${when(view.watch.checkedAt)} ${check}</p>${fresh}<h2>Default agents</h2><p class="meta">New tasks use these. Approved tasks keep the agents they were approved with.</p>${view.roles.map(role => roleForm(role, manage))}${view.chat === null ? "" : html `<h2>Chat</h2><p>${view.chat.words} · <a href="/settings/lead">Change</a></p>`}${watch}</section>`;
46
+ return html `<section class="models">${status}${problem}${previewCard(view, manage)}${editorSection(view, manage)}${presetsSection(view, manage)}${sizingSection(view)}${recentSection(view)}<h2>AI tools</h2>${tools}<p class="meta">Checked ${when(view.watch.checkedAt)} ${check}</p>${fresh}${view.chat === null ? "" : html `<h2>Chat</h2><p>${view.chat.words} · <a href="/settings/lead">Change</a></p>`}${watch}</section>`;
48
47
  }
49
48
  /** Filters one long picker as you type; the saved choice always stays listed. */
50
49
  export function modelsScript() {
51
- return `(function(){document.querySelectorAll('.models form.role').forEach(function(form){
50
+ return `(function(){document.querySelectorAll('.models .role').forEach(function(form){
52
51
  var search=form.querySelector('[data-model-filter]'),select=form.querySelector('select');
53
52
  if(!search||!select)return;search.hidden=false;
54
53
  var groups=Array.from(select.querySelectorAll('optgroup')).map(function(g){return{g:g,options:Array.from(g.children)}});
@@ -57,3 +56,47 @@ export function modelsScript() {
57
56
  select.value=chosen;});
58
57
  })})();`;
59
58
  }
59
+ const scopeQuery = (view) => view.scope.value === "installation" ? "" : `&scope=${encodeURIComponent(view.scope.value)}`;
60
+ const password = () => reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`);
61
+ /** The roles at the scope being edited: one form, one save, one password. Sizes and tiers sit behind a disclosure. */
62
+ function editorSection(view, manage) {
63
+ const scopes = view.scope.options.length < 2 ? "" : html `<form method="get" action="/settings/models" class="models-scope"><label for="models-scope">Settings for</label><div class="role-row"><select id="models-scope" name="scope">${view.scope.options.map(one => html `<option value="${one.value}"${one.value === view.scope.value ? html ` selected` : ""}>${one.label}</option>`)}</select><button type="submit" class="secondary">Show</button></div></form>`;
64
+ const main = view.roles.filter(role => !role.tier), tiers = view.roles.filter(role => role.tier);
65
+ const inUse = html `<p class="meta">In use: ${view.inUse === null ? "a custom set" : view.inUse}${view.scope.value === "installation" ? "" : html ` · ${view.scope.label}`}</p>`;
66
+ const unresolved = view.unresolved === null ? "" : html `<p class="problem" role="alert">New tasks can't be filed here yet: ${view.unresolved}</p>`;
67
+ const rows = html `${main.map(role => roleForm(role, manage))}<details class="tiers"${manage ? "" : html ` open`}><summary>By task size</summary>${tiers.map(role => roleForm(role, manage))}</details>`;
68
+ const body = manage
69
+ ? postForm("/settings/models/agent", html `${rows}${password()}<button type="submit">Save</button>`, { attrs: { class: "card editor" }, hidden: { scope: view.scope.value, digest: view.scope.digest } })
70
+ : html `<div class="card editor">${rows}</div>`;
71
+ return html `<h2>Models for new tasks</h2>${scopes}${inUse}${unresolved}${body}<p class="meta">Changes apply to tasks filed from now on. Approved and waiting tasks keep the models they were filed with.</p>`;
72
+ }
73
+ /** A preset or a switch shows exactly what it changes, and applies only with a second, password-checked submit. */
74
+ function previewCard(view, manage) {
75
+ const preview = view.preview;
76
+ if (preview === null || !manage)
77
+ return html ``;
78
+ const back = `/settings/models${view.scope.value === "installation" ? "" : `?scope=${encodeURIComponent(view.scope.value)}`}`;
79
+ if (preview.rows.length === 0)
80
+ return html `<div class="card preview" role="status"><h2>${preview.title}</h2><p>Already in use. Nothing would change.</p><a class="button-link" href="${back}">Back</a></div>`;
81
+ const rows = preview.rows.map(row => html `<tr><th scope="row">${row.role}</th><td><span class="mono">${row.before}</span> → <span class="mono">${row.after}</span>${row.notes.length === 0 ? "" : html `<ul class="meta">${row.notes.map(note => html `<li>${note}</li>`)}</ul>`}</td></tr>`);
82
+ return html `<div class="card preview"><h2>${preview.title}</h2><p>Review what changes for tasks filed from now on.</p><table class="diff"><tbody>${rows}</tbody></table>${postForm("/settings/models/apply", html `${password()}<button type="submit" class="primary">Apply ${preview.rows.length} change${preview.rows.length === 1 ? "" : "s"}</button><a class="cancel" href="${back}">Cancel</a>`, { hidden: preview.hidden })}</div>`;
83
+ }
84
+ function presetsSection(view, manage) {
85
+ const presets = view.presets.map(one => html `<div class="card preset"><div><strong>${one.label}</strong>${one.recommended ? html ` <span class="meta">Default</span>` : ""}<p>${one.words}</p></div>${one.inUse ? html `<span class="meta">In use</span>` : manage ? html `<a class="button-link" href="/settings/models?preset=${one.name}${scopeQuery(view)}">Review changes</a>` : ""}</div>`);
86
+ const newer = view.newer.length === 0 ? "" : html `<h2>Newer models</h2>${view.newer.map(one => html `<div class="card preset"><div><strong>${one.name}</strong><p>Newer than <span class="mono">${one.from}</span>, used by ${one.roles}.</p></div>${manage ? html `<a class="button-link" href="/settings/models?switch=${encodeURIComponent(one.from)}&to=${encodeURIComponent(one.to)}${scopeQuery(view)}">Switch to it</a>` : ""}</div>`)}`;
87
+ return html `<h2>Presets</h2>${presets}${newer}`;
88
+ }
89
+ /** How a task's size is decided, in plain words, and what routing does at filing. */
90
+ function sizingSection(view) {
91
+ return html `<h2>How tasks are sized</h2><div class="card sizing"><p>Each task is sized once, when it is filed. ${view.sizing.classifier} If it doesn't answer within 5 seconds, the size comes from the description.</p><dl>
92
+ <dt>Small</dt><dd>${view.sizing.small}. Built by the builder for small tasks, with no plan.</dd>
93
+ <dt>Routine</dt><dd>${view.sizing.routine}. Planned and built by the planner and builder for routine tasks.</dd>
94
+ <dt>Large or risky</dt><dd>Large: ${view.sizing.large}. Risky: ${view.sizing.risky} Planned and built by the large or risky models.</dd></dl>
95
+ <details><summary>More about routing</summary><p>${view.routing}</p><p>Changing a model never re-sizes or re-routes a task that was already filed. One exception: an older build review saved without its own route uses the reviewer set here now.</p></details></div>${view.waiting.length === 0 ? "" : html `<details class="waiting"><summary>${view.waiting.length} plan${view.waiting.length === 1 ? "" : "s"} waiting for approval</summary><p class="meta">They keep the models they were filed with.</p><ul>${view.waiting.map(one => html `<li><a href="/t/${encodeURIComponent(one.taskId)}">${one.title}</a></li>`)}</ul></details>`}`;
96
+ }
97
+ /** The model each recent run was set to, next to the model its provider reported. */
98
+ function recentSection(view) {
99
+ if (view.recent.length === 0)
100
+ return html ``;
101
+ return html `<h2>Recent work</h2><table class="recent"><thead><tr><th scope="col">Task</th><th scope="col">Chosen → ran on</th></tr></thead><tbody>${view.recent.map(one => html `<tr><td><a href="/t/${encodeURIComponent(one.taskId)}">${one.taskId}</a> <span class="meta">${one.role}</span></td><td><span class="mono">${one.chosen}</span> → <span class="mono">${one.ranOn}</span></td></tr>`)}</tbody></table>`;
102
+ }
@@ -15,6 +15,14 @@ export type NeedAction = {
15
15
  code: NeedActionCode;
16
16
  label: string;
17
17
  };
18
+ /** Where "Connect a builder" and "Start the builder" lead (code `start-worker`): the Start a builder page, which shows
19
+ * the one command to run and the open project's builder state. Every signed-in person can open it, a project-limited
20
+ * one included (System is instance-only). Never the task's own page, which only repeats that nothing is connected. */
21
+ export declare const START_BUILDER_HREF = "/start-builder";
22
+ /** Start a builder for one task: the page reports that task's own project, whatever project is open. */
23
+ export declare const startBuilderHref: (taskId: string | null) => string;
24
+ /** What starts a builder: run on the machine where the project lives, in the project's folder. */
25
+ export declare const START_BUILDER_COMMAND = "toolroll up";
18
26
  /** `build`: the run a sentence names; `provider`: the agent that signed out. */
19
27
  export type NeedContext = {
20
28
  build?: number | null;
package/dist/needs-you.js CHANGED
@@ -5,6 +5,14 @@
5
5
  *
6
6
  * The words never name the machine's internals; `plainSentence` keeps a
7
7
  * recorded reason only when it is free of them. */
8
+ /** Where "Connect a builder" and "Start the builder" lead (code `start-worker`): the Start a builder page, which shows
9
+ * the one command to run and the open project's builder state. Every signed-in person can open it, a project-limited
10
+ * one included (System is instance-only). Never the task's own page, which only repeats that nothing is connected. */
11
+ export const START_BUILDER_HREF = "/start-builder";
12
+ /** Start a builder for one task: the page reports that task's own project, whatever project is open. */
13
+ export const startBuilderHref = (taskId) => taskId === null ? START_BUILDER_HREF : `${START_BUILDER_HREF}?task=${encodeURIComponent(taskId)}`;
14
+ /** What starts a builder: run on the machine where the project lives, in the project's folder. */
15
+ export const START_BUILDER_COMMAND = "toolroll up";
8
16
  const always = () => true;
9
17
  const build = (context) => context.build == null ? "the last build" : `build #${context.build}`;
10
18
  const Build = (context) => context.build == null ? "The last build" : `Build #${context.build}`;
package/dist/operate.d.ts CHANGED
@@ -9,10 +9,12 @@ import { type Store } from "./store.js";
9
9
  import { type DispatchAdapter } from "./sync.js";
10
10
  import { type RolePort, type UpRoleStart } from "./up-processes.js";
11
11
  import { type TelegramTransport } from "./telegram.js";
12
+ import { type ProjectIdentities } from "./project-identity.js";
12
13
  import { type FolderLookOptions } from "./folder-look.js";
13
14
  import { type LeadCliSeams } from "./lead-cli.js";
14
15
  import { type Principal, type RemoteRun, type RemoteSource } from "./operate-remote.js";
15
16
  import { type PublishExec } from "./publish.js";
17
+ import { type ApplyResult, type ExtraWrite, type ModelChange, type Slot } from "./model-choices.js";
16
18
  import { type Runner as CommandRunner } from "./builder.js";
17
19
  import { type ReleaseIo } from "./releases.js";
18
20
  export type Write = (line: string) => void;
@@ -94,6 +96,8 @@ export type OperateOptions = {
94
96
  filingSizer?: Sizer;
95
97
  /** Set only by up-role-child.ts: this process is one role of a `toolroll up` (the console or the worker). */
96
98
  upRole?: UpRoleHost;
99
+ /** Injected by tests: how the console looks at project folders off its thread (folder-look.ts). */
100
+ folderLook?: FolderLookOptions;
97
101
  };
98
102
  export type OnboardSeams = Partial<Pick<OnboardIo, "home" | "env" | "cwd" | "interactive" | "confirm" | "findRepo" | "checkConnection" | "pullRequests">>;
99
103
  export declare const OPERATE_HELP: string;
@@ -153,6 +157,8 @@ export declare function runOperateAs(argv: string[], opts: {
153
157
  options?: OperateOptions;
154
158
  }): Promise<number>;
155
159
  type Context = {
160
+ /** Injected by tests: how the console looks at project folders off its thread. */
161
+ folderLook?: FolderLookOptions;
156
162
  /** A remote run's person (runOperateAs): every credential check resolves to them. Absent for every local command. */
157
163
  principal?: RemoteRun;
158
164
  /** The lead token this command runs under: it signs in as its owner. */
@@ -303,3 +309,18 @@ export declare function upProveSaved(gitRun: (file: string, args: readonly strin
303
309
  /** What `up` hands its next worker after the worker reports which saved projects it still holds start authority for:
304
310
  * only what both still hold. A worker's report can give up an authority, never add or give back one. */
305
311
  export declare function upNarrowDeferred(held: readonly string[], reported: readonly string[]): string[];
312
+ /** Resolve the console's project identities after it is listening, never before: the deploy's health check and every
313
+ * page answer at once, and each project shows up as its folder answers (project-identity.ts `refresh`, its looks a
314
+ * few at a time under their deadline). Nothing waits on it; a pass that fails leaves those paths unresolved, which
315
+ * fails closed, for the background pass to try again. */
316
+ export declare function consoleIdentitiesInBackground(identities: ProjectIdentities, paths: readonly string[]): void;
317
+ /** `config set` and `config clear` on the command line, through Settings → Models' one write path (model-choices.ts,
318
+ * docs/plans/spend-dashboard.md slice 3), under the same target and resulting-configuration checks as the editor: the
319
+ * person proved themselves with their own password or login at `proved.generation`; a machine credential (a remote
320
+ * token, the lead's token) never changes which models run. The write re-proves them inside its transaction: still an
321
+ * unrestricted instance operator, at that very generation, by a proof that still stands. */
322
+ export declare function writeModelRow(store: Store, proved: {
323
+ actor: string;
324
+ generation: number;
325
+ method: string;
326
+ }, scope: string, knownProject: (repo: string) => boolean, slot: Slot | null, to: ModelChange["to"], now: Date, extra?: readonly ExtraWrite[]): ApplyResult;
package/dist/operate.js CHANGED
@@ -222,6 +222,7 @@ import { fileTaskProposal, validateTaskText } from "./proposal.js";
222
222
  import { TEMPLATES, templateByName } from "./templates.js";
223
223
  import { isDirectChatProvider, isSubscriptionChatProvider, priceOf, PRICED_MODELS } from "./converse.js";
224
224
  import { resolvePhaseAgent, resolveScopeProfile, resolveRouteCandidates, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.js";
225
+ import { applyModelChoices, stateDigest } from "./model-choices.js";
225
226
  import { isTaskSize, legOf, projectRoute, routeDigestOf, routeWords, TASK_SIZES, PHASES as ROUTE_PHASES } from "./phase-routing.js";
226
227
  import { observeProviderReadiness, reportProviderReadinessAuthed } from "./runner.js";
227
228
  import { parseDemoUrl, projectDemoUrl, saveProjectDemo } from "./project-demo.js";
@@ -967,6 +968,7 @@ async function dispatchOn(command, positional, flags, repoList, base, options, e
967
968
  ...(options.onboardSeams === undefined ? {} : { onboardSeams: options.onboardSeams }),
968
969
  ...(options.upSeams === undefined ? {} : { upSeams: options.upSeams }),
969
970
  ...(options.upRole === undefined ? {} : { upRole: options.upRole }),
971
+ ...(options.folderLook === undefined ? {} : { folderLook: options.folderLook }),
970
972
  ...(upNeedsOneProcess(options) ? { upOneProcess: true } : {}),
971
973
  ...(activeRemote() === null ? {} : { principal: activeRemote() }),
972
974
  });
@@ -3941,11 +3943,11 @@ async function startConsole(options) {
3941
3943
  const { context } = options;
3942
3944
  // A git incident (teardown-git.ts) raises an attention notification in this console's outbox.
3943
3945
  noticeGitIncidents(context.store, context.clock);
3944
- // Every project folder this console starts with is resolved off its thread, under a deadline: one that doesn't
3945
- // answer (a drive away, an iCloud folder macOS is holding) stays unresolved, kept restrictive, and is looked at again
3946
- // by the background pass; the console starts and answers either way (project-identity.ts).
3947
- const identities = projectIdentities();
3948
- await identities.refresh([...options.repos ?? [], ...options.projectRoots ?? [], ...context.store.listProjects().map(one => one.path), ...context.store.knownRepos()]);
3946
+ // Every project folder this console starts with is resolved off its thread, under a deadline, after it is listening
3947
+ // (consoleIdentitiesInBackground): the console answers at once, and a project shows up once its folder answers. Until
3948
+ // then it is unresolved and kept restrictive; one that doesn't answer (a drive away, an iCloud folder macOS is
3949
+ // holding) is looked at again by the background pass (project-identity.ts).
3950
+ const identities = projectIdentities(context.folderLook);
3949
3951
  const server = createDecisionServer({
3950
3952
  identities,
3951
3953
  store: context.store,
@@ -3982,6 +3984,7 @@ async function startConsole(options) {
3982
3984
  }).catch(error => {
3983
3985
  throw new Error(`could not listen on ${options.host}:${options.port} — ${describe(error)}`);
3984
3986
  });
3987
+ consoleIdentitiesInBackground(identities, [...options.repos ?? [], ...options.projectRoots ?? [], ...context.store.listProjects().map(one => one.path), ...context.store.knownRepos()]);
3985
3988
  // v104: monitoring runs beside the console (every setup has one): one loop sends the audit stream and traces where
3986
3989
  // Settings → Monitoring says, and stops when the console does. A lease keeps a second process from sending too.
3987
3990
  const stopMonitoring = startMonitoring({
@@ -5042,6 +5045,9 @@ async function configCommand(positional, flags, context) {
5042
5045
  if (phase === undefined || !["plan", "build", "repair", "review"].includes(phase))
5043
5046
  return fail(write, json, `config ${action}`, "usage", "which phase? plan, build, repair, review — or chat", EXIT.usage);
5044
5047
  const acting = await askCredentials(flags, context);
5048
+ // The credential generation the proof below is checked against, read BEFORE it: a rotation after this read makes
5049
+ // the write refuse (model-choices.ts re-checks it inside the transaction), never borrow the new generation.
5050
+ const provedGeneration = acting === null ? -1 : store.accountOf(acting.name)?.generation ?? -1;
5045
5051
  const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
5046
5052
  if (acting === null || !verified?.ok)
5047
5053
  return refuseApprover(context, `config ${action}`, verified, "changing spend routing takes `--as <you> --token <t>`", phase);
@@ -5057,6 +5063,18 @@ async function configCommand(positional, flags, context) {
5057
5063
  // Clearing one left from before stays possible.
5058
5064
  if (tierGiven === "light" && phase !== "build" && !(action === "clear" && phase === "plan" && !flag(flags, "also")))
5059
5065
  return fail(write, json, `config ${action}`, "usage", "--tier light is for build only — a small change makes no plan, and repairs resume the builder's session", EXIT.usage);
5066
+ // Every row below — plain, tier, `--also`, and a leftover light planner's removal — is written through the one write
5067
+ // path (model-choices.ts): one transaction, the actor re-proved as an instance operator at the credential generation
5068
+ // proved above by a proof that still stands, the target and the resulting configuration checked, every row ledgered.
5069
+ // A project named with --repo must be one Toolroll knows (the editor's own rule).
5070
+ const projectKnown = repoGiven === undefined || (await knownProject(context, repoGiven, { listed: true, canonical: true })) !== undefined;
5071
+ const proof = { actor: acting.name, generation: provedGeneration, method: verified.method };
5072
+ const written = (slot, to, extra = []) => writeModelRow(store, proof, scope, () => projectKnown, slot, to, clock(), extra);
5073
+ // A configuration still missing a role's model is saved, and said (JSON: `incomplete`).
5074
+ const said = (command, result, payload, lines) => {
5075
+ const incomplete = result.ok ? result.unresolved : null;
5076
+ return succeed(write, json, command, { ...payload, ...(incomplete === null ? {} : { incomplete }) }, () => [...lines(), ...(incomplete === null ? [] : [` ! new tasks can't be filed here yet: ${incomplete}`])]);
5077
+ };
5060
5078
  // Another provider's candidate on the same tier (`--also`): one per provider, and at filing the one whose plan
5061
5079
  // has more room runs.
5062
5080
  if (flag(flags, "also")) {
@@ -5067,8 +5085,11 @@ async function configCommand(positional, flags, context) {
5067
5085
  if (alsoProvider !== undefined && !isProviderId(alsoProvider))
5068
5086
  return fail(write, json, `config ${action}`, "usage", `--provider is one of ${PROVIDER_IDS.join(", ")}`, EXIT.usage);
5069
5087
  if (action === "clear") {
5070
- const removed = store.clearPhaseTierAlternates(scope, phase, tier, alsoProvider ?? null);
5071
- return succeed(write, json, "config clear", { scope, phase, tier, also: true, provider: alsoProvider ?? null, cleared: removed }, () => [
5088
+ const alsoClear = written(null, null, [{ kind: "also-clear", phase, tier, provider: alsoProvider ?? null }]);
5089
+ if (!alsoClear.ok)
5090
+ return fail(write, json, "config clear", alsoClear.reason, alsoClear.problem, EXIT.refused);
5091
+ const removed = alsoClear.extra;
5092
+ return said("config clear", alsoClear, { scope, phase, tier, also: true, provider: alsoProvider ?? null, cleared: removed }, () => [
5072
5093
  removed > 0 ? `Removed ${removed} other-provider ${tier} ${phase} agent${removed === 1 ? "" : "s"} at ${scope}; sealed routes are untouched.` : `No other-provider ${tier} ${phase} agent${alsoProvider === undefined ? "" : ` on ${alsoProvider}`} was configured at ${scope}.`,
5073
5094
  ]);
5074
5095
  }
@@ -5078,10 +5099,12 @@ async function configCommand(positional, flags, context) {
5078
5099
  const alsoValid = validateSpec({ provider: alsoProvider, model: alsoModel });
5079
5100
  if (!alsoValid.ok)
5080
5101
  return fail(write, json, "config set", "invalid", alsoValid.problem, EXIT.usage);
5081
- store.setPhaseTierAlternate(scope, phase, tier, alsoProvider, alsoModel, acting.name, clock());
5102
+ const alsoSet = written(null, null, [{ kind: "also-set", phase, tier, provider: alsoProvider, model: alsoModel }]);
5103
+ if (!alsoSet.ok)
5104
+ return fail(write, json, "config set", alsoSet.reason, alsoSet.problem, EXIT.refused);
5082
5105
  const resolvedNow = resolveRouteCandidates(store, scope === INSTALLATION_SCOPE ? null : scope);
5083
5106
  const own = !resolvedNow.ok ? null : tier === "routine" ? resolvedNow.candidates[phase].routine : tier === "light" ? resolvedNow.candidates[phase].light ?? null : resolvedNow.candidates[phase].strong;
5084
- return succeed(write, json, "config set", { scope, phase, tier, also: true, provider: alsoProvider, model: alsoModel }, () => [
5107
+ return said("config set", alsoSet, { scope, phase, tier, also: true, provider: alsoProvider, model: alsoModel }, () => [
5085
5108
  `${tier} ${phase} at ${scope === INSTALLATION_SCOPE ? "the installation" : scope} can also run ${alsoProvider} · ${alsoModel}, set by ${acting.name}.`,
5086
5109
  own !== null && own.provider === alsoProvider && !own.source.includes("also")
5087
5110
  ? ` ! the ${tier} ${phase} agent already runs ${alsoProvider} (${own.model}) — this one is not used while it does.`
@@ -5089,20 +5112,39 @@ async function configCommand(positional, flags, context) {
5089
5112
  ]);
5090
5113
  }
5091
5114
  if (action === "clear") {
5115
+ if (tierGiven === "light" && phase === "build") {
5116
+ const lightWrite = written({ phase, tier: "light" }, null);
5117
+ if (!lightWrite.ok)
5118
+ return fail(write, json, "config clear", lightWrite.reason, lightWrite.problem, EXIT.refused);
5119
+ const clearedLight = lightWrite.diff.length > 0;
5120
+ return said("config clear", lightWrite, { scope, phase, tier: "light", cleared: clearedLight }, () => [
5121
+ clearedLight ? `Cleared the light ${phase} agent at ${scope} — small changes keep the default and say so; sealed routes are untouched.` : `No light ${phase} agent was configured at ${scope}.`,
5122
+ ]);
5123
+ }
5092
5124
  if (tierGiven === "light") {
5093
- const clearedLight = store.clearPhaseTierConfig(scope, phase, "light");
5094
- return succeed(write, json, "config clear", { scope, phase, tier: "light", cleared: clearedLight }, () => [
5125
+ // A light planner from before light became build-only: removed here, never written.
5126
+ const legacyClear = written(null, null, [{ kind: "legacy-light-clear", phase: phase }]);
5127
+ if (!legacyClear.ok)
5128
+ return fail(write, json, "config clear", legacyClear.reason, legacyClear.problem, EXIT.refused);
5129
+ const clearedLight = legacyClear.extra > 0;
5130
+ return said("config clear", legacyClear, { scope, phase, tier: "light", cleared: clearedLight }, () => [
5095
5131
  clearedLight ? `Cleared the light ${phase} agent at ${scope} — small changes keep the default and say so; sealed routes are untouched.` : `No light ${phase} agent was configured at ${scope}.`,
5096
5132
  ]);
5097
5133
  }
5098
5134
  if (tierGiven === "strong") {
5099
- const clearedStrong = store.clearPhaseTierConfig(scope, phase, "strong");
5100
- return succeed(write, json, "config clear", { scope, phase, tier: "strong", cleared: clearedStrong }, () => [
5135
+ const strongWrite = written({ phase: phase, tier: "strong" }, null);
5136
+ if (!strongWrite.ok)
5137
+ return fail(write, json, "config clear", strongWrite.reason, strongWrite.problem, EXIT.refused);
5138
+ const clearedStrong = strongWrite.diff.length > 0;
5139
+ return said("config clear", strongWrite, { scope, phase, tier: "strong", cleared: clearedStrong }, () => [
5101
5140
  clearedStrong ? `Cleared the strong ${phase} agent at ${scope} — demanding routes keep the default and say so; sealed routes are untouched.` : `No strong ${phase} agent was configured at ${scope}.`,
5102
5141
  ]);
5103
5142
  }
5104
- const cleared = store.clearPhaseConfig(scope, phase);
5105
- return succeed(write, json, "config clear", { scope, phase, cleared }, () => [
5143
+ const routineWrite = written({ phase: phase, tier: "routine" }, null);
5144
+ if (!routineWrite.ok)
5145
+ return fail(write, json, "config clear", routineWrite.reason, routineWrite.problem, EXIT.refused);
5146
+ const cleared = routineWrite.diff.length > 0;
5147
+ return said("config clear", routineWrite, { scope, phase, cleared }, () => [
5106
5148
  cleared ? `Cleared ${phase} at ${scope} — it resolves one layer down now.` : `Nothing was configured for ${phase} at ${scope}.`,
5107
5149
  ]);
5108
5150
  }
@@ -5116,8 +5158,10 @@ async function configCommand(positional, flags, context) {
5116
5158
  if (tierGiven === "light") {
5117
5159
  if (modelGiven === null)
5118
5160
  return fail(write, json, "config set", "usage", `a light ${phase} agent names an exact --model — approvals bind exact routing`, EXIT.usage);
5119
- store.setPhaseTierConfig(scope, phase, "light", providerGiven, modelGiven, acting.name, clock());
5120
- return succeed(write, json, "config set", { scope, phase, tier: "light", provider: providerGiven, model: modelGiven }, () => [
5161
+ const lightSet = written({ phase: phase, tier: "light" }, { provider: providerGiven, model: modelGiven });
5162
+ if (!lightSet.ok)
5163
+ return fail(write, json, "config set", lightSet.reason, lightSet.problem, EXIT.refused);
5164
+ return said("config set", lightSet, { scope, phase, tier: "light", provider: providerGiven, model: modelGiven }, () => [
5121
5165
  `light ${phase} at ${scope === INSTALLATION_SCOPE ? "the installation" : scope} is ${providerGiven} · ${modelGiven}, set by ${acting.name}.`,
5122
5166
  " small changes filed from now on build on it with no plan; sealed routes are untouched.",
5123
5167
  ]);
@@ -5127,14 +5171,19 @@ async function configCommand(positional, flags, context) {
5127
5171
  return fail(write, json, "config set", "invalid", "gemini has no isolation posture for the review phase yet — name claude or codex as the strong reviewer", EXIT.usage);
5128
5172
  if (modelGiven === null)
5129
5173
  return fail(write, json, "config set", "usage", `a strong ${phase} agent names an exact --model — approvals bind exact routing`, EXIT.usage);
5130
- store.setPhaseTierConfig(scope, phase, "strong", providerGiven, modelGiven, acting.name, clock());
5131
- return succeed(write, json, "config set", { scope, phase, tier: "strong", provider: providerGiven, model: modelGiven }, () => [
5174
+ const strongSet = written({ phase: phase, tier: "strong" }, { provider: providerGiven, model: modelGiven });
5175
+ if (!strongSet.ok)
5176
+ return fail(write, json, "config set", strongSet.reason, strongSet.problem, EXIT.refused);
5177
+ return said("config set", strongSet, { scope, phase, tier: "strong", provider: providerGiven, model: modelGiven }, () => [
5132
5178
  `strong ${phase} at ${scope === INSTALLATION_SCOPE ? "the installation" : scope} is ${providerGiven}${modelGiven === null ? "" : ` · ${modelGiven}`}, set by ${acting.name}.`,
5133
5179
  " large or risky, strict, screenshot-proof, and automerge routes filed from now on reach for it; sealed routes are untouched.",
5134
5180
  ...(phase === "repair" ? [" a strong repair agent applies only when its provider is the build's — cross-provider repair does not exist."] : []),
5135
5181
  ]);
5136
5182
  }
5137
- store.setPhaseConfig(scope, phase, providerGiven, modelGiven, acting.name, clock());
5183
+ // A routine row may still name no model on the command line (said below); the editor never writes one.
5184
+ const routineSet = written({ phase: phase, tier: "routine" }, { provider: providerGiven, model: modelGiven });
5185
+ if (!routineSet.ok)
5186
+ return fail(write, json, "config set", routineSet.reason, routineSet.problem, EXIT.refused);
5138
5187
  const warnings = [];
5139
5188
  if (modelGiven === null) {
5140
5189
  warnings.push(`no --model named: approvals bind exact routing, so scopes cannot file until ${phase} has an exact model — \`config set ${phase} --provider ${providerGiven} --model <model>\``);
@@ -5145,7 +5194,7 @@ async function configCommand(positional, flags, context) {
5145
5194
  if (providerGiven !== "claude") {
5146
5195
  warnings.push(`${providerGiven} does not report dollar cost: its runs land as UNMEASURED, and any schedule with a weekly limit fails closed on them by design.`);
5147
5196
  }
5148
- return succeed(write, json, "config set", { scope, phase, provider: providerGiven, model: modelGiven, warnings }, () => [
5197
+ return said("config set", routineSet, { scope, phase, provider: providerGiven, model: modelGiven, warnings }, () => [
5149
5198
  `${phase} at ${scope === INSTALLATION_SCOPE ? "the installation" : scope} now runs ${providerGiven}${modelGiven === null ? "" : ` · ${modelGiven}`}, set by ${acting.name}.`,
5150
5199
  ...warnings.map(one => ` ! ${one}`),
5151
5200
  ]);
@@ -7493,11 +7542,14 @@ async function upCommand(flags, context) {
7493
7542
  pendingLinks.set(id, value => { clearTimeout(timer); resolveLink(value); });
7494
7543
  consoleRole?.send({ type: "sign-in-link", id, account });
7495
7544
  });
7496
- const home = homedir();
7497
- const file = approverPlan2.passwordFile === null ? null : approverPlan2.passwordFile.startsWith(`${home}/`) ? `~${approverPlan2.passwordFile.slice(home.length)}` : approverPlan2.passwordFile;
7545
+ // Every start says where the saved password is and the exact command that shows it, browser or not (Settings
7546
+ // revamp S4, D2). Only the path is printed, never what the file holds: absolute (a relative --db names this
7547
+ // directory), single-quoted (no ~ or $ expands), after `--` (a leading dash is never an option).
7548
+ const saved = approverPlan2.passwordFile === null ? null : resolve(approverPlan2.passwordFile);
7549
+ const showCommand = saved === null ? null : `cat -- '${saved.replaceAll("'", "'\\''")}'`;
7498
7550
  const signInWords = account === null
7499
7551
  ? `sign in as one of ${approverPlan2.approvers.join(", ")}`
7500
- : file === null ? `sign in as ${account} with your password` : `sign in as ${account}: the password is in ${file}`;
7552
+ : showCommand === null ? `sign in as ${account} with your password` : `sign in as ${account}`;
7501
7553
  write("");
7502
7554
  write(`Toolroll is on ${url}`);
7503
7555
  if (link !== null) {
@@ -7507,8 +7559,11 @@ async function upCommand(flags, context) {
7507
7559
  }
7508
7560
  else {
7509
7561
  write(`${signInWords.charAt(0).toUpperCase()}${signInWords.slice(1)}.`);
7510
- write("Ctrl-C stops it.");
7511
7562
  }
7563
+ if (showCommand !== null)
7564
+ write(`Your password: run \`${showCommand}\``);
7565
+ if (link === null)
7566
+ write("Ctrl-C stops it.");
7512
7567
  }
7513
7568
  }
7514
7569
  // A bounded run (--for) counts from readiness, so a slow start never eats the window it asked for.
@@ -11744,7 +11799,7 @@ function upNeedsOneProcess(options) {
11744
11799
  options.flowEveryMs !== undefined || options.platform !== undefined || options.fenceAvailable !== undefined || options.flowFetch !== undefined ||
11745
11800
  options.integrationIo !== undefined || options.shouldStop !== undefined || options.leadSeams !== undefined || options.waitSleep !== undefined ||
11746
11801
  options.releaseIo !== undefined || options.installBin !== undefined || options.tempRoots !== undefined || options.filingSizer !== undefined ||
11747
- options.now !== undefined;
11802
+ options.now !== undefined || options.folderLook !== undefined;
11748
11803
  }
11749
11804
  /** The entry a role child runs: the built sibling when installed, the TypeScript source through tsx in a checkout. */
11750
11805
  /**
@@ -12688,3 +12743,18 @@ export function upNarrowDeferred(held, reported) {
12688
12743
  const kept = new Set(reported);
12689
12744
  return held.filter(one => kept.has(one));
12690
12745
  }
12746
+ /** Resolve the console's project identities after it is listening, never before: the deploy's health check and every
12747
+ * page answer at once, and each project shows up as its folder answers (project-identity.ts `refresh`, its looks a
12748
+ * few at a time under their deadline). Nothing waits on it; a pass that fails leaves those paths unresolved, which
12749
+ * fails closed, for the background pass to try again. */
12750
+ export function consoleIdentitiesInBackground(identities, paths) {
12751
+ void identities.refresh(paths).catch(() => false);
12752
+ }
12753
+ /** `config set` and `config clear` on the command line, through Settings → Models' one write path (model-choices.ts,
12754
+ * docs/plans/spend-dashboard.md slice 3), under the same target and resulting-configuration checks as the editor: the
12755
+ * person proved themselves with their own password or login at `proved.generation`; a machine credential (a remote
12756
+ * token, the lead's token) never changes which models run. The write re-proves them inside its transaction: still an
12757
+ * unrestricted instance operator, at that very generation, by a proof that still stands. */
12758
+ export function writeModelRow(store, proved, scope, knownProject, slot, to, now, extra = []) {
12759
+ return applyModelChoices(store, { scope, change: slot === null ? [] : [{ slot, to }], expected: null, strict: false, knownProject, extra }, { ...proved, live: () => true }, now);
12760
+ }
@@ -21,7 +21,7 @@ export type AdmissionActor = {
21
21
  };
22
22
  export type AdmissionFailure = {
23
23
  ok: false;
24
- reason: "unavailable" | "not-git" | "locked" | "registry" | "abandoned" | "failed";
24
+ reason: "unavailable" | "not-git" | "locked" | "registry" | "abandoned" | "failed" | "moved";
25
25
  message: string;
26
26
  };
27
27
  /** `remote`: the origin URL this add recorded as the project's trusted remote (null: none), and why one wasn't. */
@@ -60,6 +60,9 @@ export declare function admitProject(store: Store, input: {
60
60
  captureOrigin?: boolean;
61
61
  /** Explicit value from the owner/instance-operator CLI boundary. */
62
62
  remoteUrl?: string;
63
+ /** The caller's authority, asked again about the proved repo inside the locked registry update and inside the
64
+ * database transaction (the console: nothing it proved before an await moved, and the repo is still visible). */
65
+ stillAllowed?: (repo: string) => boolean;
63
66
  }): Promise<Admitted | AdmissionFailure>;
64
67
  /**
65
68
  * Remove one project from the lists and the builder. Its tasks, results and
@@ -61,13 +61,29 @@ export async function admitProject(store, input) {
61
61
  ? await originOf(repo, input.exec ?? run) : null;
62
62
  let remote = { url: store.projectRemote(repo), why: null };
63
63
  let registryHad = true;
64
+ const allowed = () => input.stillAllowed === undefined || input.stillAllowed(repo);
65
+ const moved = { ok: false, reason: "moved", message: `${projectName(repo)} wasn't added: a project folder changed while it was being checked. Try again.` };
64
66
  if (input.registryFile !== null) {
65
- const enrolled = await updateRepos(input.registryFile, repos => addRepos(repos, [repo]));
67
+ let refused = false;
68
+ let had = false;
69
+ const enrolled = await updateRepos(input.registryFile, repos => {
70
+ had = repos.includes(repo);
71
+ if (!allowed()) {
72
+ refused = true;
73
+ return repos;
74
+ }
75
+ return addRepos(repos, [repo]);
76
+ });
66
77
  if (!enrolled.ok)
67
78
  return { ok: false, reason: enrolled.reason, message: `${projectName(repo)} wasn't added: ${enrolled.message}` };
68
- registryHad = before !== null && !("error" in before) && before.repos.includes(repo);
79
+ if (refused)
80
+ return moved;
81
+ registryHad = had;
69
82
  }
70
83
  const added = store.transact(() => {
84
+ // Re-asked with the write; a refusal writes nothing here and takes back the registry line this call added below.
85
+ if (!allowed())
86
+ return null;
71
87
  const rowHad = store.listProjects().some(one => one.path === repo);
72
88
  const previous = store.projectRemote(repo);
73
89
  const first = !known && !store.projectAdmissionKnown(repo);
@@ -81,6 +97,11 @@ export async function admitProject(store, input) {
81
97
  store.recordAction({ at: input.now.toISOString(), actor: input.actor.label, repo, taskId: null, runId: null, action: PROJECT_ADDED, outcome: "added", source: "access", detail: `from ${input.actor.origin}` });
82
98
  return true;
83
99
  });
100
+ if (added === null) {
101
+ if (input.registryFile !== null && !registryHad)
102
+ await updateRepos(input.registryFile, repos => removeRepos(repos, [repo]));
103
+ return moved;
104
+ }
84
105
  return { ok: true, repo, name: projectName(repo), added, remote };
85
106
  }
86
107
  /**