toolroll 0.9.81 → 0.9.83

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 (168) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/cli.js +1 -0
  3. package/dist/commit-extract.d.ts +18 -0
  4. package/dist/commit-extract.js +39 -1
  5. package/dist/converse.d.ts +12 -0
  6. package/dist/converse.js +18 -2
  7. package/dist/deploy-gate.js +8 -0
  8. package/dist/desktop-update.d.ts +1 -1
  9. package/dist/eval-gate.d.ts +51 -0
  10. package/dist/eval-gate.js +199 -0
  11. package/dist/eval-hooks.d.ts +39 -0
  12. package/dist/eval-hooks.js +477 -0
  13. package/dist/eval-oracle-main.d.ts +1 -0
  14. package/dist/eval-oracle-main.js +31 -0
  15. package/dist/eval-oracle.d.ts +60 -0
  16. package/dist/eval-oracle.js +217 -0
  17. package/dist/eval-world.d.ts +17 -0
  18. package/dist/eval-world.js +97 -0
  19. package/dist/evals.d.ts +299 -0
  20. package/dist/evals.js +964 -0
  21. package/dist/exec.js +15 -1
  22. package/dist/first-tasks-child.d.ts +1 -0
  23. package/dist/first-tasks-child.js +11 -0
  24. package/dist/flow-draft.js +4 -0
  25. package/dist/flow-sort.js +4 -0
  26. package/dist/folder-look.d.ts +107 -0
  27. package/dist/folder-look.js +302 -0
  28. package/dist/github-jobs.d.ts +25 -0
  29. package/dist/github-jobs.js +22 -0
  30. package/dist/invoke.d.ts +1 -1
  31. package/dist/invoke.js +14 -0
  32. package/dist/lead.js +4 -1
  33. package/dist/learning-child.d.ts +1 -0
  34. package/dist/learning-child.js +34 -0
  35. package/dist/learning-look.d.ts +25 -0
  36. package/dist/learning-look.js +72 -0
  37. package/dist/memory-pass.js +4 -0
  38. package/dist/nightly-check.d.ts +4 -1
  39. package/dist/nightly-check.js +5 -2
  40. package/dist/nightly-github.d.ts +316 -0
  41. package/dist/nightly-github.js +1022 -0
  42. package/dist/operate.d.ts +123 -1
  43. package/dist/operate.js +344 -29
  44. package/dist/project-identity.d.ts +37 -0
  45. package/dist/project-identity.js +106 -0
  46. package/dist/project-knowledge.d.ts +4 -4
  47. package/dist/project-tools.d.ts +4 -1
  48. package/dist/project-tools.js +7 -2
  49. package/dist/project.d.ts +4 -1
  50. package/dist/project.js +11 -4
  51. package/dist/provider.d.ts +3 -0
  52. package/dist/provider.js +9 -2
  53. package/dist/release-adapters.d.ts +21 -21
  54. package/dist/release-adapters.js +52 -19
  55. package/dist/release-auto.js +26 -0
  56. package/dist/release-card.js +3 -0
  57. package/dist/release-cli.js +59 -3
  58. package/dist/release-view.d.ts +3 -0
  59. package/dist/release-view.js +2 -0
  60. package/dist/release.d.ts +16 -0
  61. package/dist/release.js +16 -0
  62. package/dist/reported-usage.d.ts +69 -0
  63. package/dist/reported-usage.js +97 -0
  64. package/dist/repos.d.ts +7 -0
  65. package/dist/repos.js +57 -0
  66. package/dist/serve.js +35 -8
  67. package/dist/server/chat.js +4 -0
  68. package/dist/server/flows.js +9 -3
  69. package/dist/server/guards.d.ts +4 -0
  70. package/dist/server/guards.js +12 -3
  71. package/dist/server/http.d.ts +11 -0
  72. package/dist/server/http.js +3 -0
  73. package/dist/server/pages.js +62 -88
  74. package/dist/server/render-pages.d.ts +2 -16
  75. package/dist/server/render-pages.js +1 -55
  76. package/dist/server/render-tasks.js +1 -0
  77. package/dist/server/runtime.d.ts +4 -0
  78. package/dist/server/runtime.js +1 -0
  79. package/dist/server/settings.js +14 -3
  80. package/dist/server/tasks.js +15 -4
  81. package/dist/store.d.ts +109 -1
  82. package/dist/store.js +394 -4
  83. package/dist/subagents.js +4 -0
  84. package/dist/subscription-chat.d.ts +2 -0
  85. package/dist/subscription-chat.js +52 -13
  86. package/dist/task-sizing.js +4 -0
  87. package/dist/teardown-git.d.ts +9 -2
  88. package/dist/teardown-git.js +56 -33
  89. package/dist/up-processes.d.ts +13 -0
  90. package/dist/up-processes.js +6 -2
  91. package/evals/cases/catalog/case.json +25 -0
  92. package/evals/cases/catalog/fixture/README.md +3 -0
  93. package/evals/cases/catalog/fixture/package.json +8 -0
  94. package/evals/cases/catalog/fixture/test/catalog.test.mjs +4 -0
  95. package/evals/cases/catalog/oracle.mjs +23 -0
  96. package/evals/cases/catalog/probe.mjs +10 -0
  97. package/evals/cases/catalog/source.md +9 -0
  98. package/evals/cases/csv-quoting/case.json +23 -0
  99. package/evals/cases/csv-quoting/fixture/README.md +3 -0
  100. package/evals/cases/csv-quoting/fixture/csv.js +1 -0
  101. package/evals/cases/csv-quoting/fixture/package.json +8 -0
  102. package/evals/cases/csv-quoting/fixture/test/csv.test.mjs +5 -0
  103. package/evals/cases/csv-quoting/oracle.mjs +14 -0
  104. package/evals/cases/csv-quoting/probe.mjs +9 -0
  105. package/evals/cases/csv-quoting/source.md +9 -0
  106. package/evals/cases/dedupe/case.json +23 -0
  107. package/evals/cases/dedupe/fixture/README.md +3 -0
  108. package/evals/cases/dedupe/fixture/dedupe.js +1 -0
  109. package/evals/cases/dedupe/fixture/package.json +8 -0
  110. package/evals/cases/dedupe/fixture/test/dedupe.test.mjs +5 -0
  111. package/evals/cases/dedupe/oracle.mjs +16 -0
  112. package/evals/cases/dedupe/probe.mjs +13 -0
  113. package/evals/cases/dedupe/source.md +9 -0
  114. package/evals/cases/endpoint/case.json +25 -0
  115. package/evals/cases/endpoint/fixture/README.md +3 -0
  116. package/evals/cases/endpoint/fixture/package.json +8 -0
  117. package/evals/cases/endpoint/fixture/test/endpoint.test.mjs +4 -0
  118. package/evals/cases/endpoint/oracle.mjs +27 -0
  119. package/evals/cases/endpoint/probe.mjs +10 -0
  120. package/evals/cases/endpoint/source.md +11 -0
  121. package/evals/cases/math-divide/case.json +23 -0
  122. package/evals/cases/math-divide/fixture/README.md +3 -0
  123. package/evals/cases/math-divide/fixture/package.json +8 -0
  124. package/evals/cases/math-divide/fixture/src/math.js +1 -0
  125. package/evals/cases/math-divide/fixture/test/math.test.js +5 -0
  126. package/evals/cases/math-divide/oracle.mjs +13 -0
  127. package/evals/cases/math-divide/probe.mjs +9 -0
  128. package/evals/cases/math-divide/source.md +9 -0
  129. package/evals/cases/math-multiply/case.json +23 -0
  130. package/evals/cases/math-multiply/fixture/README.md +3 -0
  131. package/evals/cases/math-multiply/fixture/package.json +8 -0
  132. package/evals/cases/math-multiply/fixture/src/math.js +2 -0
  133. package/evals/cases/math-multiply/fixture/test/math.test.js +5 -0
  134. package/evals/cases/math-multiply/oracle.mjs +12 -0
  135. package/evals/cases/math-multiply/probe.mjs +9 -0
  136. package/evals/cases/math-multiply/source.md +9 -0
  137. package/evals/cases/math-subtract/case.json +23 -0
  138. package/evals/cases/math-subtract/fixture/README.md +3 -0
  139. package/evals/cases/math-subtract/fixture/package.json +8 -0
  140. package/evals/cases/math-subtract/fixture/src/math.js +1 -0
  141. package/evals/cases/math-subtract/fixture/test/math.test.js +5 -0
  142. package/evals/cases/math-subtract/oracle.mjs +12 -0
  143. package/evals/cases/math-subtract/probe.mjs +9 -0
  144. package/evals/cases/math-subtract/source.md +9 -0
  145. package/evals/cases/merge-config/case.json +23 -0
  146. package/evals/cases/merge-config/fixture/README.md +3 -0
  147. package/evals/cases/merge-config/fixture/config.js +1 -0
  148. package/evals/cases/merge-config/fixture/package.json +8 -0
  149. package/evals/cases/merge-config/fixture/test/config.test.mjs +5 -0
  150. package/evals/cases/merge-config/oracle.mjs +28 -0
  151. package/evals/cases/merge-config/probe.mjs +15 -0
  152. package/evals/cases/merge-config/source.md +9 -0
  153. package/evals/cases/paginate/case.json +23 -0
  154. package/evals/cases/paginate/fixture/README.md +3 -0
  155. package/evals/cases/paginate/fixture/package.json +8 -0
  156. package/evals/cases/paginate/fixture/paginate.js +1 -0
  157. package/evals/cases/paginate/fixture/test/paginate.test.mjs +5 -0
  158. package/evals/cases/paginate/oracle.mjs +18 -0
  159. package/evals/cases/paginate/probe.mjs +13 -0
  160. package/evals/cases/paginate/source.md +9 -0
  161. package/evals/cases/round2-asks/case.json +24 -0
  162. package/evals/cases/round2-asks/fixture/README.md +3 -0
  163. package/evals/cases/round2-asks/fixture/package.json +8 -0
  164. package/evals/cases/round2-asks/fixture/src/math.js +1 -0
  165. package/evals/cases/round2-asks/fixture/test/math.test.js +5 -0
  166. package/evals/cases/round2-asks/source.md +11 -0
  167. package/evals/manifest.json +15 -0
  168. package/package.json +2 -1
