toolroll 0.9.82 → 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 (92) hide show
  1. package/CHANGELOG.md +41 -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/dispatch.d.ts +15 -0
  15. package/dist/dispatch.js +26 -4
  16. package/dist/first-run.d.ts +5 -2
  17. package/dist/first-run.js +13 -2
  18. package/dist/first-tasks-child.d.ts +1 -0
  19. package/dist/first-tasks-child.js +11 -0
  20. package/dist/folder-look.d.ts +110 -0
  21. package/dist/folder-look.js +306 -0
  22. package/dist/guides.js +4 -3
  23. package/dist/learning-child.d.ts +1 -0
  24. package/dist/learning-child.js +34 -0
  25. package/dist/learning-look.d.ts +25 -0
  26. package/dist/learning-look.js +72 -0
  27. package/dist/model-catalog.d.ts +5 -0
  28. package/dist/model-catalog.js +11 -2
  29. package/dist/model-choices.d.ts +188 -0
  30. package/dist/model-choices.js +405 -0
  31. package/dist/model-names.d.ts +6 -0
  32. package/dist/model-names.js +34 -0
  33. package/dist/models-ui.d.ts +69 -5
  34. package/dist/models-ui.js +53 -10
  35. package/dist/needs-you.d.ts +8 -0
  36. package/dist/needs-you.js +8 -0
  37. package/dist/operate.d.ts +53 -0
  38. package/dist/operate.js +277 -49
  39. package/dist/project-admission.d.ts +4 -1
  40. package/dist/project-admission.js +23 -2
  41. package/dist/project-identity.d.ts +44 -0
  42. package/dist/project-identity.js +153 -0
  43. package/dist/project-knowledge.d.ts +6 -6
  44. package/dist/project-tools.d.ts +4 -1
  45. package/dist/project-tools.js +7 -2
  46. package/dist/project.d.ts +4 -1
  47. package/dist/project.js +11 -4
  48. package/dist/repos.d.ts +7 -0
  49. package/dist/repos.js +57 -0
  50. package/dist/serve.js +91 -17
  51. package/dist/server/chat.js +5 -4
  52. package/dist/server/chrome.d.ts +3 -0
  53. package/dist/server/chrome.js +5 -2
  54. package/dist/server/console.css +5 -2
  55. package/dist/server/flows.js +13 -3
  56. package/dist/server/guards.d.ts +4 -0
  57. package/dist/server/guards.js +12 -3
  58. package/dist/server/http.d.ts +12 -0
  59. package/dist/server/http.js +9 -0
  60. package/dist/server/pages.js +102 -92
  61. package/dist/server/people-tokens.js +40 -20
  62. package/dist/server/render-pages.d.ts +12 -16
  63. package/dist/server/render-pages.js +30 -69
  64. package/dist/server/render-people.d.ts +14 -2
  65. package/dist/server/render-people.js +35 -12
  66. package/dist/server/render-settings.js +1 -0
  67. package/dist/server/render-tasks.js +7 -14
  68. package/dist/server/request-context.d.ts +2 -0
  69. package/dist/server/route-table.js +8 -1
  70. package/dist/server/runtime.d.ts +4 -0
  71. package/dist/server/runtime.js +1 -0
  72. package/dist/server/settings.d.ts +58 -1
  73. package/dist/server/settings.js +211 -71
  74. package/dist/server/signup-gate.d.ts +9 -0
  75. package/dist/server/signup-gate.js +40 -0
  76. package/dist/server/tasks.js +19 -5
  77. package/dist/settings-kit-script.d.ts +21 -0
  78. package/dist/settings-kit-script.js +67 -0
  79. package/dist/settings-kit.d.ts +137 -0
  80. package/dist/settings-kit.js +186 -0
  81. package/dist/settings-proofs.d.ts +64 -0
  82. package/dist/settings-proofs.js +101 -0
  83. package/dist/setup-guide.js +4 -2
  84. package/dist/spend-report.d.ts +87 -0
  85. package/dist/spend-report.js +265 -0
  86. package/dist/spend-ui.d.ts +29 -8
  87. package/dist/spend-ui.js +74 -39
  88. package/dist/task-sizing.d.ts +5 -3
  89. package/dist/task-sizing.js +4 -4
  90. package/dist/up-processes.d.ts +13 -0
  91. package/dist/up-processes.js +6 -2
  92. package/package.json +1 -1
package/dist/serve.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { secretFolder } from "./tool-secret-store.js";
2
2
  import { acceptedIdentities, recoverLearning } from "./project-learning.js";
3
+ import { syncProjectIdentities } from "./project-identity.js";
4
+ import { firstTasksOffThread, recoverLearningOffThread } from "./learning-look.js";
3
5
  import { loadOrCreateVapidKeys } from "./push.js";
4
6
  import { checkoutPlan } from "./checkout-cleanup.js";
5
7
  import { latestReleaseFor } from "./server/render-settings.js";
@@ -38,7 +40,8 @@ import { asReadOnlyRequest } from './read-only-request.js';
38
40
  import { connectionsOf, localConnectOf, oneClickOf } from "./mcp-connect.js";
39
41
  import { MOBILE_VIEWPORT_SCRIPT } from "./mobile-viewport.js";
40
42
  import { projectAuthority } from "./project-access.js";
41
- import { discoverTools, projectToolsOf, secretsSetFor, TOOL_CATALOG, toolSecretStorage, toolStanding } from "./project-tools.js";
43
+ import { discoverTools, projectToolsOf, savedProjectMcp, secretsSetFor, TOOL_CATALOG, toolSecretStorage, toolStanding } from "./project-tools.js";
44
+ import { lookProjectMcp } from "./folder-look.js";
42
45
  import { toolRefusal } from "./policy.js";
