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
@@ -8,7 +8,8 @@ import { createHmac, randomBytes } from "node:crypto";
8
8
  import { realpathSync } from "node:fs";
9
9
  import {} from "node:http";
10
10
  import { homedir, hostname } from "node:os";
11
- import { dirname, join, resolve } from "node:path";
11
+ import { basename, dirname, join, resolve } from "node:path";
12
+ import { buildersFor } from "../dispatch.js";
12
13
  import { listFolder, localOrigins } from "../folder-look.js";
13
14
  import { Readable } from "node:stream";
14
15
  import { pipeline } from "node:stream/promises";
@@ -50,7 +51,7 @@ import { refuse, regionScript, screen, settingsRows, WORKSPACE_STYLE, workToolRo
50
51
  import { redirect, respond, safeReturn, SAFETY, taskHref } from "./http.js";
51
52
  import { assignmentOf } from "../assignment.js";
52
53
  import { assignmentTaskStatusOf } from "../assignment-presentation.js";
53
- import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage, homePage, portfolioOverview, projectsPage, systemPage, workbenchRail } from "./render-pages.js";
54
+ import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage, homePage, portfolioOverview, projectsPage, startBuilderHtml, systemPage, workbenchRail } from "./render-pages.js";
54
55
  import { capsPage } from "./render-settings.js";
55
56
  import { decisionAnswerScript, taskBody } from "./render-tasks.js";
56
57
  import {} from "./session.js";
@@ -640,6 +641,7 @@ export function createPagesHandlers(runtime) {
640
641
  }
641
642
  const askedPath = (body.get("path") ?? "").trim();
642
643
  // Resolved off the console's thread (project-identity.ts): a folder that doesn't answer is said, never waited on.
644
+ const decidedOn = identities.generation();
643
645
  const picked = askedPath === "" ? null : await identities.resolveNow(askedPath);
644
646
  if (picked === "no-answer")
645
647
  return refuse(response, who, 503, "That project's folder didn't answer. It may be on a drive that's disconnected, or waiting on macOS. Try again in a moment.");
@@ -647,6 +649,9 @@ export function createPagesHandlers(runtime) {
647
649
  if ((askedPath !== "" && canonicalPick === null) || (canonicalPick !== null && (!visible(canonicalPick) || !(await authorizedProject(liveCeiling(), canonicalPick))))) {
648
650
  return refuse(response, who, 403, "that project is outside this console's reach");
649
651
  }
652
+ // An identity that changed during the awaits above undoes what they proved.
653
+ if (identities.generation() !== decidedOn)
654
+ return refuse(response, who, 409, "a project folder changed while this was being checked — try again");
650
655
  who.session.project = canonicalPick;
651
656
  who.session.projectRevision += 1;
652
657
  sessions.persist(who.session);
@@ -680,6 +685,7 @@ export function createPagesHandlers(runtime) {
680
685
  return refuse(response, who, 403, "opening a project is a browser session's act");
681
686
  }
682
687
  const asked = (body.get("path") ?? "").trim();
688
+ const decidedOn = identities.generation();
683
689
  const resolved = asked === "" ? null : await identities.resolveNow(asked);
684
690
  if (resolved === "no-answer") {
685
691
  return void projectsScreen(response, who, "that folder didn't answer — it may be on a drive that's disconnected, or waiting on macOS; try again in a moment", 503, safeReturn(body.get("return")));
@@ -702,13 +708,21 @@ export function createPagesHandlers(runtime) {
702
708
  if (!visible(canonical)) {
703
709
  return void projectsScreen(response, who, "That project is outside your access.", 403, safeReturn(body.get("return")));
704
710
  }
711
+ // An identity that changed during the awaits above undoes what they proved.
712
+ if (identities.generation() !== decidedOn)
713
+ return void projectsScreen(response, who, "a project folder changed while this was being checked — try again", 409, safeReturn(body.get("return")));
705
714
  // Opening an allowed repository admits it the same way `repos add` does
706
715
  // (project-admission.ts). A co-located `up` notices the durable list and
707
716
  // connects its builder; there is no separate restart or binding step.
708
- const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now });
717
+ // The admission asks again, inside its registry lock and its transaction, about the repo it proves.
718
+ const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
719
+ const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now, stillAllowed });
709
720
  if (!admitted.ok) {
710
- return void projectsScreen(response, who, admitted.reason === "not-git" ? "that path is not a git repository" : `that project is valid, but it could not be added — ${admitted.message}`, 400, safeReturn(body.get("return")));
721
+ return void projectsScreen(response, who, admitted.reason === "not-git" ? "that path is not a git repository" : `that project is valid, but it could not be added — ${admitted.message}`, admitted.reason === "moved" ? 409 : 400, safeReturn(body.get("return")));
711
722
  }
723
+ // And once more before the session follows it.
724
+ if (!stillAllowed(admitted.repo))
725
+ return void projectsScreen(response, who, "a project folder changed while this was being checked — try again", 409, safeReturn(body.get("return")));
712
726
  deletedRepos.delete(admitted.repo);
713
727
  who.session.project = admitted.repo;
714
728
  who.session.projectRevision++;
@@ -858,6 +872,8 @@ export function createPagesHandlers(runtime) {
858
872
  return projectsScreen(response, who, "that preview expired or was already used — preview again", 400);
859
873
  }
860
874
  live.onboard?.delete(nonce); // consumed BEFORE the first await
875
+ // Identities as this confirmation found them: the admission below re-checks against them (project-identity.ts).
876
+ const decidedOn = identities.generation();
861
877
  if (record.large && body.get("big-ok") !== "1") {
862
878
  return projectsScreen(response, who, "that repository is large (or its size is unknown) — tick the box to clone it anyway", 400);
863
879
  }
@@ -887,10 +903,13 @@ export function createPagesHandlers(runtime) {
887
903
  if (admitted === null) {
888
904
  return projectsScreen(response, who, `the clone landed at ${cloned.target} but did not prove under the ceiling — it was left in place; enroll it by hand`, 400);
889
905
  }
890
- const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now });
906
+ const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
907
+ const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now, stillAllowed });
891
908
  if (!joined.ok) {
892
- return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, 400);
909
+ return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, joined.reason === "moved" ? 409 : 400);
893
910
  }