@@ -1396,6 +1396,8 @@ export function createChatHandlers(runtime) {
1396
1396
  const pinnedSettle = settleForPrice(enabled.price, result.answer.tokensIn, result.answer.tokensOut);
1397
1397
  const settled = Math.max(pinnedSettle, result.answer.reportedCostMicrousd ?? 0);
1398
1398
  const envelope = parseAssistantEnvelope(result.answer.text);
1399
+ // v140: what the provider's response body itself reported (absent: unreported, kept NULL).
1400
+ const reported = { modelReported: result.answer.modelReported ?? null, reportedCostUsd: result.answer.reportedCostUsd ?? null };
1399
1401
  if (!envelope.ok) {
1400
1402
  finish({
1401
1403
  state: "failed",
@@ -1403,6 +1405,7 @@ export function createChatHandlers(runtime) {
1403
1405
  tokensIn: result.answer.tokensIn,
1404
1406
  tokensOut: result.answer.tokensOut,
1405
1407
  settledMicrousd: settled,
1408
+ ...reported,
1406
1409
  });
1407
1410
  tell(null, "the model's answer was malformed and was discarded");
1408
1411
  return;
@@ -1437,6 +1440,7 @@ export function createChatHandlers(runtime) {
1437
1440
  settledMicrousd: settled,
1438
1441
  replyBytes: Buffer.byteLength(envelope.envelope.reply, "utf8"),
1439
1442
  candidateCount: kept,
1443
+ ...reported,
1440
1444
  });
1441
1445
  tell(envelope.envelope.reply, null, envelope.proposalsDiscarded);
1442
1446
  }
@@ -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,8 +839,14 @@ 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
+ if (rootMode) {
843
+ // A mutation's target is resolved now, off the console's thread, and must still be the project it names.
844
+ const resolved = await identities.resolveNow(project);
845
+ if (resolved === "no-answer")
846
+ 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);
847
+ if (resolved !== project || !(await authorizedProject(liveCeiling(), resolved)))
848
+ throw new RecipeError("The project is outside this server's access.", 403);
849
+ }
844
850
  const rechecked = authorizeMutation(request, who, readForm(posted, CONSOLE_FORMS.mutationGuard));