43
46
  import { isEdgeAddress, refuseEdge } from "./server/edge-refusal.js";
44
47
  import { announcePasswordBearer, isPasswordBearer, PASSWORD_BEARER_REFUSED, passwordBearerAccepted } from "./password-bearer.js";
@@ -143,6 +146,7 @@ import { createChatHandlers } from './server/chat.js';
143
146
  import { createLiveHandlers } from './server/live.js';
144
147
  import { createTerminalHandlers, terminalHostFor } from './server/terminals.js';
145
148
  import { passwordlessOn } from './passwordless.js';
149
+ import { keepTypedFields, openProjectFor, settingsCrumbs } from './settings-kit.js';
146
150
  import { createSweepHandlers } from './server/sweeps.js';
147
151
  import { createFlowsHandlers } from './server/flows.js';
148
152
  import { createPagesHandlers } from './server/pages.js';
@@ -166,6 +170,9 @@ import { loadConsoleUrl } from "./webhooks.js";
166
170
  import { reviewFactsOf } from "./workspace-ui.js";
167
171
  import { WorktreePool } from "./worktree.js";
168
172
  export function createDecisionServer(options) {
173
+ // Identities come from what the console's start resolved off its thread (startConsole); a path that didn't answer
174
+ // stays unresolved, kept restrictive. Without them (embedders, tests) the old synchronous reads apply.
175
+ const identities = options.identities ?? syncProjectIdentities();
169
176
  const { projectFamilyPeek, familiesInView, familyTasksInView, revisionDestination, runIsLive, runIsTaskResult, revisionViewOf, taskViewData, routeViewOf, taskChatFocus, pullRequestTargetOf, failureOf, explainAttempt, taskScreen, armTaskResume, planViewOf, planContractViewOf, revisionDocOf, revisionLedgerOf, resultDetailOf } = createTaskViews({
170
177
  get store() { return store; },
171
178
  get projectCounts() { return projectCounts; },
@@ -188,12 +195,13 @@ export function createDecisionServer(options) {
188
195
  const { authenticateApprover, authenticateCredential, authorizeMutation, projectOf, projectRequestAllowed, identify, lookupSession, restricted, visible, admissionList, codingActorAllowed, codingProjectAllowed, workAccess, familyOf, taskRepoOf, runVisible } = createSharedGuards({
189
196
  get store() { return store; },
190
197
  get liveCeiling() { return liveCeiling; },
198
+ identities,
191
199
  get codingProjects() { return codingProjects; },
192
200
  get unscopedMode() { return unscopedMode; },
193
201
  get managedRepos() { return managedRepos; },
194
202
  get ceiling() { return ceiling; },
195
203
  get allowedHost() { return allowedHost; },
196
- get defaultProject() { return defaultProject; },
204
+ get defaultProject() { return defaultProject(); },
197
205
  get actionTarget() { return actionTarget; },
198
206
  get team() { return team; },
199
207
  get joinSourceOf() { return joinSourceOf; },
@@ -446,20 +454,21 @@ export function createDecisionServer(options) {
446
454
  };
447
455
  let joinGlobal = { tokens: 30, refilledAt: Date.now() };
448
456
  const approvalNonces = new Map();
449
- // Resolve startup authority once. Missing paths remain restrictive.
450
- // Native additions come only from the controller after its admission checks.
451
- const { ceiling, unresolved: unresolvedRepos } = resolveCeiling([...(options.repo === undefined ? [] : [options.repo]), ...(options.repos ?? [])], options.projectRoots ?? []);
452
- /** The project every fresh session opens with: the sole configured repo, else none. */
453
- const defaultProject = ceiling.repos.length === 1 && ceiling.roots.length === 0 ? ceiling.repos[0] : null;
457
+ // Startup authority: the configured repos and roots as their identities say (startupCeiling). Missing or unresolved
458
+ // paths remain restrictive. Native additions come only from the controller after its admission checks.
459
+ const { ceiling, unresolved: unresolvedRepos, defaultProject, configured, configuredPaths } = startupCeiling([...(options.repo === undefined ? [] : [options.repo]), ...(options.repos ?? [])], options.projectRoots ?? [], options.identities === undefined ? undefined : identities, identities.strict);
454
460
  /** No ceiling configured at all: the legacy trust-everything mode, named. */
455
- const unscopedMode = ceiling.repos.length === 0 && ceiling.roots.length === 0;
461
+ // An `up` console (it is handed its admitted set) is never unscoped: with every saved project away it is empty.
462
+ // Judged by what was configured, never by what has resolved so far: a console whose folders haven't answered yet is
463
+ // scoped and empty, not unscoped.
464
+ const unscopedMode = !configured && options.admittedRepos === undefined;
456
465
  /** Per-row visibility under the ceiling — the authorization question for reads. */
457
466
  /** The exact repositories this console serves now: `up`'s live admitted set when it supplies one, else startup's. */
458
467
  const exactRepos = () => options.admittedRepos?.() ?? ceiling.repos;
459
468
  const liveCeiling = () => {
460
469
  const repos = [...exactRepos(), ...(options.additionalProjectRepos?.() ?? [])];
461
470
  // An admitted set that is empty for now is an empty ceiling, never the legacy unscoped mode.
462
- return { repos: repos.length === 0 && ceiling.roots.length === 0 && !unscopedMode ? [NO_PROJECT] : repos, roots: ceiling.roots };
471
+ return { repos: repos.length === 0 && ceiling.roots.length === 0 && !unscopedMode ? [NO_PROJECT] : repos, roots: ceiling.roots, canon: identities.strict };
463
472
  };
464
473
  /** The explicit, currently proved project list. The callback is supplied
465
474
  * only by `up`, after it has independently checked git-ness and the root
@@ -479,7 +488,7 @@ export function createDecisionServer(options) {
479
488
  const repos = [];
480
489
  const gone = goneRepos();
481
490
  for (const path of [...exactRepos(), ...(options.currentRepos?.() ?? [])]) {
482
- const canonical = canonicalProject(path) ?? path;
491
+ const canonical = identities.hot(path);
483
492
  if (seen.has(canonical) || !visible(canonical) || gone.has(canonical))
484
493
  continue;
485
494
  seen.add(canonical);
@@ -568,7 +577,8 @@ export function createDecisionServer(options) {
568
577
  // A service that connects by signing in (or an app's own Connect) is offered only that way.
569
578
  catalog: TOOL_CATALOG.filter(one => !names.has(one.name) && oneClickOf(one.name) === null && localConnectOf(one.name) === null),
570
579
  connections: connectionsOf(store, repo, toolHome), kit, wanted,
571
- found: discoverTools(repo, codex, toolHome).filter(one => !names.has(one.spec.name)),
580
+ // The project's own .mcp.json as the background pass last read it off the console's thread (never read here).
581
+ found: discoverTools(repo, codex, toolHome, process.env, options.identities === undefined ? undefined : savedProjectMcp.get(repo) ?? null).filter(one => !names.has(one.spec.name)),
572
582
  storage: toolSecretStorage(),
573
583
  };
574
584
  };
@@ -879,6 +889,7 @@ export function createDecisionServer(options) {
879
889
  browser: who.via === 'cookie',
880
890
  // Passwordless (read once for rendering; every act proves it again at the act): ceremony fields render as nothing.
881
891
  passwordless: who.via === 'cookie' && passwordlessOn(store),
892
+ openProject: who.via === 'cookie' ? who.session.project : null,
882
893
  // v100: signed in with the identity provider: its label, and whether it checked them recently enough to stand in for a password.
883
894
  // Cockpit B2: "fresh" is an explicit re-check made on request and not yet spent (read live; one step-up uses it).
884
895
  sso: who.via === 'cookie' && who.session.sso !== undefined ? { label: ssoSettings()?.label ?? "your identity provider",
@@ -1027,7 +1038,7 @@ export function createDecisionServer(options) {
1027
1038
  }
1028
1039
  const known = new Set(["/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/save", "/recipes/launch", "/tasks/add", "/queue/move", "/queue/note", "/people/invite", "/people/invite-revoke", "/people/revoke", "/people/projects", "/projects/select", "/projects/open", "/mode/confirm", "/mode/sign", "/mode/revoke", "/settings/chat-approval/save", "/settings/chat-approval/off", "/settings/project/live-view"]);
1029
1040
  const placed = url.pathname === "/tasks/add" ? body?.get("repo")?.trim() : null;
1030
- return { repo: url.pathname.startsWith("/people/") ? null : placed ? canonicalProject(placed) ?? placed : projectOf(who, request) ?? null, taskId: null, runId: null,
1041
+ return { repo: url.pathname.startsWith("/people/") ? null : placed ? identities.strict(placed) ?? placed : projectOf(who, request) ?? null, taskId: null, runId: null,
1031
1042
  action: known.has(url.pathname) ? url.pathname.slice(1).replaceAll("/", " ") : "console request" };
1032
1043
  }
1033
1044
  async function handleGet(route, url, who, request, response) {
@@ -1317,8 +1328,8 @@ export function createDecisionServer(options) {
1317
1328
  return { ok: false, code: "demo", why: "this is a demo database — chat cannot contact an external model" };
1318
1329
  if (unscopedMode)
1319
1330
  return { ok: false, code: "unscoped", why: "chat needs at least one added project" };
1320
- if (options.currentRepos === undefined && unresolvedRepos.length > 0) {
1321
- return { ok: false, code: "unresolved", why: "a configured project path did not resolve at startup — fix it and restart before chat will run" };
1331
+ if (options.currentRepos === undefined && unresolvedRepos().length > 0) {
1332
+ return { ok: false, code: "unresolved", why: "a configured project folder hasn't been found yet — chat runs once it answers; if its path is wrong, fix it and restart" };
1322
1333
  }
1323
1334
  if (managedRepos().length === 0)
1324
1335
  return { ok: false, code: "empty", why: "add a project first — chat will include it automatically" };
@@ -1473,13 +1484,20 @@ export function createDecisionServer(options) {
1473
1484
  const ssoFacts = requestContext.getStore()?.sso;
1474
1485
  if (ssoFacts !== undefined) {
1475
1486
  const back = requestContext.getStore()?.returnTo ?? "/";
1476
- s = { ...s, body: ssoStepUps(s.body, ssoFacts, back), ...(s.workspace?.pageHtml == null ? {} : { workspace: { ...s.workspace, pageHtml: ssoStepUps(s.workspace.pageHtml, ssoFacts, back) } }) };
1487
+ // A typed-only proof (settings-kit.ts) stays a password field: its handler takes nothing else.
1488
+ s = { ...s, body: keepTypedFields(s.body, ssoStepUps, ssoFacts, back), ...(s.workspace?.pageHtml == null ? {} : { workspace: { ...s.workspace, pageHtml: keepTypedFields(s.workspace.pageHtml, ssoStepUps, ssoFacts, back) } }) };
1477
1489
  if (s.workspace?.view?.kind === "flow")
1478
1490
  s = { ...s, workspace: { ...s.workspace, view: { ...s.workspace.view, stepUp: { label: ssoFacts.label, fresh: ssoFacts.fresh, confirmHref: `/login/sso?reauth=1&return=${encodeURIComponent(back)}` } } } };
1479
1491
  }
1480
1492
  // D5: team chat (the central team service) is deprecated; /chat?team=1 still opens this release, but nothing links to it.
1481
1493
  const sensitive = authored || s.forceSensitive === true;
1482
1494
  const requestFacts = requestContext.getStore();
1495
+ // The settings frame (settings-kit.ts): its breadcrumb, for the shell and the workspace alike.
1496
+ if (s.chrome !== undefined && s.chrome.crumbs === undefined) {
1497
+ const crumbs = settingsCrumbs(new URL(requestFacts?.returnTo ?? '/', 'http://standing-orders.local').pathname, s.title, htmlString(s.body));
1498
+ if (crumbs !== undefined)
1499
+ s = { ...s, chrome: { ...s.chrome, crumbs } };
1500
+ }
1483
1501
  if (requestFacts?.browser && s.chrome && !s.forceSensitive && (requestFacts.workspaceRead || browserAssetsAvailable())) {
1484
1502
  const path = new URL(requestFacts.returnTo, 'http://standing-orders.local');
1485
1503
  path.searchParams.delete('format');
@@ -1554,6 +1572,10 @@ export function createDecisionServer(options) {
1554
1572
  const request = requestFacts.workspaceRequest;
1555
1573
  const workspace = {
1556
1574
  version: 1, path: currentPath, title: s.title, user: requestFacts.actor ?? '', csrf: requestFacts.csrf, sensitive,
1575
+ // The project picker names the open project; the settings frame carries its breadcrumb.
1576
+ // Only a project this person may see now: never the raw saved path (settings-kit.ts openProjectFor).
1577
+ openProject: openProjectFor(requestFacts.openProject ?? null, s.chrome.project, s.chrome.projects ?? []),
1578
+ ...(s.chrome.crumbs === undefined ? {} : { crumbs: s.chrome.crumbs }),
1557
1579
  ...(requestFacts.passwordless === true ? { passwordless: true } : {}),
1558
1580
  ...(requestFacts.actor ? { leadName: leadNameOf(store, requestFacts.actor) } : {}),
1559
1581
  refreshUrl: path.pathname + path.search,
@@ -1955,6 +1977,8 @@ export function createDecisionServer(options) {
1955
1977
  for (const provider of PROVIDER_IDS.filter(isKeyedProvider).filter(one => one !== "claude" && one !== "codex"))
1956
1978
  waits.push(connectionCheck(provider).catch(() => undefined));
1957
1979
  }
1980
+ // Project identities, resolved off the thread: what pages asked about, and answers gone stale.
1981
+ waits.push(identities.refresh([...configuredPaths, ...exactRepos(), ...(options.currentRepos?.() ?? []), ...store.listProjects().map(one => one.path), ...store.knownRepos()]));
1958
1982
  integrations.refreshIfDue();
1959
1983
  const sso = ssoSettings();
1960
1984
  if (sso !== null && (ssoProvider === null || ssoProvider.issuer !== sso.issuer || Date.now() - ssoProvider.at >= 10 * 60_000))
@@ -1991,13 +2015,22 @@ export function createDecisionServer(options) {
1991
2015
  continue;
1992
2016
  const reading = { until: Date.now() + 10 * 60_000, found: entry?.found ?? null, reading: true };
1993
2017
  firstTasks.set(repo, reading);
1994
- waits.push(findFirstTasks(repo, options.firstTaskRunner ?? execRun).then(found => { reading.found = found; }, () => { }).finally(() => { reading.reading = false; }));
2018
+ // A test's runner reads in-process; otherwise in a process of its own (learning-look.ts), so the hardened
2019
+ // git's synchronous reads of the project's folder never hold the console's thread.
2020
+ waits.push((options.firstTaskRunner !== undefined ? findFirstTasks(repo, options.firstTaskRunner) : firstTasksOffThread(repo)).then(found => { reading.found = found; }, () => { }).finally(() => { reading.reading = false; }));
1995
2021
  }
1996
2022
  }
1997
2023
  // Learning recovered from reviewed runs (and a project's saved rows carried to its current identity), which
1998
2024
  // Settings → Learning and Knowledge used to do as they opened.
1999
2025
  if (lastChecks.due("learning", 10 * 60_000)) {
2000
2026
  waits.push(lastChecks.run("learning", async () => {
2027
+ // Its reads of each project's Git folder are synchronous, so with a database file they run in a process of
2028
+ // their own (learning-look.ts): a folder that doesn't answer costs its own recovery, never the console. An
2029
+ // in-memory database (a test) can't be shared, so it is read here.
2030
+ if (databaseFile !== null) {
2031
+ await recoverLearningOffThread({ databaseFile, evidenceRoot, repos: managedRepos() }, options.learningRunner === undefined ? {} : { runner: options.learningRunner });
2032
+ return;
2033
+ }
2001
2034
  for (const repo of managedRepos()) {
2002
2035
  try {
2003
2036
  acceptedIdentities(store, repo);
@@ -2012,6 +2045,11 @@ export function createDecisionServer(options) {
2012
2045
  }
2013
2046
  // What Codex has configured, per project, for Settings → Tools to offer.
2014
2047
  for (const repo of managedRepos()) {
2048
+ // The project's own .mcp.json, read off the console's thread (folder-look.ts) for the Tools page and the lead.
2049
+ if (options.identities !== undefined && lastChecks.due(`project-mcp:${repo}`, 10 * 60_000)) {
2050
+ waits.push(lastChecks.run(`project-mcp:${repo}`, async () => { const looked = await lookProjectMcp(repo); if (looked.ok)
2051
+ savedProjectMcp.set(repo, looked.value); return looked.ok; }));
2052
+ }
2015
2053
  if (lastChecks.due(`codex-tools:${repo}`, 10 * 60_000))
2016
2054
  waits.push(lastChecks.run(`codex-tools:${repo}`, () => codexServers(repo)));
2017
2055
  }
@@ -2178,7 +2216,7 @@ export function createDecisionServer(options) {
2178
2216
  get providerFor() { return providerFor; },
2179
2217
  get providerKnown() { return providerKnown; },
2180
2218
  get connectVisits() { return connectVisits; },
2181
- get defaultProject() { return defaultProject; },
2219
+ get defaultProject() { return defaultProject(); },
2182
2220
  get googleVisits() { return googleVisits; },
2183
2221
  get setupAttemptsLeft() { return setupAttemptsLeft; },
2184
2222
  set setupAttemptsLeft(value) { setupAttemptsLeft = value; },
@@ -2220,6 +2258,7 @@ export function createDecisionServer(options) {
2220
2258
  get revisionLedgerOf() { return revisionLedgerOf; },
2221
2259
  get firstTasks() { return firstTasks; },
2222
2260
  get authenticateCredential() { return authenticateCredential; },
2261
+ get identities() { return identities; },
2223
2262
  };
2224
2263
  const tasksHandlers = createTasksHandlers(handlerRuntime);
2225
2264
  const pagesHandlers = createPagesHandlers(handlerRuntime);
@@ -2297,3 +2336,38 @@ export { diffFileAnchor, editorFileHref, needActionOf, orderChangedFiles, plainC
2297
2336
  export { settingsGroups, settingsTiles } from "./server/render-settings.js";
2298
2337
  export { decisionAnswerScript, earlierVersionsWords, revisionLineageWords } from "./server/render-tasks.js";
2299
2338
  export { SIGN_IN_LINK_MS, SIGN_IN_LINK_PATH } from "./server/session.js";
2339
+ /**
2340
+ * The console's configured repos and roots. Without identities (embedders and tests) they resolve once, synchronously,
2341
+ * as before. With them, the console resolves them in the background after it listens (operate.ts startConsole), so
2342
+ * each is read from the last answer: a configured path counts by its real path from the moment its folder answers,
2343
+ * and until then only by its spelling, which the strict `canon` never matches for an unresolved row.
2344
+ */
2345
+ function startupCeiling(repos, roots, identities, canon) {
2346
+ const configured = repos.length > 0 || roots.length > 0;
2347
+ const configuredPaths = [...repos, ...roots];
2348
+ if (identities === undefined) {
2349
+ const once = resolveCeiling(repos, roots);
2350
+ return {
2351
+ ceiling: { ...once.ceiling, canon },
2352
+ unresolved: () => once.unresolved,
2353
+ defaultProject: () => once.ceiling.repos.length === 1 && once.ceiling.roots.length === 0 ? once.ceiling.repos[0] : null,
2354
+ configured,
2355
+ configuredPaths,
2356
+ };
2357
+ }
2358
+ // Only resolved entries authorize: an unresolved configured path is in no list, so it matches nothing (not even its
2359
+ // own spelling) and is no session's default until its folder answers.
2360
+ const resolved = (paths) => [...new Set(paths.flatMap(path => { const real = identities.known(path); return typeof real === "string" ? [real] : []; }))];
2361
+ const ceiling = {
2362
+ get repos() { return resolved(repos); },
2363
+ get roots() { return resolved(roots); },
2364
+ canon,
2365
+ };
2366
+ return {
2367
+ ceiling,
2368
+ unresolved: () => [...repos, ...roots].filter(path => typeof identities.known(path) !== "string"),
2369
+ defaultProject: () => repos.length === 1 && roots.length === 0 ? resolved(repos)[0] ?? null : null,
2370
+ configured,
2371
+ configuredPaths,
2372
+ };
2373
+ }
@@ -12,10 +12,10 @@ import { CHAT_ACTIONS, mintSharedActionReview, sharedActionPayload, sharedAction
12
12
  import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
13
13
  import { buildDataDocument, composeRequest, isSubscriptionChatProvider, parseAssistantEnvelope, performChatRequest, plausibleChatKey, priceOf, settleForPrice, TURN_WALL_CLOCK_MS, worstCaseForPrice } from "../converse.js";
14
14
  import { DEMO_CHAT_SCRIPT, demoChatHtml, demoThreadHtml } from "../demo-chat.js";
15
- import { withDispatchDiagnoses } from "../dispatch.js";
15
+ import { builderReadyFor, firstRunBuilderRepo, withDispatchDiagnoses } from "../dispatch.js";
16
16
  import { readVerifiedArtifact, scanForSecrets } from "../evidence.js";
17
17
  import { run as execRun } from "../exec.js";
18
- import { findFirstTasks, firstTaskSuggestions, HOW_IT_WORKS, SANDBOX_COMMAND } from "../first-run.js";
18
+ import { findFirstTasks, firstTaskSuggestions, HOW_IT_WORKS, SANDBOX_COMMAND, withBuilderStep } from "../first-run.js";
19
19
  import { leadBriefHtml } from '../lead-context.js';
20
20
  import { configureLeadFollow, leadFollowStatus } from '../lead-follow.js';
21
21
  import { leadActivity } from "../lead-voice.js";
@@ -1158,11 +1158,12 @@ export function createChatHandlers(runtime) {
1158
1158
  }
1159
1159
  /** Chat's first run: the steps, first tasks until the first task exists, and the sandbox while no agent is signed in. */
1160
1160
  function chatFirstRun(now, project) {
1161
- const steps = firstRunStepsNow(now);
1161
+ const repo = project ?? managedRepos()[0] ?? null;
1162
+ // Done only when a task in this project could start: a live builder bound to it, as dispatch decides.
1163
+ const steps = withBuilderStep(firstRunStepsNow(now), builderReadyFor(store, firstRunBuilderRepo(store, project, visible, managedRepos()), now));
1162
1164
  if (steps === null)
1163
1165
  return undefined;
1164
1166
  const taskFiled = steps.find(one => one.key === "task")?.done ?? true;
1165
- const repo = project ?? managedRepos()[0] ?? null;
1166
1167
  const agent = steps.find(one => one.key === "agent");
1167
1168
  const lead = leadWords();
1168
1169
  return {
@@ -1,4 +1,5 @@
1
1
  import { type BrowserSignIn, type BrowserUpdateNotice, type BrowserWorkspace } from "../browser-workspace.js";
2
+ import { type Crumb } from "../settings-kit.js";
2
3
  import { type Decision, type Run } from "../store.js";
3
4
  import { type tally } from "../summary.js";
4
5
  import { type ServerResponse } from "node:http";
@@ -115,6 +116,8 @@ export type Chrome = {
115
116
  csrf?: string;
116
117
  /** Where a switch made on this screen returns to. */
117
118
  returnTo?: string;
119
+ /** The settings frame's breadcrumb ("Settings / <page>", settings-kit.ts), set by sendScreen; absent elsewhere. */
120
+ crumbs?: Crumb[];
118
121
  };
119
122
  /**
120
123
  * A server-rendered region that stays current: when the page's live stream
@@ -33,6 +33,7 @@ import { PULL_REQUEST_SETTINGS_CSS } from "../pull-request-ui.js";
33
33
  import { RECIPE_CSS } from "../recipe-ui.js";
34
34
  import { REQUEST_LIMITS_CSS } from "../request-budget-ui.js";
35
35
  import { RETENTION_CSS } from "../retention-ui.js";
36
+ import { crumbsHtml, SETTINGS_KIT_CSS } from "../settings-kit.js";
36
37
  import { SKILLS_CSS } from "../skills-ui.js";
37
38
  import { SPEND_CSS } from "../spend-ui.js";
38
39
  import { SSO_CSS } from "../sso-ui.js";
@@ -148,7 +149,7 @@ export const INBOX_TABS_CSS = '.inbox-tabs{display:inline-flex;gap:2px;max-width
148
149
  '.inbox-ask{margin:18px 0 0}.inbox-ask>h2{display:flex;align-items:baseline;gap:8px;margin:0 0 4px;font-size:15px;font-weight:600}.inbox-ask>h2 .count{font:500 12px var(--so-mono,ui-monospace,monospace);font-variant-numeric:tabular-nums;color:var(--so-muted)}.inbox-ask h3{font-size:13px;font-weight:600;margin:12px 0 4px}' +
149
150
  '.inbox-unread{display:none;position:absolute;top:4px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--so-signal)}' +
150
151
  '@media (max-width:760px){.inbox-tabs{display:flex;width:100%}.inbox-tabs a{flex:1;justify-content:center;min-height:44px;padding:0 6px}.inbox-unread{display:block}}';
151
- export const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + SUBAGENT_CSS + KITS_CSS + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + REQUEST_LIMITS_CSS + PEOPLE_AUDIT_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + DISCLOSURE_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
152
+ export const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + SUBAGENT_CSS + KITS_CSS + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + REQUEST_LIMITS_CSS + PEOPLE_AUDIT_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + DISCLOSURE_CSS + SETTINGS_KIT_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
152
153
  /**
153
154
  * A server-rendered region that stays current: when the page's live stream
154
155
  * says something changed (GET /live, live-bus.ts), fetch this page's own
@@ -579,8 +580,10 @@ ${update === undefined ? "" : html `<div class="banner update-banner" data-updat
579
580
  ${postForm(update.dismissHref, html `<button type="submit">Dismiss</button>`, { attrs: { class: "inline" }, hidden: { version: update.version } })}</div>`}`;
580
581
  // The scope bar sits between the banners and the main/split body, so it
581
582
  // can never disappear with a responsive pane (portfolio arc §1).
583
+ // The settings frame's breadcrumb sits above the page's own h1 (its only h1).
584
+ const crumbs = chrome.crumbs === undefined ? "" : crumbsHtml(chrome.crumbs);
582
585
  const content = chrome.listPane === undefined
583
- ? html `<div class="content">${demoBanner}${scopeBar}<main>${main}</main></div>`
586
+ ? html `<div class="content">${demoBanner}${scopeBar}${crumbs}<main>${main}</main></div>`
584
587
  : html `<div class="content">${demoBanner}${scopeBar}<div class="split">\
585
588
  <div class="list-pane">${chrome.listPane}</div>\
586
589
  <div class="detail"><main>${main}</main></div>\
@@ -265,12 +265,15 @@
265
265
  .project-card .project-name:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: .375rem; }
266
266
 
267
267
  label { display: block; font-size: 0.8125rem; font-weight: 500; margin: .75rem 0 0; color: var(--foreground); }
268
- input[type=text], input[type=password], input[type=number], input[type=url], input[type=email], input[type=search], input[type=time], textarea, select {
269
- width: 100%; margin: .35rem 0 0; padding: .45rem .7rem; font: 400 0.875rem/1.4 var(--font-sans);
268
+ :where(input[type=text], input[type=password], input[type=number], input[type=url], input[type=email], input[type=search], input[type=time], textarea, select) {
269
+ width: 100%; margin: .35rem 0 0; padding: .45rem .7rem;
270
270
  color: var(--foreground); background: var(--card); min-height: 2.125rem;
271
271
  border: 1px solid var(--input); border-radius: calc(var(--radius) - 3px);
272
272
  transition: border-color .15s, box-shadow .15s;
273
273
  }
274
+ /* The box above is in :where() so a surface's own rules (the workspace's 44px phone targets) win; the type keeps
275
+ its old weight, so the phone's 16px rule still loses to it as before. */
276
+ input[type=text], input[type=password], input[type=number], input[type=url], input[type=email], input[type=search], input[type=time], textarea, select { font: 400 0.875rem/1.4 var(--font-sans); }
274
277
  @media (hover: hover) and (pointer: fine) { input:hover, textarea:hover, select:hover { border-color: var(--muted-foreground); } }
275
278
  input[type=number] { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
276
279
  input[type=radio], input[type=checkbox] { accent-color: var(--ring); }
@@ -43,7 +43,7 @@ import { redirect, respond, SAFETY, taskHref } from "./http.js";
43
43
  import { html } from "../html.js";
44
44
  import {} from "./session.js";
45
45
  export function createFlowsHandlers(runtime) {
46
- const { store, clock, sendScreen, chromeFor, consoleProjects, options, visible, liveCeiling, providerHome, toolHome, restricted, evidenceRoot, authenticateApprover, authenticateCredential, projectOf, unscopedMode, ceiling, authorizeMutation } = runtime;
46
+ const { identities, store, clock, sendScreen, chromeFor, consoleProjects, options, visible, liveCeiling, providerHome, toolHome, restricted, evidenceRoot, authenticateApprover, authenticateCredential, projectOf, unscopedMode, ceiling, authorizeMutation } = runtime;
47
47
  // ---- reads ---------------------------------------------------------------
48
48
  /** A gallery template's page (Flows → New → Use this): its questions, the preview of these answers, and Create. */
49
49
  function sendGalleryUse(response, who, template, projects, repo, given, name, notice, said = null) {
@@ -839,13 +839,23 @@ export function createFlowsHandlers(runtime) {
839
839
  if (body.get("repo") !== project || (who.via === "cookie" && body.get("projectRevision") !== String(who.session.projectRevision)))
840
840
  throw new RecipeError("The open project changed. Reopen this recipe in the intended project.", 409);
841
841
  const rootMode = !unscopedMode && ceiling.roots.length > 0;
842
- if (rootMode && !(await authorizedProject(liveCeiling(), project)))
843
- throw new RecipeError("The project is outside this server's access.", 403);
842
+ const decidedOn = identities.generation();
843
+ if (rootMode) {
844
+ // A mutation's target is resolved now, off the console's thread, and must still be the project it names.
845
+ const resolved = await identities.resolveNow(project);
846
+ if (resolved === "no-answer")
847
+ throw new RecipeError("The project's folder didn't answer. It may be on a drive that's disconnected, or waiting on macOS. Try again in a moment.", 503);
848
+ if (resolved !== project || !(await authorizedProject(liveCeiling(), resolved)))
849
+ throw new RecipeError("The project is outside this server's access.", 403);
850
+ }
844
851
  const rechecked = authorizeMutation(request, who, readForm(posted, CONSOLE_FORMS.mutationGuard));
845
852
  if (rechecked !== null)
846
853
  throw new RecipeError(rechecked.message, rechecked.status);
847
854
  if (projectOf(who, request) !== project)
848
855
  throw new RecipeError("The open project changed. Preview the intended project again.", 409);
856
+ // An identity that changed during the awaits above undoes what they proved.
857
+ if (identities.generation() !== decidedOn)
858
+ throw new RecipeError("A project folder changed while this was being checked. Try again.", 409);
849
859
  const revision = who.via === "cookie" ? who.session.projectRevision : 0;
850
860
  if (step === "prepare") {
851
861
  const id = body.get("recipe") ?? "", version = body.get("recipeRevision") ?? "";
@@ -1,3 +1,4 @@
1
+ import { type ProjectIdentities } from "../project-identity.js";
1
2
  import { type FormFieldOf, type FormView } from "../contracts/console-api.js";
2
3
  import type { TaskFamily } from "../store.js";
3
4
  import { type IncomingMessage, type ServerResponse } from "node:http";
@@ -13,8 +14,11 @@ export interface GuardsRuntime {
13
14
  liveCeiling: () => {
14
15
  repos: string[];
15
16
  roots: readonly string[];
17
+ canon?: (path: string) => string | null;
16
18
  };
17
19
  codingProjects: () => string[];
20
+ /** Project identities read without touching folders (project-identity.ts); absent: the synchronous reads. */
21
+ identities?: ProjectIdentities;
18
22
  unscopedMode: boolean;
19
23
  managedRepos: () => string[];
20
24
  ceiling: import("../project.js").ProjectCeiling;
@@ -1,3 +1,4 @@
1
+ import { syncProjectIdentities } from "../project-identity.js";
1
2
  import { OWNER_ACTIONS, sharedActionPayload } from '../chat-actions.js';
2
3
  import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
3
4
  import { timingSafeEqual } from "node:crypto";
@@ -14,6 +15,7 @@ import { refuse } from "./chrome.js";
14
15
  import { matchTaskPath } from "./http.js";
15
16
  import { PersistentSessions, SESSION_COOKIE, sessionWithinLifetime } from "./session.js";
16
17
  export function createSharedGuards(runtime) {
18
+ const syncIdentities = syncProjectIdentities();
17
19
  // v99: wrong passwords. One address guessing across names runs out of tries here; each name
18
20
  // locks on its own (sign-in-guard.ts), and every lock, whatever road it came by, is kept.
19
21
  /**
@@ -130,8 +132,9 @@ export function createSharedGuards(runtime) {
130
132
  return fallback();
131
133
  if (Array.isArray(header))
132
134
  return undefined;
133
- const canonical = canonicalProject(header);
134
- if (canonical === null || !visible(canonical))
135
+ // A request's path, never resolved on this thread (project-identity.ts): a known identity or exactly a saved one.
136
+ const canonical = identities().vouched(header, savedIdentities);
137
+ if (canonical === undefined || !visible(canonical))
135
138
  return undefined;
136
139
  return canonical;
137
140
  }
@@ -152,7 +155,8 @@ export function createSharedGuards(runtime) {
152
155
  case 'form-path': {
153
156
  const path = body?.get('path')?.trim() ?? '';
154
157
  // Invalid/missing paths keep the opener's existing 400 response and recoverable form.
155
- return path === '' ? null : canonicalProject(path);
158
+ // Never resolved here: an unknown path passes on to the opener, which resolves it off the thread and checks again.
159
+ return path === '' ? null : identities().vouched(path, savedIdentities) ?? null;
156
160
  }
157
161
  case 'task': {
158
162
  const task = matchTaskPath(url.pathname, '(?:/[a-z-]+)?$');
@@ -370,5 +374,10 @@ export function createSharedGuards(runtime) {
370
374
  function runVisible(run) {
371
375
  return visible(taskRepoOf(run.taskRef));
372
376
  }
377
+ // Defined last, so the reviewed functions above keep their places (password-freshness.test.ts).
378
+ /** Project identities read without touching a folder (project-identity.ts); absent: the synchronous reads. */
379
+ function identities() { return runtime.identities ?? syncIdentities; }
380
+ /** The projects `up` proved and admitted (each its own identity): what a request may name without a read. */
381
+ function savedIdentities() { return runtime.managedRepos(); }
373
382
  return { authenticateApprover, authenticateCredential, authorizeMutation, projectOf, resolveRouteProject, projectRequestAllowed, identify, lookupSession, restricted, visible, admissionList, codingActorAllowed, codingProjectAllowed, workAccess, familyOf, taskRepoOf, runVisible };
374
383
  }
@@ -17,6 +17,9 @@ import { type Store } from "../store.js";
17
17
  import { type SubscriptionLeadRunner } from "../subscription-chat.js";
18
18
  import { type launchRuntimeUpdate } from "../toolroll-update.js";
19
19
  import { type IncomingMessage, type ServerResponse } from "node:http";
20
+ import { type FolderLookRunner } from "../folder-look.js";
21
+ import { type ProjectIdentities } from "../project-identity.js";
22
+ import { type LearningRunner } from "../learning-look.js";
20
23
  export type ServeOptions = {
21
24
  /** Tests: runs a person's command for the HTTP MCP gateway instead of operate.ts's. */
22
25
  runOperateAs?: import("../mcp-person.js").RunOperateAs;
@@ -172,6 +175,14 @@ export type ServeOptions = {
172
175
  * how often they run and ask this computer's agents again. */
173
176
  backgroundChecks?: boolean;
174
177
  backgroundChecksMs?: number;
178
+ /** Tests: how a person's folders are read off the console's thread (folder-look.ts), and how long a read may take. */
179
+ folderLook?: FolderLookRunner;
180
+ folderLookMs?: number;
181
+ /** Project identities resolved off the console's thread (project-identity.ts); `up` and `serve` pass them. Absent:
182
+ * the old synchronous reads (embedders and tests). */
183
+ identities?: ProjectIdentities;
184
+ /** Tests: how the background pass runs one project's learning recovery in a process of its own. */
185
+ learningRunner?: LearningRunner;
175
186
  connectionHome?: string;
176
187
  /** Test seam for Chat's first tasks: the `gh` and `git grep` reads. */
177
188
  firstTaskRunner?: typeof execRun;
@@ -215,6 +226,7 @@ export declare function matchTaskPath(pathname: string, suffixPattern: string):
215
226
  verb: string;
216
227
  } | null;
217
228
  export declare function taskHref(taskId: string): string;
229
+ export declare function runHref(runId: number): string;
218
230
  export declare const SAFETY: {
219
231
  readonly "Content-Security-Policy": "default-src 'none'; style-src 'self' 'unsafe-inline'; font-src 'self'; manifest-src 'self'; worker-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
220
232
  readonly "X-Content-Type-Options": "nosniff";
@@ -19,6 +19,9 @@ import {} from "../toolroll-update.js";
19
19
  import { timingSafeEqual } from "node:crypto";
20
20
  import {} from "node:http";
21
21
  import { SIGN_IN_LINK_PATH } from "./session.js";
22
+ import {} from "../folder-look.js";
23
+ import {} from "../project-identity.js";
24
+ import {} from "../learning-look.js";
22
25
  // A note is at most NOTE_BYTE_CAP (16,000) UTF-8 bytes, which URL encoding
23
26
  // can triple: room for one whole, before canonical text validation.
24
27
  export const BODY_CAP = 64 * 1024;
@@ -48,6 +51,9 @@ export function matchTaskPath(pathname, suffixPattern) {
48
51
  export function taskHref(taskId) {
49
52
  return `/t/${encodeURIComponent(taskId)}`;
50
53
  }
54
+ export function runHref(runId) {
55
+ return `/r/${Math.trunc(runId)}`;
56
+ }
51
57
  // ---- rendering -------------------------------------------------------------
52
58
  export const SAFETY = {
53
59
  "Content-Security-Policy": "default-src 'none'; style-src 'self' 'unsafe-inline'; font-src 'self'; manifest-src 'self'; worker-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
@@ -61,10 +67,13 @@ export function respond(response, status, type, body) {
61
67
  // writeHead's headers would otherwise win.
62
68
  const own = response.getHeader("content-security-policy");
63
69
  const cache = response.getHeader("cache-control");
70
+ // The first-account form's page sends `same-origin` so its own post names this console (signup-gate.ts).
71
+ const referrer = response.getHeader("referrer-policy");
64
72
  response.writeHead(status, {
65
73
  ...SAFETY,
66
74
  ...(own === undefined ? {} : { "content-security-policy": own }),
67
75
  ...(cache === undefined ? {} : { "Cache-Control": cache }),
76
+ ...(referrer === "same-origin" ? { "Referrer-Policy": "same-origin" } : {}),
68
77
  "Content-Type": type,
69
78
  });
70
79
  response.end(isHtml(body) ? htmlString(body) : body);