911
+ if (!stillAllowed(joined.repo))
912
+ return projectsScreen(response, who, `${cloned.target} is cloned but wasn't opened — a project folder changed while it was being checked; try again`, 409);
894
913
  deletedRepos.delete(admitted);
895
914
  who.session.project = admitted;
896
915
  who.session.projectRevision += 1;
@@ -1058,6 +1077,22 @@ export function createPagesHandlers(runtime) {
1058
1077
  return respond(response, 404, "application/json", JSON.stringify({ error: "run" }));
1059
1078
  return respond(response, 200, "application/json", JSON.stringify(window.ok ? window : { error: window.reason }));
1060
1079
  }
1080
+ /** Start a builder (Settings revamp S4): the command, and whether a builder that may build the open project is
1081
+ * checking in, as dispatch decides. Read-only; a project-limited person sees only a project they may see. */
1082
+ async function startBuilderPage(ctx) {
1083
+ const { response, now, project, url, who } = ctx;
1084
+ // ?task=<id>: that task's own saved project (a builder there, as dispatch decides for it), never the open one; the
1085
+ // session's selection is left alone. Unknown, unplaced or not visible to this person: 404, the same answer.
1086
+ const taskId = url.searchParams.get("task");
1087
+ const ref = taskId === null ? null : store.lookupRef(taskId.slice(0, 200));
1088
+ if (taskId !== null && (ref === null || ref.repo === null || !visible(ref.repo)))
1089
+ return refuse(response, who, 404, "That task is not available.", "/work");
1090
+ const shown = ref !== null ? ref.repo : project !== null && visible(project) ? project : null;
1091
+ const connected = shown === null ? null : buildersFor(store, shown, now, ref?.assignedRunner ?? null).alive.length > 0;
1092
+ return sendScreen(response, 200, screen("Start a builder", startBuilderHtml({
1093
+ heading: "h1", project: shown === null ? null : basename(shown), connected, managed: !restricted(),
1094
+ }), { chrome: chromeFor(project, "none") }));
1095
+ }
1061
1096
  const registrations = handlersOf("pages", {
1062
1097
  "projects.browse": projectsBrowse,
1063
1098
  "projects.github": projectsGithub,
@@ -1069,6 +1104,7 @@ export function createPagesHandlers(runtime) {
1069
1104
  "ledger": ledgerPage,
1070
1105
  "activity": activityPage,
1071
1106
  "system": systemScreen,
1107
+ "start-builder": startBuilderPage,
1072
1108
  "peek": peekPage,
1073
1109
  "agents": agentsPage,
1074
1110
  "agents.live": agentsLive,
@@ -12,7 +12,7 @@ import { SSO_CALLBACK } from "../sso-settings.js";
12
12
  import { personChip, refuse, screen } from "./chrome.js";
13
13
  import { redirect, respond, sayTo } from "./http.js";
14
14
  /** people handlers, moved without changing their route bodies. */
15
- import { randomBytes, timingSafeEqual } from "node:crypto";
15
+ import { randomBytes } from "node:crypto";
16
16
  import {} from "node:http";
17
17
  import {} from "../contracts/console-api.js";
18
18
  import { listCoordinators } from "../coordinator.js";
@@ -22,8 +22,9 @@ import { personAuditHtml, personHref } from "../people-audit-ui.js";
22
22
  import { cursorOf as auditCursorOf, remoteAudit } from "../remote-audit.js";
23
23
  import { credentialProof, hashPassword, proveAccountNow } from "../scope.js";
24
24
  import { form, HANDOFF_STYLE, loginHref, loginReturn, page, SIGN_IN_COOKIE, signInSpent, startedHere } from "./http.js";
25
- import { joinDeadPage, joinFormPage, loginPage, signupPage } from "./render-people.js";
25
+ import { joinDeadPage, joinFormPage, loginPage, SIGNUP_NAME, SIGNUP_PASSWORD_MIN, signupPage } from "./render-people.js";
26
26
  import { SESSION_COOKIE, SIGN_IN_LINK_PATH } from "./session.js";
27
+ import { setupCodeMatches, signupCookie, signupFromHere } from "./signup-gate.js";
27
28
  /** Clock skew allowed between the provider and this server when reading auth_time — forward only (a provider clock
28
29
  * running ahead). auth_time is in whole seconds, so a check made in the visit's own second is floored below startedAt:
29
30
  * the one allowance backward is that rounding, never a check made before the visit began. */
@@ -39,7 +40,7 @@ export function sameVisitCheck(authTime, startedAt, now) {
39
40
  return Math.min(Math.max(at, startedAt), now);
40
41
  }
41
42
  export function createPeopleHandlers(runtime) {
42
- const { joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, defaultProject, cookieSecure, consoleOrigin, providerFor, providerKnown, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
43
+ const { joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, cookieSecure, consoleOrigin, providerFor, providerKnown, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
43
44
  // v101: Settings → Sessions & tokens: where you're signed in, and your API tokens. An instance operator can see everyone's.
44
45
  async function sessionsPage(ctx) {
45
46
  const { url, who, response, project } = ctx;
@@ -307,6 +308,11 @@ ${reauth(html `<label>Password <input type="password" name="token" autocomplete=
307
308
  async function signInPage(ctx) {
308
309
  const { url, response } = ctx;
309
310
  if (options.setupCode !== undefined && store.listApprovers().length === 0) {
311
+ // The form's same-site proof (signup-gate.ts): a post without it never reaches the setup code.
312
+ if (runtime.setupAttemptsLeft > 0)
313
+ response.setHeader("Set-Cookie", signupCookie(cookieSecure));
314
+ // Its own post then carries this console's Origin (the console's default no-referrer sends `null`).
315
+ response.setHeader("Referrer-Policy", "same-origin");
310
316
  return page(response, 200, signupPage(null, runtime.setupAttemptsLeft));
311
317
  }
312
318
  return page(response, 200, loginPage(null, loginReturn(url.searchParams.get("return")), ssoOffer()));
@@ -334,7 +340,7 @@ ${reauth(html `<label>Password <input type="password" name="token" autocomplete=
334
340
  return page(response, 403, loginPage(`Sign in with ${ssoSettings()?.label ?? "your identity provider"}.`, "/chat", ssoOffer()));
335
341
  const id = randomBytes(32).toString("hex");
336
342
  sessions.set(id, { ...arrival(request), name: held.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
337
- project: defaultProject, projectRevision: 1 });
343
+ project: runtime.defaultProject, projectRevision: 1 });
338
344
  recordSignIn(held.account, "signed in", "one-time link");
339
345
  response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
340
346
  return redirect(response, "/chat");
@@ -429,44 +435,58 @@ ${reauth(html `<label>Password <input type="password" name="token" autocomplete=
429
435
  const account = store.accountOf(signedIn.account);
430
436
  const id = randomBytes(32).toString("hex");
431
437
  sessions.set(id, { ...arrival(request), name: signedIn.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
432
- project: defaultProject, projectRevision: 1, sso: { at: handoff.checkedAt ?? 0 } });
438
+ project: runtime.defaultProject, projectRevision: 1, sso: { at: handoff.checkedAt ?? 0 } });
433
439
  recordSignIn(signedIn.account, "signed in", settings.label);
434
440
  response.setHeader("Set-Cookie", [signInSpent("/login/sso/finish"), `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
435
441
  return redirect(response, handoff.returnTo);
436
442
  }
437
443
  async function signupSend(ctx) {
438
444
  const { request, response } = ctx;
445
+ // A re-shown form posts again: its Origin must name this console too (signup-gate.ts).
446
+ response.setHeader("Referrer-Policy", "same-origin");
439
447
  // Only while the table is empty, only with the printed code, only
440
448
  // five tries: the bootstrap door underneath is atomic, so two first
441
449
  // visitors serialize to one owner.
442
450
  if (options.setupCode === undefined || store.listApprovers().length > 0) {
443
- return page(response, 409, loginPage("an account already exists — sign in"));
451
+ return page(response, 409, loginPage("An account already exists. Sign in."));
444
452
  }
445
453
  if (runtime.setupAttemptsLeft <= 0) {
446
- return page(response, 403, signupPage("too many wrong codes — restart the server to get a fresh code", 0));
454
+ return page(response, 403, signupPage(null, 0));
455
+ }
456
+ // Another site's post (or one without this page's cookie) is refused before it can spend a try.
457
+ if (!signupFromHere(request, runtime.allowedHost)) {
458
+ request.resume();
459
+ response.setHeader("Set-Cookie", signupCookie(cookieSecure));
460
+ return page(response, 403, signupPage("This form had expired, so it was reloaded. Enter the details again.", runtime.setupAttemptsLeft));
447
461
  }
448
462
  const body = readForm(await form(request), CONSOLE_FORMS.signup);
449
463
  const code = (body.get("code") ?? "").trim();
450
464
  const name = (body.get("name") ?? "").trim();
451
465
  const password = body.get("password") ?? "";
452
- const given = Buffer.from(code.padEnd(64, " "), "utf8");
453
- const wanted = Buffer.from(options.setupCode.padEnd(64, " "), "utf8");
454
- if (code === "" || !timingSafeEqual(given, wanted)) {
466
+ // Re-read after the body: concurrent posts all waited above, and only the tries still left may be compared.
467
+ // From here to the decrement nothing awaits, so check and spend are one step.
468
+ if (runtime.setupAttemptsLeft <= 0) {
469
+ return page(response, 403, signupPage(null, 0));
470
+ }
471
+ if (!setupCodeMatches(code, options.setupCode)) {
455
472
  runtime.setupAttemptsLeft -= 1;
456
- return page(response, 403, signupPage("that is not the setup code the server printed", runtime.setupAttemptsLeft));
473
+ // What was typed comes back except the code and the password (never echoed); the count rides the code's note.
474
+ return page(response, 403, signupPage(runtime.setupAttemptsLeft > 0
475
+ ? { field: "code", text: "That isn't the setup code Toolroll printed. Check the terminal and try again." }
476
+ : "That wasn't the setup code, and that was the last try. Restart Toolroll to get a new code.", runtime.setupAttemptsLeft, name));
457
477
  }
458
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
459
- return page(response, 400, signupPage("a username is letters, digits, dots, dashes — up to 64", runtime.setupAttemptsLeft));
478
+ if (!SIGNUP_NAME.test(name)) {
479
+ return page(response, 400, signupPage({ field: "name", text: "Use only letters, digits, dots, dashes or underscores, starting with a letter or digit." }, runtime.setupAttemptsLeft, name));
460
480
  }
461
- if (password.length < 8) {
462
- return page(response, 400, signupPage("a password is at least 8 characters", runtime.setupAttemptsLeft));
481
+ if (password.length < SIGNUP_PASSWORD_MIN) {
482
+ return page(response, 400, signupPage({ field: "password", text: `Use at least ${SIGNUP_PASSWORD_MIN} characters.` }, runtime.setupAttemptsLeft, name));
463
483
  }
464
484
  const made = store.bootstrapApproverIfNone(name, hashPassword(password), clock());
465
485
  if (!made.ok)
466
- return page(response, 409, loginPage("an account already exists — sign in"));
486
+ return page(response, 409, loginPage("An account already exists. Sign in."));
467
487
  const authenticated = authenticateAccount(store, name, password);
468
488
  if (authenticated === null || !authenticated.ok)
469
- return page(response, 500, loginPage("the account was created but could not sign in — try again"));
489
+ return page(response, 500, loginPage("The account was created, but signing in failed. Sign in with it now."));
470
490
  const id = randomBytes(32).toString("hex");
471
491
  sessions.set(id, {
472
492
  ...arrival(request),
@@ -477,7 +497,7 @@ ${reauth(html `<label>Password <input type="password" name="token" autocomplete=
477
497
  createdAt: Date.now(),
478
498
  sawBoardAt: null,
479
499
  lastSeen: Date.now(),
480
- project: defaultProject,
500
+ project: runtime.defaultProject,
481
501
  projectRevision: 1,
482
502
  // A password typed on this computer starts this session's freshness window (cockpit B2); from anywhere else, never.
483
503
  ...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
@@ -528,7 +548,7 @@ ${reauth(html `<label>Password <input type="password" name="token" autocomplete=
528
548
  createdAt: Date.now(),
529
549
  sawBoardAt: null,
530
550
  lastSeen: Date.now(),
531
- project: defaultProject,
551
+ project: runtime.defaultProject,
532
552
  projectRevision: 1,
533
553
  // A password typed to sign in on this computer starts this session's freshness window (cockpit B2); from anywhere
534
554
  // else (a phone, a proxy) it signs in and proves nothing more.
@@ -615,7 +635,7 @@ ${reauth(html `<label>Password <input type="password" name="token" autocomplete=
615
635
  createdAt: Date.now(),
616
636
  sawBoardAt: null,
617
637
  lastSeen: Date.now(),
618
- project: defaultProject,
638
+ project: runtime.defaultProject,
619
639
  projectRevision: 1,
620
640
  });
621
641
  response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
@@ -112,6 +112,16 @@ export declare function inboxPage(chrome: Chrome, data: {
112
112
  unread?: readonly InboxTab[];
113
113
  }): Screen;
114
114
  /** System: the machinery — workers, background service, workspaces. */
115
+ /** Start a builder: the one command, as text to select (/start-builder, where every "Connect a builder" leads, and
116
+ * /system#builder). `project`: the open project's name, when the viewer may see it; `connected`: whether a builder
117
+ * that may build there is checking in (null: say nothing); `managed`: whether this person can start one themselves
118
+ * (registering a builder takes an instance operator), else who to ask. */
119
+ export declare function startBuilderHtml(data: {
120
+ heading: "h1" | "h2";
121
+ project: string | null;
122
+ connected: boolean | null;
123
+ managed: boolean;
124
+ }): Html;
115
125
  export declare function systemPage(chrome: Chrome, data: {
116
126
  agents: {
117
127
  phase: "plan" | "build" | "repair" | "review";
@@ -9,13 +9,14 @@ import {} from "../dispatch.js";
9
9
  import {} from "../first-run.js";
10
10
  import {} from "../gaps.js";
11
11
  import { limitsHtml } from "../limits-ui.js";
12
- import { ASK_LABEL, ASKS } from "../needs-you.js";
12
+ import { ASK_LABEL, ASKS, START_BUILDER_COMMAND } from "../needs-you.js";
13
13
  import {} from "../onboard.js";
14
14
  import {} from "../peek.js";
15
15
  import { projectName } from "../project.js";
16
16
  import {} from "../proof.js";
17
17
  import {} from "../quality.js";
18
18
  import { isAlive as runnerAlive } from "../runner.js";
19
+ import { agentModelName, modelName } from "../model-names.js";
19
20
  import {} from "../scope.js";
20
21
  import {} from "../store.js";
21
22
  import { runCostWords, spendLine, tally } from "../summary.js";
@@ -80,9 +81,10 @@ export function inboxPage(chrome, data) {
80
81
  : html `<h2>Supply a requirement</h2><p class="hint">approved work is ready except for these — fill one and its tasks start</p>${joinHtml(data.gaps.map(gap => html `<p class="row"><a href="/caps">${gap.key}</a> <span class="meta">frees ${gap.unblocks.length} task${gap.unblocks.length > 1 ? "s" : ""}</span><span class="right meta">how to fix →</span></p>`), "\n")}`;
81
82
  // The first-run steps, until the first Ready result: each is done or
82
83
  // shows the one action that does it.
83
- const wizard = data.wizard === null
84
+ const steps = data.wizard;
85
+ const wizard = steps === null
84
86
  ? null
85
- : html `<div class="card" data-first-run><p><strong>Get to your first result</strong></p>${joinHtml(data.wizard.map(step => html `<p class="row" data-step="${step.key}"><span class="mono" aria-hidden="true">${step.done ? "✓" : "○"}</span> <strong>${step.title}</strong>${step.checking ? html ` <span class="meta">checking…</span>` : step.action === null ? html ` <span class="meta">done</span>`
87
+ : html `<div class="card" data-first-run><p><strong>Get to your first result</strong></p>${joinHtml(steps.map(step => html `<p class="row" data-step="${step.key}"><span class="mono" aria-hidden="true">${step.done ? "✓" : "○"}</span> <strong>${step.title}</strong>${step.checking ? html ` <span class="meta">checking…</span>` : step.action === null ? html ` <span class="meta">done</span>`
86
88
  : step.action.kind === "link" ? html ` <a href="${step.action.href}">${step.action.label}</a>`
87
89
  : html ` <code>${step.action.command}</code>`}</p>`), "\n")}</div>`;
88
90
  const noWorker = data.worker.answering > 0
@@ -146,6 +148,20 @@ export function inboxPage(chrome, data) {
146
148
  });
147
149
  }
148
150
  /** System: the machinery — workers, background service, workspaces. */
151
+ /** Start a builder: the one command, as text to select (/start-builder, where every "Connect a builder" leads, and
152
+ * /system#builder). `project`: the open project's name, when the viewer may see it; `connected`: whether a builder
153
+ * that may build there is checking in (null: say nothing); `managed`: whether this person can start one themselves
154
+ * (registering a builder takes an instance operator), else who to ask. */
155
+ export function startBuilderHtml(data) {
156
+ const where = data.project === null ? null : html ` to <strong>${data.project}</strong>`;
157
+ const state = data.connected === null ? null
158
+ : data.connected ? html `<p>A builder is connected${where}. Approved tasks there start on their own.</p>`
159
+ : html `<p><strong>No builder is connected</strong>${where}. Tasks wait until one is.</p>`;
160
+ const title = data.heading === "h1" ? html `<h1 id="builder-heading">Start a builder</h1>` : html `<h2 id="builder-heading">Start a builder</h2>`;
161
+ return html `<section id="builder" aria-labelledby="builder-heading">${title}<div class="card">${state}<p>On the computer where ${data.project ?? "the project"} lives, open the project's folder in a terminal and run:</p><p><code class="start-builder-command" style="user-select:all">${START_BUILDER_COMMAND}</code></p>${data.managed
162
+ ? html `<p class="meta">Keep it running. Approved tasks start on their own. The console alone (<code>toolroll serve</code>) doesn't build.</p>`
163
+ : html `<p class="meta">Starting a builder takes someone who manages this Toolroll. Ask them to run it there; your tasks start on their own once it connects.</p>`}</div></section>`;
164
+ }
149
165
  export function systemPage(chrome, data) {
150
166
  const nowMs = data.now.getTime();
151
167
  const runnerCards = data.runners
@@ -186,16 +202,15 @@ export function systemPage(chrome, data) {
186
202
  ? html `set for the whole installation${one.setBy === null ? null : html ` by ${one.setBy}`}`
187
203
  : html `the default — nothing configured`;
188
204
  const dollars = one.provider === "claude" ? null : html ` · <span title="this provider reports tokens, not dollars — its runs land as unmeasured spend">no dollar costs</span>`;
189
- return html `<p class="row"><span class="mono">${one.phase}</span> <strong>${one.provider ?? ""}</strong>${one.model === null || one.model === undefined ? html ` <span class="meta">(its default model)</span>` : html ` · <span class="mono">${one.model}</span>`}<span class="right meta">${who}${dollars}</span></p><p class="meta" style="margin-top:0">${PHASE_SAID[one.phase] ?? ""}</p>`;
205
+ return html `<p class="row"><span class="mono">${one.phase}</span> <strong>${one.provider === null || one.provider === undefined ? "" : agentModelName(one.provider, one.model ?? null)}</strong>${one.model === null || one.model === undefined ? html ` <span class="meta">(its default model)</span>` : null}<span class="right meta">${who}${dollars}</span></p><p class="meta" style="margin-top:0">${PHASE_SAID[one.phase] ?? ""}</p>`;
190
206
  }), "\n");
191
- const agentsCard = html `<h2>Agents</h2><p class="meta">Which AI provider runs each phase — changed from the terminal with your credentials (<code>toolroll config</code>), never by a browser click</p><div class="card">${agentLines}<p class="meta">Repair always stays on the provider that built — only its model can differ. A schedule's task filed under an approval moved from a routine is pinned to the agents approved then.</p></div>`;
207
+ const agentsCard = html `<h2>Agents</h2><p class="meta">Which AI provider runs each phase. An instance operator changes them in <a href="/settings/models">Settings → Models</a> with their password, or with <code>toolroll config</code>.</p><div class="card">${agentLines}<p class="meta">Repair always stays on the provider that built — only its model can differ. A schedule's task filed under an approval moved from a routine is pinned to the agents approved then.</p></div>`;
192
208
  return screen("system", joinHtml([
193
209
  html `<h1>System</h1>`,
194
210
  html `<p class="hint">builders execute tasks in isolated temporary copies of each project</p>`,
195
211
  agentsCard,
196
- cards.length === 0
197
- ? html `<p class="meta">No builder is connected yet. On the machine where the project lives, open that folder and run <code>toolroll up</code>.</p>`
198
- : html `<div class="cards">${cards}</div>`,
212
+ cards.length === 0 ? null : html `<div class="cards">${cards}</div>`,
213
+ startBuilderHtml({ heading: "h2", project: null, connected: data.runners.some(one => one.retiredAt === null && runnerAlive(one, data.now)) ? null : false, managed: true }),
199
214
  data.outboxPending > 0 ? html `<p class="meta">Notifications: ${data.outboxPending} pending delivery</p>` : null,
200
215
  (data.externalWork ?? []).length === 0
201
216
  ? null
@@ -372,11 +387,11 @@ export function boardBody(data, ciRed) {
372
387
  ? null
373
388
  : html `<span class="steps" role="img" aria-label="${`step ${step + 1} of ${BUILD_STEPS.length}: ${BUILD_STEPS[step]}`}">${BUILD_STEPS.map((_, index) => html `<i class="${index < step ? "on" : index === step ? "now" : ""}"></i>`)}</span>`;
374
389
  // Who, which model and which branch, as one muted line: words in Geist, the model and branch in mono.
375
- const model = claim.model === null ? null : `${claim.provider !== null && claim.provider !== "claude" ? `${claim.provider} · ` : ""}${claim.model}`;
390
+ const model = claim.model === null ? null : claim.provider === null ? modelName(null, claim.model) : agentModelName(claim.provider, claim.model);
376
391
  const branch = claim.branch === null ? null : `${claim.branch}${workspace === null ? "" : ` · ${workspace}`}`;
377
392
  const who = joinHtml([
378
393
  html `<span>${claim.runner}</span>`,
379
- model === null ? null : html `<span class="mono">${model}</span>`,
394
+ model === null ? null : html `<span>${model}</span>`,
380
395
  branch === null ? null : html `<span class="mono">${branch}</span>`,
381
396
  ].filter((one) => one !== null), html `<span aria-hidden="true"> · </span>`);
382
397
  return html `<a class="lane-card building" href="${card.href}" data-vt="${card.taskId}"><span class="t"><span class="dot dot-ok pulse"></span>${card.title}</span>${steps}<span class="live-line"><span class="stage">${live}</span>${tickingClock(claim.claimedAt, data.now)}</span><span class="id">${card.taskId}</span><span class="who">${who}</span>${chips([
@@ -477,7 +492,7 @@ export function homePage(chrome, data) {
477
492
  // an operator actually watches this page, so it re-renders itself.
478
493
  const building = data.building.length === 0
479
494
  ? null
480
- : html `<h2>Building now</h2><p class="hint">live builds — this page refreshes itself every 10 seconds while anything runs</p><div class="cards">${data.building.map(claim => html `<a class="stat-card" href="${taskHref(claim.taskId)}" style="text-decoration:none"><span class="k"><span class="dot dot-ok pulse"></span>${claim.taskId}</span><span class="v">${claim.runner} · ${Math.max(1, Math.round((data.now.getTime() - new Date(claim.claimedAt).getTime()) / 60_000))}m elapsed${claim.model === null ? null : html ` · ${claim.model}`}</span></a>`)}</div>`;
495
+ : html `<h2>Building now</h2><p class="hint">live builds — this page refreshes itself every 10 seconds while anything runs</p><div class="cards">${data.building.map(claim => html `<a class="stat-card" href="${taskHref(claim.taskId)}" style="text-decoration:none"><span class="k"><span class="dot dot-ok pulse"></span>${claim.taskId}</span><span class="v">${claim.runner} · ${Math.max(1, Math.round((data.now.getTime() - new Date(claim.claimedAt).getTime()) / 60_000))}m elapsed${claim.model === null ? null : html ` · ${modelName(null, claim.model)}`}</span></a>`)}</div>`;
481
496
  // THE FLEET: runners by heartbeat age, worktrees by lease state, the watch.
482
497
  const nowMs = data.now.getTime();
483
498
  const runnerCards = data.runners
@@ -797,7 +812,7 @@ export function portfolioOverview(data) {
797
812
  const outcomeWords = groups.filter(([, count]) => count > 0).map(([word, count]) => `${count} ${word}`).join(" · ");
798
813
  const summary = tally(data.runs24);
799
814
  // ---- running: current live claims, project chips on ----
800
- const liveRows = joinHtml(data.live.map(one => html `<p class="row"><a href="${taskHref(one.taskId)}">${one.taskId}</a>${projectChip(one.repo)} <span class="badge badge-running">Running</span> <span class="mono meta">${one.runner}${one.model === null ? null : html ` · ${one.model}`} · <time data-elapsed-since="${one.claimedAt}"></time></span></p>`), "\n");
815
+ const liveRows = joinHtml(data.live.map(one => html `<p class="row"><a href="${taskHref(one.taskId)}">${one.taskId}</a>${projectChip(one.repo)} <span class="badge badge-running">Running</span> <span class="meta"><span class="mono">${one.runner}</span>${one.model === null ? null : html ` · ${modelName(null, one.model)}`} · <time data-elapsed-since="${one.claimedAt}"></time></span></p>`), "\n");
801
816
  // ---- the ledger: terminal runs started in the window, one chip each ----
802
817
  const chipClass = (outcome) => outcome === "built" || outcome === "no-change" ? "badge-done" : outcome === "failed" ? "badge-failed" : "";
803
818
  const prLink = (one) => {
@@ -808,7 +823,7 @@ export function portfolioOverview(data) {
808
823
  const safe = safePrUrl(one.prUrl);
809
824
  return safe === null ? html ` · PR #${one.prNumber}` : html ` · <a href="${safe}">PR #${one.prNumber}</a>`;
810
825
  };
811
- const ledgerRows = joinHtml(data.ledger.map(one => html `<p class="row"><a href="/r/${one.runId}">${one.title}</a>${projectChip(one.repo)} <span class="badge ${chipClass(one.outcome)}">${sentenceCase(one.outcome)}</span> ${one.role === "scout" ? html `<a class="badge" href="${taskHref(one.taskId)}#report">Report</a> ` : null}<span class="mono meta">${one.provider === null ? "" : one.provider}${one.model === null ? null : html ` · ${one.model}`}${one.ranMinutes === null ? null : html ` · ${one.ranMinutes}m`} · ${runCostWords({ authMode: one.authMode, costUsd: one.costUsd, tokensIn: null, tokensOut: null })}${prLink(one)}</span></p>`), "\n");
826
+ const ledgerRows = joinHtml(data.ledger.map(one => html `<p class="row"><a href="/r/${one.runId}">${one.title}</a>${projectChip(one.repo)} <span class="badge ${chipClass(one.outcome)}">${sentenceCase(one.outcome)}</span> ${one.role === "scout" ? html `<a class="badge" href="${taskHref(one.taskId)}#report">Report</a> ` : null}<span class="meta">${one.provider === null ? (one.model === null ? "" : modelName(null, one.model)) : agentModelName(one.provider, one.model)}${one.ranMinutes === null ? null : html ` · ${one.ranMinutes}m`} · ${runCostWords({ authMode: one.authMode, costUsd: one.costUsd, tokensIn: null, tokensOut: null })}${prLink(one)}</span></p>`), "\n");
812
827
  return joinHtml([
813
828
  html `<div class="control-room-head"><div><h1>Portfolio</h1><p class="meta">Every project and live build in one place</p></div><div class="actions"><a class="badge" href="/tasks/new">+ New task</a><a class="badge" href="/board?scope=all">Full board →</a></div></div>`,
814
829
  data.saturated ? html `<div class="problem">This overview reached its 200-task display cap; the task list holds the rest.</div>` : null,
@@ -1099,7 +1114,7 @@ export function fleetBody(queued, building, runners, queueRevision, visibleRepo)
1099
1114
  const head = retired
1100
1115
  ? html `<p class="meta">This worker is retired — drag these elsewhere, or register the name again</p>`
1101
1116
  : html `${postForm("/queue/note", html `<input type="text" name="note" class="runner-note" value="${runner.queueNote ?? ""}" data-initial="${runner.queueNote ?? ""}" placeholder="what this worker is working through" aria-label="column note" maxlength="200">`, { attrs: { class: "row" }, hidden: { from: "fleet", runner: runner.name } })}<p class="meta">${runnerAlive(runner, new Date()) ? "alive" : "quiet"} · ${live.length}/${runner.capacity} building</p>`;
1102
- const buildingCards = joinHtml(live.map(claim => html `<div class="lane-card" data-taken="1"><p class="row"><span class="dot dot-ok pulse"></span> ${claim.taskId}</p><p class="row meta">building${chip(claim.repo ?? null)}${claim.model === null ? null : html ` · ${claim.model}`} · ${Math.max(1, Math.round((Date.now() - new Date(claim.claimedAt).getTime()) / 60_000))}m</p></div>`), "\n");
1117
+ const buildingCards = joinHtml(live.map(claim => html `<div class="lane-card" data-taken="1"><p class="row"><span class="dot dot-ok pulse"></span> ${claim.taskId}</p><p class="row meta">building${chip(claim.repo ?? null)}${claim.model === null ? null : html ` · ${modelName(null, claim.model)}`} · ${Math.max(1, Math.round((Date.now() - new Date(claim.claimedAt).getTime()) / 60_000))}m</p></div>`), "\n");
1103
1118
  const queuedCards = joinHtml(own.map(queuedCardOf), "\n");
1104
1119
  const empty = live.length === 0 && own.length === 0
1105
1120
  ? html `<p class="meta lane-empty">${retired ? "nothing left" : "idle — will take from the shared queue"}</p>`
@@ -29,5 +29,17 @@ export declare function wrongHostPage(facts: {
29
29
  served: string;
30
30
  command: string | null;
31
31
  }): Html;
32
- /** The first-account page (setup review): shown only while no approver exists. */
33
- export declare function signupPage(problem: string | null, attemptsLeft: number): Html;
32
+ /** Which field a first-account refusal is about (null: the page itself, as when the tries are spent). */
33
+ export type SignupProblem = {
34
+ field: "code" | "name" | "password" | null;
35
+ text: string;
36
+ };
37
+ /** The username rule, said once for the server's check, the input's `pattern` (anchored by the browser; `\-` so the
38
+ * v-flag parse accepts it) and the hint. */
39
+ export declare const SIGNUP_NAME: RegExp;
40
+ export declare const SIGNUP_PASSWORD_MIN = 8;
41
+ /** The first-account page (setup review): shown only while no approver exists. Rules are stated up front and match
42
+ * the inputs' own constraints. On a refusal the typed username comes back; the setup code and the password never do.
43
+ * The message sits under the field it is about, announced (role="alert"), with that field aria-invalid, described by
44
+ * it and focused, so it works without script (this page has none). */
45
+ export declare function signupPage(problem: SignupProblem | string | null, attemptsLeft: number, typedName?: string): Html;
@@ -99,21 +99,44 @@ ${facts.command === null ? "" : html `<p>To use this address, stop Toolroll (Ctr
99
99
  <p class="meta">On that computer, ${code(`http://${facts.served}/`)} always works. Other addresses need your say-so, so no other website can reach it.</p>
100
100
  </main></body></html>`;
101
101
  }
102
- /** The first-account page (setup review): shown only while no approver exists. */
103
- export function signupPage(problem, attemptsLeft) {
104
- return shell("Toolroll", joinHtml([
105
- html `<div class="login-viewport"><div class="login-shell">`,
106
- html `<h1 class="so-wordmark login-brand">${BRAND_HTML}</h1>`,
102
+ /** The username rule, said once for the server's check, the input's `pattern` (anchored by the browser; `\-` so the
103
+ * v-flag parse accepts it) and the hint. */
104
+ export const SIGNUP_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
105
+ const SIGNUP_NAME_PATTERN = "[A-Za-z0-9][A-Za-z0-9._\\-]{0,63}";
106
+ export const SIGNUP_PASSWORD_MIN = 8;
107
+ /** The first-account page (setup review): shown only while no approver exists. Rules are stated up front and match
108
+ * the inputs' own constraints. On a refusal the typed username comes back; the setup code and the password never do.
109
+ * The message sits under the field it is about, announced (role="alert"), with that field aria-invalid, described by
110
+ * it and focused, so it works without script (this page has none). */
111
+ export function signupPage(problem, attemptsLeft, typedName = "") {
112
+ const open = attemptsLeft > 0;
113
+ // Out of tries, the page always says why and what to do, also when it is simply opened again.
114
+ const said = typeof problem === "string" ? { field: null, text: problem }
115
+ : problem ?? (open ? null : { field: null, text: "Too many wrong setup codes. Restart Toolroll to get a new code." });
116
+ const field = open ? said?.field ?? null : null;
117
+ const error = (which) => field === which && said !== null ? html `<p class="problem signup-problem" id="signup-problem" role="alert">${said.text}</p>` : "";
118
+ const describe = (which, hint) => field === which ? html ` aria-invalid="true" aria-describedby="${hint} signup-problem" autofocus` : html ` aria-describedby="${hint}"`;
119
+ const tries = attemptsLeft === 1 ? "1 try left" : `${attemptsLeft} tries left`;
120
+ return shell("Create the first account · Toolroll", joinHtml([
121
+ // Inputs on this page reach a 44px target; the console's shared input rule stops at 36px.
122
+ html `<style>.signup h1{margin-bottom:1rem}.signup input{min-height:2.75rem}.signup .field-note{margin:.375rem 0 0;font-size:.75rem;line-height:1.45;color:var(--muted-foreground)}.signup .signup-problem{margin:.5rem 0 0}</style>`,
123
+ html `<div class="login-viewport"><div class="login-shell signup">`,
124
+ html `<p class="so-wordmark login-brand">${BRAND_HTML}</p>`,
125
+ html `<h1>Create the first account</h1>`,
107
126
  html `<div class="login-card">`,
108
- html `<p><strong>Create the first account</strong></p>`,
109
- html `<p class="meta">There are no accounts yet. The terminal that started Toolroll printed a setup code; enter it here with the username and password you want.</p>`,
110
- problem === null ? "" : html `<div class="problem">${problem}</div>`,
111
- attemptsLeft <= 0
127
+ !open || field === null ? (said === null ? "" : html `<div class="problem" role="alert">${said.text}</div>`) : "",
128
+ !open
112
129
  ? ""
113
130
  : postForm("/signup", joinHtml([
114
- html `<label>Setup code<input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" autofocus></label>`,
115
- html `<label>Username<input type="text" name="name" autocomplete="username" autocapitalize="none" spellcheck="false" required></label>`,
116
- html `<label>Password<input type="password" name="password" autocomplete="new-password"></label>`,
131
+ html `<label>Setup code<input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" autocapitalize="none" spellcheck="false" required${describe("code", "signup-code-note")}${field === null ? " autofocus" : ""}></label>`,
132
+ html `<p class="field-note" id="signup-code-note">Printed in the terminal that started Toolroll. ${tries}.</p>`,
133
+ error("code"),
134
+ html `<label>Username<input type="text" name="name" value="${typedName}" autocomplete="username" autocapitalize="none" spellcheck="false" required maxlength="64" pattern="${SIGNUP_NAME_PATTERN}"${describe("name", "signup-name-note")}></label>`,
135
+ html `<p class="field-note" id="signup-name-note">Letters, digits, dots, dashes or underscores, starting with a letter or digit. Up to 64.</p>`,
136
+ error("name"),
137
+ html `<label>Password<input type="password" name="password" autocomplete="new-password" required minlength="${SIGNUP_PASSWORD_MIN}"${describe("password", "signup-password-note")}></label>`,
138
+ html `<p class="field-note" id="signup-password-note">At least ${SIGNUP_PASSWORD_MIN} characters.</p>`,
139
+ error("password"),
117
140
  html `<button type="submit">Create account and sign in</button>`,
118
141
  ], "\n"), { signedOut: true }),
119
142
  html `</div>`,
@@ -511,6 +511,7 @@ export const SETTINGS_GROUPS = [
511
511
  ["Agents", [
512
512
  ["/settings/lead", "Lead", html `<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 9h8M8 13h5"/>`],
513
513
  ["/settings/models", "Models", html `<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/>`],
514
+ ["/settings/spend", "Spend", html `<circle cx="12" cy="12" r="9"/><path d="M15 9.5c-.6-.9-1.7-1.5-3-1.5-1.7 0-3 .9-3 2s1.3 1.7 3 2 3 .9 3 2-1.3 2-3 2c-1.3 0-2.4-.6-3-1.5M12 6v2M12 16v2"/>`],
514
515
  ["/settings/skills", "Skills", html `<path d="m12 3 1.9 5.8L20 10l-5 3.6L16.8 20 12 16.4 7.2 20 9 13.6 4 10l6.1-1.2z"/>`],
515
516
  ["/settings/tools", "Tools", html `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>`],
516
517
  ["/settings/knowledge", "Knowledge", html `<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20V3H6.5A2.5 2.5 0 0 0 4 5.5z"/><path d="M4 19.5A2.5 2.5 0 0 0 6.5 22H20v-5"/>`],
@@ -25,13 +25,14 @@ import { taskRoad } from "../first-run.js";
25
25
  import { starterForWork } from "../flow-starters.js";
26
26
  import {} from "../gaps.js";
27
27
  import { readAuthModeStrict } from "../keys.js";
28
- import { failedAttemptSentence, retryNoteOf } from "../needs-you.js";
28
+ import { failedAttemptSentence, retryNoteOf, START_BUILDER_COMMAND, startBuilderHref } from "../needs-you.js";
29
29
  import { chosenWords, makesNoPlan, PHASES as ROUTE_PHASES, TASK_SIZES } from "../phase-routing.js";
30
30
  import { parseExecutionPlanDocument } from "../plan.js";
31
31
  import {} from "../planner-source.js";
32
32
  import { projectName } from "../project.js";
33
33
  import { dispatchStatusToken, manualReviewOnly, plainReasonWords, reviewConflict } from "../proof.js";
34
34
  import { providerName } from "../provider-auth.js";
35
+ import { agentModelName } from "../model-names.js";
35
36
  import {} from "../provider.js";
36
37
  import { qualityModeTitle } from "../quality.js";
37
38
  import { evidenceShortenedWords } from "../result-review.js";
@@ -134,17 +135,7 @@ export function acceptanceCeremonyHtml(criteria) {
134
135
  }
135
136
  /** "claude" + "opus" → "Claude Opus"; "claude-opus-5-5" → "Claude Opus 5.5"; anything else keeps its exact id. */
136
137
  export function agentNameWords(provider, model) {
137
- const name = providerName(provider);
138
- if (model === null || model === "" || model === "default")
139
- return name;
140
- const bare = model.startsWith(`${provider}-`) ? model.slice(provider.length + 1) : model;
141
- const title = (word) => `${word[0].toUpperCase()}${word.slice(1)}`;
142
- if (/^[a-z]+$/.test(bare))
143
- return `${name} ${title(bare)}`;
144
- const versioned = /^([a-z]+)-(\d+(?:-\d+)*)$/.exec(bare);
145
- if (versioned !== null)
146
- return `${name} ${title(versioned[1])} ${versioned[2].replace(/-/g, ".")}`;
147
- return `${name} ${model}`;
138
+ return agentModelName(provider, model);
148
139
  }
149
140
  /** The permission a sealed profile grants, in two or three words. */
150
141
  export function permissionWordsOf(profile) {
@@ -815,7 +806,9 @@ export function taskRecoveryHref(taskId, diagnosis) {
815
806
  return null;
816
807
  const task = taskHref(taskId);
817
808
  switch (diagnosis.action) {
809
+ // The task page only repeats that no builder is connected: lead to the command that connects one.
818
810
  case "start-worker":
811
+ return startBuilderHref(taskId);
819
812
  case "repair-dependency":
820
813
  return `${task}#run-status`;
821
814
  case "retry-task":
@@ -1350,11 +1343,11 @@ export function taskBodyParts(data) {
1350
1343
  firstConnection
1351
1344
  ? html `<p>Toolroll is open, but this project has not been connected to a builder yet.</p><p>On the machine where the project lives, open that folder and run:</p>`
1352
1345
  : html `<p>Toolroll is open, but this project's builder stopped checking in. Reopen Toolroll on the machine where the project lives.</p><p>If you normally start it from a terminal, open the project folder and run:</p>`,
1353
- html `<code class="dispatch-recovery-command">toolroll up</code>`,
1346
+ html `<code class="dispatch-recovery-command">${START_BUILDER_COMMAND}</code>`,
1354
1347
  firstConnection
1355
1348
  ? html `<p class="meta">This is the normal start command: it opens the app, connects the project, and starts its builder. Keep Toolroll running; approved tasks begin automatically.</p>`
1356
1349
  : html `<p class="meta">This task resumes automatically when the builder reconnects. You do not need to file or approve it again.</p>`,
1357
- html `<p><a href="/system">See connection status →</a></p></div></details>`,
1350
+ html `<p><a href="${startBuilderHref(task.id)}">See connection status →</a></p></div></details>`,
1358
1351
  ]);
1359
1352
  }
1360
1353
  // Never a link to this page's own acts: Retry sits on the status card and below.
@@ -43,6 +43,8 @@ export type RequestFacts = {
43
43
  };
44
44
  /** Passwordless is on and this is a signed-in browser: ceremony password fields render as nothing (html.ts reauth). */
45
45
  passwordless?: boolean;
46
+ /** The browser session's open project (display only: the sidebar's picker names it). */
47
+ openProject?: string | null;
46
48
  };
47
49
  /**
48
50
  * The request's own session facts, readable from anywhere below the