845
851
  if (rechecked !== null)
846
852
  throw new RecipeError(rechecked.message, rechecked.status);
@@ -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;
@@ -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;
@@ -5,10 +5,11 @@ import { handlersOf } from './handler-registry.js';
5
5
  import { html, joinHtml, postForm, reauth } from "../html.js";
6
6
  /** pages handlers, moved without changing their route bodies. */
7
7
  import { createHmac, randomBytes } from "node:crypto";
8
- import { existsSync, opendirSync, readdirSync, realpathSync } from "node:fs";
8
+ import { realpathSync } from "node:fs";
9
9
  import {} from "node:http";
10
10
  import { homedir, hostname } from "node:os";
11
- import { dirname, join } from "node:path";
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { listFolder, localOrigins } from "../folder-look.js";
12
13
  import { Readable } from "node:stream";
13
14
  import { pipeline } from "node:stream/promises";
14
15
  import { setImmediate as yieldEventLoop } from "node:timers/promises";
@@ -49,13 +50,13 @@ import { refuse, regionScript, screen, settingsRows, WORKSPACE_STYLE, workToolRo
49
50
  import { redirect, respond, safeReturn, SAFETY, taskHref } from "./http.js";
50
51
  import { assignmentOf } from "../assignment.js";
51
52
  import { assignmentTaskStatusOf } from "../assignment-presentation.js";
52
- import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage, homePage, originUrlOf, portfolioOverview, projectsPage, systemPage, workbenchRail } from "./render-pages.js";
53
+ import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage, homePage, portfolioOverview, projectsPage, systemPage, workbenchRail } from "./render-pages.js";
53
54
  import { capsPage } from "./render-settings.js";
54
55
  import { decisionAnswerScript, taskBody } from "./render-tasks.js";
55
56
  import {} from "./session.js";
56
57
  import { rereadBrowser } from "./live.js";
57
58
  export function createPagesHandlers(runtime) {
58
- const { clock, lastChecks, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, authenticateCredential, projectOf, lookupSession } = runtime;
59
+ const { identities, clock, lastChecks, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, authenticateCredential, projectOf, lookupSession } = runtime;
59
60
  // The filesystem browser (operator request): pick a project folder by
60
61
  // looking, not typing. CONFINED to exactly what opening allows —
61
62
  // explicit --repo mode has an enumerable list and gets no browser at
@@ -67,40 +68,32 @@ export function createPagesHandlers(runtime) {
67
68
  const { url, who, response } = ctx;
68
69
  if (who.via !== "cookie")
69
70
  return refuse(response, who, 403, "browsing feeds a browser session's act");
70
- const browseRoots = ceiling.roots.length > 0 ? [...ceiling.roots] : unscopedMode ? [realpathSync(homedir())] : [];
71
+ const browseRoots = ceiling.roots.length > 0 ? [...ceiling.roots] : unscopedMode ? [identities.hot(homedir())] : [];
71
72
  if (browseRoots.length === 0) {
72
73
  return refuse(response, who, 404, "this server was configured with an explicit repo list — the openable projects are all on the projects page", "/projects");
73
74
  }
74
75
  const asked = url.searchParams.get("at") ?? browseRoots[0];
75
- const canonical = canonicalProject(asked);
76
- const inside = canonical !== null &&
77
- browseRoots.some(root => canonical === root || canonical.startsWith(`${root}/`));
78
- if (canonical === null || !inside) {
76
+ const within = (path) => browseRoots.some(root => path === root || path.startsWith(`${root}/`));
77
+ // Spelled outside every root: refused before anything is read. Inside, the look resolves links and checks again.
78
+ if (!within(resolve(asked))) {
79
79
  return refuse(response, who, 403, "that path is outside what this server may browse", "/projects/browse");
80
80
  }
81
- let entries = [];
82
- try {
83
- entries = readdirSync(canonical, { withFileTypes: true })
84
- .filter(one => one.isDirectory() || one.isSymbolicLink())
85
- .filter(one => !one.name.startsWith(".") && one.name !== "node_modules")
86
- .slice(0, 400)
87
- .map(one => {
88
- const path = join(canonical, one.name);
89
- // Resolve NOW so a symlink pointing outside the fence renders
90
- // as nothing rather than as a door.
91
- const resolved = canonicalProject(path);
92
- if (resolved === null || !browseRoots.some(root => resolved === root || resolved.startsWith(`${root}/`))) {
93
- return null;
94
- }
95
- return { name: one.name, path: resolved, git: existsSync(join(resolved, ".git")) };
96
- })
97
- .filter((one) => one !== null)
98
- .sort((a, b) => Number(b.git) - Number(a.git) || a.name.localeCompare(b.name))
99
- .slice(0, 200);
81
+ // Read off the console's thread (folder-look.ts): a folder that doesn't answer never freezes the console.
82
+ const looked = await listFolder(asked, browseRoots, { ...(options.folderLook === undefined ? {} : { runner: options.folderLook }), ...(options.folderLookMs === undefined ? {} : { deadlineMs: options.folderLookMs }) });
83
+ if (!looked.ok) {
84
+ return looked.why === "no-answer"
85
+ ? refuse(response, who, 503, "This folder didn't answer. It may be on a drive that's disconnected, or waiting on macOS. Try again in a moment.", "/projects")
86
+ : refuse(response, who, 404, "that directory cannot be read", "/projects/browse");
100
87
  }
101
- catch {
102
- return refuse(response, who, 404, "that directory cannot be read", "/projects/browse");
88
+ const canonical = looked.value.at;
89
+ if (canonical === null || !within(canonical)) {
90
+ return refuse(response, who, 403, "that path is outside what this server may browse", "/projects/browse");
103
91
  }
92
+ // The look kept only folders inside a root; checked here again, as before.
93
+ const entries = looked.value.entries
94
+ .filter(one => within(one.path))
95
+ .sort((a, b) => Number(b.git) - Number(a.git) || a.name.localeCompare(b.name))
96
+ .slice(0, 200);
104
97
  const root = browseRoots.find(one => canonical === one || canonical.startsWith(`${one}/`));
105
98
  const parent = canonical === root ? null : canonical.slice(0, canonical.lastIndexOf("/")) || root;
106
99
  const csrf = who.session.csrf;
@@ -133,48 +126,28 @@ export function createPagesHandlers(runtime) {
133
126
  // (GitHub's rule); the FIRST match wins.
134
127
  const localByIdentity = new Map();
135
128
  const registered = new Set();
136
- for (const project of store.listProjects()) {
137
- if (!visible(project.path))
138
- continue;
139
- registered.add(project.path);
140
- const identity = githubIdentityOf(originUrlOf(project.path) ?? "");
141
- if (identity !== null && !localByIdentity.has(identity.toLowerCase())) {
142
- localByIdentity.set(identity.toLowerCase(), project.path);
143
- }
144
- }
129
+ for (const project of store.listProjects())
130
+ if (visible(project.path))
131
+ registered.add(project.path);
145
132
  // Roots are walked ONLY when there are listed repos to match against
146
- // (finding 1), through an iterator that stops at 400 entries — a
147
- // directory with a million children costs 400 stats, not a scan.
148
- if (listed !== null && listed.ok && listed.repos.length > 0) {
149
- for (const root of ceiling.roots) {
150
- const children = [];
151
- try {
152
- const dir = opendirSync(root);
153
- try {
154
- for (let seen = 0; seen < 400; seen++) {
155
- const entry = dir.readSync();
156
- if (entry === null)
157
- break;
158
- if (entry.isDirectory())
159
- children.push(join(root, entry.name));
160
- }
161
- }
162
- finally {
163
- dir.closeSync();
164
- }
165
- }
166
- catch {
167
- // an unreadable root offers nothing
168
- }
169
- for (const child of children) {
170
- if (!existsSync(join(child, ".git")))
171
- continue;
172
- const identity = githubIdentityOf(originUrlOf(child) ?? "");
173
- if (identity !== null && !localByIdentity.has(identity.toLowerCase())) {
174
- localByIdentity.set(identity.toLowerCase(), child);
175
- }
133
+ // (finding 1), at most 400 folders a root. Every read runs off the
134
+ // console's thread (folder-look.ts): a folder that doesn't answer
135
+ // leaves its copies unmatched and the page says so.
136
+ const roots = listed !== null && listed.ok && listed.repos.length > 0 ? [...ceiling.roots] : [];
137
+ let localUnanswered = false;
138
+ if (registered.size > 0 || roots.length > 0) {
139
+ const looked = await localOrigins([...registered], roots, { ...(options.folderLook === undefined ? {} : { runner: options.folderLook }), ...(options.folderLookMs === undefined ? {} : { deadlineMs: options.folderLookMs }) });
140
+ if (looked.ok) {
141
+ const children = looked.value.found.filter(one => roots.some(root => one.path.startsWith(`${root}/`)));
142
+ for (const one of [...looked.value.registered.filter(row => registered.has(row.path)), ...children]) {
143
+ const identity = githubIdentityOf(one.url ?? "");
144
+ if (identity !== null && !localByIdentity.has(identity.toLowerCase()))
145
+ localByIdentity.set(identity.toLowerCase(), one.path);
176
146
  }
177
147
  }
148
+ else {
149
+ localUnanswered = looked.why === "no-answer";
150
+ }
178
151
  }
179
152
  return sendScreen(response, 200, githubReposPage(chromeFor(who.via === "cookie" ? who.session.project : null, "projects"), {
180
153
  listed,
@@ -183,6 +156,7 @@ export function createPagesHandlers(runtime) {
183
156
  csrf: who.via === "cookie" ? who.session.csrf : "",
184
157
  cloneReady: ceiling.roots.length > 0,
185
158
  openProject: who.via === "cookie" ? who.session.project ?? null : null,
159
+ localUnanswered,
186
160
  }));
187
161
  }
188
162
  // List repositories / Refresh list: ask the GitHub CLI signed in on this machine, then show the page.
@@ -354,7 +328,7 @@ export function createPagesHandlers(runtime) {
354
328
  runners: store.listRunners(),
355
329
  worktrees: store
356
330
  .listWorktrees()
357
- .filter(one => project === null || sameRepo(one.repo, project)),
331
+ .filter(one => project === null || one.repo === project || identities.hot(one.repo) === identities.hot(project)),
358
332
  episode: project === null ? null : store.latestWatchEpisode(project),
359
333
  now,
360
334
  }));
@@ -378,7 +352,7 @@ export function createPagesHandlers(runtime) {
378
352
  }),
379
353
  building: store.liveClaims(project, now),
380
354
  runners: store.listRunners(),
381
- worktrees: store.listWorktrees().filter(one => project === null || sameRepo(one.repo, project)),
355
+ worktrees: store.listWorktrees().filter(one => project === null || one.repo === project || identities.hot(one.repo) === identities.hot(project)),
382
356
  episode: project === null ? null : store.latestWatchEpisode(project),
383
357
  outboxPending: store.pendingForAttention().length,
384
358
  // External work at a glance (arc 3 finding 24): every dispatch
@@ -665,7 +639,11 @@ export function createPagesHandlers(runtime) {
665
639
  return refuse(response, who, 403, "selecting a project is a browser session's act");
666
640
  }
667
641
  const askedPath = (body.get("path") ?? "").trim();
668
- const canonicalPick = askedPath === "" ? null : canonicalProject(askedPath);
642
+ // Resolved off the console's thread (project-identity.ts): a folder that doesn't answer is said, never waited on.
643
+ const picked = askedPath === "" ? null : await identities.resolveNow(askedPath);
644
+ if (picked === "no-answer")
645
+ 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.");
646
+ const canonicalPick = picked;
669
647
  if ((askedPath !== "" && canonicalPick === null) || (canonicalPick !== null && (!visible(canonicalPick) || !(await authorizedProject(liveCeiling(), canonicalPick))))) {
670
648
  return refuse(response, who, 403, "that project is outside this console's reach");
671
649
  }
@@ -702,7 +680,11 @@ export function createPagesHandlers(runtime) {
702
680
  return refuse(response, who, 403, "opening a project is a browser session's act");
703
681
  }
704
682
  const asked = (body.get("path") ?? "").trim();
705
- const canonical = asked === "" ? null : canonicalProject(asked);
683
+ const resolved = asked === "" ? null : await identities.resolveNow(asked);
684
+ if (resolved === "no-answer") {
685
+ 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")));
686
+ }
687
+ const canonical = resolved;
706
688
  if (canonical === null) {
707
689
  return void projectsScreen(response, who, "that path does not exist on this server", 400, safeReturn(body.get("return")));
708
690
  }
@@ -807,13 +789,9 @@ export function createPagesHandlers(runtime) {
807
789
  const rootRaw = Number.isInteger(rootIndex) ? ceiling.roots[rootIndex] : undefined;
808
790
  if (rootRaw === undefined)
809
791
  return projectsScreen(response, who, "pick one of the configured roots", 400);
810
- let root;
811
- try {
812
- root = realpathSync(rootRaw);
813
- }
814
- catch {
792
+ const root = await identities.resolveNow(rootRaw);
793
+ if (root === null || root === "no-answer")
815
794
  return projectsScreen(response, who, "that projects root does not resolve right now", 400);
816
- }
817
795
  const previewed = await (options.ghPreview ?? previewGithubRepo)(shape.owner, shape.name);
818
796
  if (!previewed.ok)
819
797
  return projectsScreen(response, who, previewed.message, 400);
@@ -886,13 +864,9 @@ export function createPagesHandlers(runtime) {
886
864
  const rootRaw = ceiling.roots[record.rootIndex];
887
865
  if (rootRaw === undefined)
888
866
  return projectsScreen(response, who, "the configured roots changed — preview again", 400);
889
- let root;
890
- try {
891
- root = realpathSync(rootRaw);
892
- }
893
- catch {
867
+ const root = await identities.resolveNow(rootRaw);
868
+ if (root === null || root === "no-answer")
894
869
  return projectsScreen(response, who, "that projects root does not resolve right now", 400);
895
- }
896
870
  if (dirname(record.target) !== root)
897
871
  return projectsScreen(response, who, "the configured roots changed — preview again", 400);
898
872
  const cloned = await (options.ghClone ?? cloneGithubRepo)(record.nameWithOwner, root, async (cwd) => {
@@ -908,8 +882,8 @@ export function createPagesHandlers(runtime) {
908
882
  return projectsScreen(response, who, "the clone answered with a different path than the preview promised — refused; nothing was enrolled", 400);
909
883
  }
910
884
  // authorizedProject AFTER the repository exists (finding 20).
911
- const proved = await authorizedProject(liveCeiling(), cloned.target);
912
- const admitted = proved ? (canonicalProject(cloned.target) ?? cloned.target) : null;
885
+ const clonedAt = await identities.resolveNow(cloned.target);
886
+ const admitted = typeof clonedAt === "string" && clonedAt !== "no-answer" && (await authorizedProject(liveCeiling(), clonedAt)) ? clonedAt : null;
913
887
  if (admitted === null) {
914
888
  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);
915
889
  }
@@ -937,7 +911,7 @@ export function createPagesHandlers(runtime) {
937
911
  const recentPaths = new Set(recent.map(one => one.path));
938
912
  const candidates = new Set();
939
913
  for (const path of consoleProjects()) {
940
- const canonical = canonicalProject(path) ?? path;
914
+ const canonical = identities.hot(path);
941
915
  if (!recentPaths.has(canonical) && visible(canonical))
942
916
  candidates.add(canonical);
943
917
  }
@@ -422,22 +422,6 @@ export type ProjectPeek = {
422
422
  * GitHub identity. Everything else is null.
423
423
  */
424
424
  export declare function githubIdentityOf(remoteUrl: string): string | null;
425
- /**
426
- * The `origin` remote's url from a repository's OWN .git/config. BOUNDED
427
- * I/O by construction (ghlist review, finding 1): the final component may
428
- * not be a symlink (O_NOFOLLOW + lstat), must be a REGULAR file under a
429
- * size cap, and at most 64 KiB are ever read — a sparse monster or a fifo
430
- * planted as a "config" reads as null, never as a hang. Parsed LINE BY
431
- * LINE with real section tracking (finding 2): a `[remote "origin"]`
432
- * embedded inside some other value never opens the section. null when
433
- * unreadable or origin-less; a worktree-style `.git` FILE (gitdir pointer)
434
- * reads null too, which is honest — its identity lives elsewhere. The
435
- * answer is ADVISORY metadata for offering a button: the /projects/open
436
- * road re-proves path, ceiling, and git-ness before anything mutates, and
437
- * a config that lies about its origin can only mislabel a repository the
438
- * operator was already allowed to open.
439
- */
440
- export declare function originUrlOf(repoPath: string): string | null;
441
425
  /** The GitHub listing page: the account's repositories with ONE honest
442
426
  * action each. Names, descriptions, and paths are gh/filesystem DATA —
443
427
  * escaped at the sink like everything else. */
@@ -450,6 +434,8 @@ export declare function githubReposPage(chrome: Chrome, data: {
450
434
  csrf: string;
451
435
  cloneReady: boolean;
452
436
  openProject: string | null;
437
+ /** A projects folder didn't answer in time: local copies weren't matched this time. */
438
+ localUnanswered?: boolean;
453
439
  }): Screen;
454
440
  export declare function projectsPage(chrome: Chrome, recent: {
455
441
  path: string;
@@ -22,7 +22,6 @@ import { runCostWords, spendLine, tally } from "../summary.js";
22
22
  import {} from "../work-index.js";
23
23
  import { WORK_VIEWS } from "../workspace-ui.js";
24
24
  import { createHash } from "node:crypto";
25
- import { closeSync, constants as fsConstants, lstatSync, openSync, readSync } from "node:fs";
26
25
  import { homedir } from "node:os";
27
26
  import { join } from "node:path";
28
27
  import { buildsViews, CHEVRON_ICON, consoleSpend, FOLDER_PATHS, GRIP_HANDLE, isOverdue, projectChip, relativeAge, safePrUrl, screen, sentenceCase, strokeIcon, TO_FRONT_ICON, when, whenTime } from "./chrome.js";
@@ -865,60 +864,6 @@ export function githubIdentityOf(remoteUrl) {
865
864
  return `${ssh[1]}/${ssh[2]}`;
866
865
  return null;
867
866
  }
868
- /**
869
- * The `origin` remote's url from a repository's OWN .git/config. BOUNDED
870
- * I/O by construction (ghlist review, finding 1): the final component may
871
- * not be a symlink (O_NOFOLLOW + lstat), must be a REGULAR file under a
872
- * size cap, and at most 64 KiB are ever read — a sparse monster or a fifo
873
- * planted as a "config" reads as null, never as a hang. Parsed LINE BY
874
- * LINE with real section tracking (finding 2): a `[remote "origin"]`
875
- * embedded inside some other value never opens the section. null when
876
- * unreadable or origin-less; a worktree-style `.git` FILE (gitdir pointer)
877
- * reads null too, which is honest — its identity lives elsewhere. The
878
- * answer is ADVISORY metadata for offering a button: the /projects/open
879
- * road re-proves path, ceiling, and git-ness before anything mutates, and
880
- * a config that lies about its origin can only mislabel a repository the
881
- * operator was already allowed to open.
882
- */
883
- export function originUrlOf(repoPath) {
884
- const file = join(repoPath, ".git", "config");
885
- let fd = null;
886
- try {
887
- const stat = lstatSync(file);
888
- if (!stat.isFile() || stat.size > 1024 * 1024)
889
- return null;
890
- fd = openSync(file, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
891
- const buffer = Buffer.alloc(64 * 1024);
892
- const read = readSync(fd, buffer, 0, buffer.length, 0);
893
- const config = buffer.toString("utf8", 0, read);
894
- let inOrigin = false;
895
- for (const line of config.split("\n")) {
896
- if (/^\s*\[/.test(line)) {
897
- inOrigin = /^\s*\[remote "origin"\]\s*$/.test(line);
898
- continue;
899
- }
900
- if (!inOrigin)
901
- continue;
902
- const url = /^\s*url\s*=\s*(.+)$/.exec(line)?.[1];
903
- if (url !== undefined)
904
- return url.trim();
905
- }
906
- return null;
907
- }
908
- catch {
909
- return null;
910
- }
911
- finally {
912
- if (fd !== null) {
913
- try {
914
- closeSync(fd);
915
- }
916
- catch {
917
- // already closed
918
- }
919
- }
920
- }
921
- }
922
867
  /** The GitHub listing page: the account's repositories with ONE honest
923
868
  * action each. Names, descriptions, and paths are gh/filesystem DATA —
924
869
  * escaped at the sink like everything else. */
@@ -955,6 +900,7 @@ export function githubReposPage(chrome, data) {
955
900
  return screen("projects", joinHtml([
956
901
  html `<h1>Your GitHub repositories</h1>`,
957
902
  html `<p class="meta">Repositories available to the GitHub account signed in on this machine — open one you already have, or clone a new one after a quick preview.</p>`,
903
+ data.localUnanswered === true ? html `<p class="meta">Your projects folder didn't answer, so copies already on this machine aren't matched yet. Try again in a moment.</p>` : null,
958
904
  rows,
959
905
  data.listed === null ? null : list("Refresh list"),
960
906
  html `<p class="row" style="margin-top:.6rem"><a class="badge" href="/projects">← back to projects</a></p>`,
@@ -2519,6 +2519,7 @@ export function releaseCardHtml(taskId, release, canStop) {
2519
2519
  html `<details><summary>Later commits (${release.landed.length})</summary>${landed}</details>`,
2520
2520
  release.obligations.length === 0 ? "" : html `<details><summary>Still to pass (${release.obligations.length})</summary><ul>${joinHtml(release.obligations.map(one => html `<li>${one}</li>`), "")}</ul></details>`,
2521
2521
  release.nightly === null ? "" : html `<p class="meta">Nightly check of main: ${release.nightly.outcome ?? "running"} at ${release.nightly.sha.slice(0, 7)}${release.openMainFailures > 0 ? ` · ${release.openMainFailures} open failure${release.openMainFailures === 1 ? "" : "s"}` : ""}</p>`,
2522
+ ...release.githubNightly.map(line => html `<p class="meta">${line}</p>`),
2522
2523
  release.carrying && canStop ? postForm(`${taskHref(taskId)}/stop-carrying`, html `<button type="submit" class="secondary">Stop carrying later commits</button>`, { attrs: { class: "inline" } }) : "",
2523
2524
  html `</div>`,
2524
2525
  ], "\n");
@@ -1,3 +1,4 @@
1
+ import { type ProjectIdentities } from "../project-identity.js";
1
2
  import type { FreshFacts } from '../scope.js';
2
3
  import type { createPersonalQueueRuntime } from '../lead-queue-runtime.js';
3
4
  import type { FlowRooms } from '../flow-live.js';
@@ -95,7 +96,10 @@ export interface ServerRuntime {
95
96
  liveCeiling: () => {
96
97
  repos: string[];
97
98
  roots: readonly string[];
99
+ canon?: (path: string) => string | null;
98
100
  };
101
+ /** Project identities without reading folders on the console's thread (project-identity.ts). */
102
+ identities: ProjectIdentities;
99
103
  taskScreen: (response: ServerResponse, who: Who, taskId: string, problem: string | null, status: number, scopeDraft?: URLSearchParams, cancelDraft?: string, editPlan?: boolean, page?: TaskPageExtras) => void;
100
104
  runVisible: (run: Run) => boolean;
101
105
  runIsLive: (run: Pick<Run, "outcome" | "leaseId" | "taskRef">) => boolean;
@@ -1,3 +1,4 @@
1
+ import {} from "../project-identity.js";
1
2
  /** Captured server state and shared guards. Getters preserve live state across requests. */
2
3
  import {} from "node:http";
3
4
  import {} from '../assignment.js';
@@ -9,6 +9,7 @@ import { CONNECT_CALLBACK, connectionsOf, finishConnect, localConnectOf, oneClic
9
9
  import { learningView } from "../project-learning.js";
10
10
  import { skillTestResult } from "../project-skills.js";
11
11
  import { discoverTools, projectToolsOf } from "../project-tools.js";
12
+ import { lookProjectMcp } from "../folder-look.js";
12
13
  import { publishingOf } from '../pull-request-flow.js';
13
14
  import { skillTestFeedbackHtml } from "../skills-ui.js";
14
15
  import { labelOf, nameOf } from "../subagent-admin.js";
@@ -132,7 +133,7 @@ import { personChip, ROLE_TITLES } from "./chrome.js";
132
133
  import { chatReturnWithSaid, goOutside } from "./http.js";
133
134
  import { leadSettingsHtml, notificationProjects, SETTINGS_AUTOSAVE_SCRIPT, settingsPage, settingsWorkers, telegramDeliveryWords, telegramTrouble } from "./render-settings.js";
134
135
  export function createSettingsHandlers(runtime) {
135
- const { store, providerHome, managedRepos, consoleProjects, sendScreen, chromeFor, connectionCheck, connectionKnown, lastChecks, checkRelease, clock, modelCatalog, modelSeams, toolsViewOf, codexServers, projectViewOf, sessions, restricted, options, integrations, consoleOrigin, evidenceRoot, storagePool, visible, ssoSettings, checkLocalAgents, chatKeyFor, chatCatalog, chatCatalogKnown, leadWords, agentSignInCommand, settingsUpdates, phoneSetup, authenticateApprover, authenticateCredential, deletedRepos, projectOf, providerFor, admissionList, toolHome, connectVisits, mintApprovalNonce, consumeApprovalNonce, defaultProject, googleVisits } = runtime;
136
+ const { identities, store, providerHome, managedRepos, consoleProjects, sendScreen, chromeFor, connectionCheck, connectionKnown, lastChecks, checkRelease, clock, modelCatalog, modelSeams, toolsViewOf, codexServers, projectViewOf, sessions, restricted, options, integrations, consoleOrigin, evidenceRoot, storagePool, visible, ssoSettings, checkLocalAgents, chatKeyFor, chatCatalog, chatCatalogKnown, leadWords, agentSignInCommand, settingsUpdates, phoneSetup, authenticateApprover, authenticateCredential, deletedRepos, projectOf, providerFor, admissionList, toolHome, connectVisits, mintApprovalNonce, consumeApprovalNonce, defaultProject, googleVisits } = runtime;
136
137
  // v105: what agent work cost, by month, and the monthly budgets. An instance operator's page.
137
138
  async function spendPage(ctx) {
138
139
  const { url, who, response, now } = ctx;
@@ -1547,7 +1548,15 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1547
1548
  const wanted = body.getAll("import");
1548
1549
  if (wanted.length === 0)
1549
1550
  return back("problem", "Choose at least one tool to add.");
1550
- const found = discoverTools(repo, await codexServers(repo), toolHome);
1551
+ // The project's own .mcp.json read now, off the console's thread (folder-look.ts).
1552
+ let projectConfig;
1553
+ if (options.identities !== undefined) {
1554
+ const looked = await lookProjectMcp(repo);
1555
+ if (!looked.ok)
1556
+ return back("problem", "This project's folder didn't answer. It may be on a drive that's disconnected, or waiting on macOS. Try again in a moment.");
1557
+ projectConfig = looked.value;
1558
+ }
1559
+ const found = discoverTools(repo, await codexServers(repo), toolHome, process.env, projectConfig);
1551
1560
  const added = [], problems = [];
1552
1561
  for (const one of wanted) {
1553
1562
  const match = found.find(tool => tool.spec.name === one);
@@ -1887,7 +1896,9 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1887
1896
  if (["scope", "full-access", "cap-usd", "nonce", "digest", "token"].some(key => body.getAll(key).length > 1))
1888
1897
  return refuse(response, who, 400, "Submit one value for each field.", "/settings");
1889
1898
  const given = (body.get("scope") ?? "all").trim();
1890
- const scope = given === "all" || given === "" ? CHAT_APPROVAL_ALL : canonicalProject(given) ?? given;
1899
+ const scope = given === "all" || given === "" ? CHAT_APPROVAL_ALL : identities.vouched(given, consoleProjects) ?? null;
1900
+ if (scope === null)
1901
+ return refuse(response, who, 403, "That isn't one of your projects.", "/settings");
1891
1902
  if (scope !== CHAT_APPROVAL_ALL && !(visible(scope) && store.accountCanAccess(who.name, scope)))
1892
1903
  return refuse(response, who, 403, "That isn't one of your projects.", "/settings");
1893
1904
  const where = scope === CHAT_APPROVAL_ALL ? "all your projects" : projectName(scope);