toolroll 0.9.82 → 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.
package/dist/operate.js CHANGED
@@ -110,7 +110,7 @@ import { claimInstance, clearInstance, openInstanceSocket } from "./console-inst
110
110
  import { authorizePlanUnderMode } from "./plan-auto.js";
111
111
  import { ghDispatchAdapter, mirrorTaskId, syncPass } from "./sync.js";
112
112
  import { LIVE_FILE_BOUND, LIVE_RETAIN_MS, sweepLiveLogs } from "./live.js";
113
- import { addRepos, removeRepos, updateRepos, loadRepos, loadProjectRegistry, updateProjectRegistry } from "./repos.js";
113
+ import { addRepos, removeRepos, updateRepos, loadRepos, loadProjectRegistry, updateProjectRegistry, registryRemovals } from "./repos.js";
114
114
  import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
115
115
  import { pushPass } from "./push.js";
116
116
  import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync } from "node:fs";
@@ -181,6 +181,8 @@ import { createDecisionServer } from "./serve.js";
181
181
  import { readTailnetNames } from "./tailnet.js";
182
182
  import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLegacyDaemon, planDaemon, uninstallDaemon, awaitFreshHeartbeat, } from "./daemon.js";
183
183
  import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, networkFence, networkFenceLine, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
184
+ import { projectIdentities } from "./project-identity.js";
185
+ import { lookIdentities } from "./folder-look.js";
184
186
  import { scanRepo } from "./capscan.js";
185
187
  import { computeGaps, describeCapability } from "./gaps.js";
186
188
  import { ask as promptAsk, askHidden as promptHidden, confirm as promptConfirm, interactive as promptInteractive, underAgent } from "./prompt.js";
@@ -3939,7 +3941,13 @@ async function startConsole(options) {
3939
3941
  const { context } = options;
3940
3942
  // A git incident (teardown-git.ts) raises an attention notification in this console's outbox.
3941
3943
  noticeGitIncidents(context.store, context.clock);
3944
+ // Every project folder this console starts with is resolved off its thread, under a deadline: one that doesn't
3945
+ // answer (a drive away, an iCloud folder macOS is holding) stays unresolved, kept restrictive, and is looked at again
3946
+ // by the background pass; the console starts and answers either way (project-identity.ts).
3947
+ const identities = projectIdentities();
3948
+ await identities.refresh([...options.repos ?? [], ...options.projectRoots ?? [], ...context.store.listProjects().map(one => one.path), ...context.store.knownRepos()]);
3942
3949
  const server = createDecisionServer({
3950
+ identities,
3943
3951
  store: context.store,
3944
3952
  // Sign-in, integration and model checks, and the tidy-ups no page visit does (docs/plans/get-side-effects.md).
3945
3953
  backgroundChecks: true,
@@ -7055,6 +7063,8 @@ async function upCommand(flags, context) {
7055
7063
  // folders they may be added from are installation state, not arguments a
7056
7064
  // person must repeat on every start.
7057
7065
  const registryPath = registryPathOf(context);
7066
+ // Read before the list itself: a line removed after this (even if saved again) counts differently from now on.
7067
+ const removalsAtStart = registryRemovals(registryPath);
7058
7068
  const loadedRegistry = await loadProjectRegistry(registryPath);
7059
7069
  if ("error" in loadedRegistry)
7060
7070
  return fail(write, json, "up", "registry", loadedRegistry.error, EXIT.refused);
@@ -7078,23 +7088,28 @@ async function upCommand(flags, context) {
7078
7088
  // soon as a projects folder or saved repository exists.
7079
7089
  const repos = [];
7080
7090
  const proveRepo = async (input) => {
7081
- const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd: resolve(input) });
7082
- if (top.code !== 0)
7083
- return null;
7084
- try {
7085
- return realpathSync(top.stdout.trim());
7086
- }
7087
- catch {
7088
- return null;
7089
- }
7091
+ // The checkout's real path is read off the thread, under a deadline (folder-look.ts): a folder that doesn't answer
7092
+ // is unavailable, never a wait.
7093
+ return (await upProveSaved(gitRun, input))?.root ?? null;
7090
7094
  };
7095
+ // A saved project that doesn't answer now (a drive not connected, a folder macOS is holding) is looked at again
7096
+ // while this start runs, and connects once it answers (upDeferredRetry).
7097
+ const deferredRepos = [];
7098
+ const deferredGenerations = {};
7099
+ // The saved line(s) each project was proved from, so its removal is judged by its own line alone.
7100
+ const repoLines = {};
7091
7101
  for (const input of loadedRegistry.repos) {
7092
7102
  const root = await proveRepo(input);
7093
7103
  if (root === null) {
7094
- progress(`saved project unavailable — skipped ${input}`);
7104
+ progress(`saved project unavailable — skipped ${input}; it connects when it's back`);
7105
+ deferredRepos.push(input);
7106
+ // An unreadable removal record leaves no count that could match later: the authority can't be used.
7107
+ deferredGenerations[input] = removalsAtStart === null ? -1 : removalsAtStart[input] ?? 0;
7095
7108
  }
7096
- else if (!repos.includes(root)) {
7097
- repos.push(root);
7109
+ else {
7110
+ if (!repos.includes(root))
7111
+ repos.push(root);
7112
+ (repoLines[root] ??= []).push(input);
7098
7113
  }
7099
7114
  }
7100
7115
  const explicitRepos = context.repoList ?? [];
@@ -7118,7 +7133,9 @@ async function upCommand(flags, context) {
7118
7133
  if (cwdRepo !== null)
7119
7134
  pinnedRepos.add(cwdRepo);
7120
7135
  }
7121
- if (repos.length === 0 && projectRoots.length === 0)
7136
+ // Saved projects that are only away for now still start the service: it serves an empty, restrictive set until they
7137
+ // answer (never the unscoped mode), and connects each as it comes back.
7138
+ if (repos.length === 0 && projectRoots.length === 0 && deferredRepos.length === 0)
7122
7139
  return fail(write, json, "up", "no-projects", "choose where your projects live once: `toolroll up --project-root <dir>`", EXIT.refused);
7123
7140
  // A person at a terminal reads three lines (onboarding); the folders are for --verbose, a service log, or an agent.
7124
7141
  const quietStart = !json && !flags.has("verbose") && (context.upSeams?.terminal ?? process.stdout.isTTY === true) && !underAgent(context.upSeams?.env ?? process.env);
@@ -7376,6 +7393,9 @@ async function upCommand(flags, context) {
7376
7393
  type: "start", role: "worker", databaseFile: context.databaseFile, containment: containmentFlag, build,
7377
7394
  config: {
7378
7395
  runner: runnerName, token: runnerToken, repos: generation === 1 ? [...repos] : [...currentRepos], pinnedRepos: [...pinnedRepos], projectRoots, registryPath, pool,
7396
+ // What this start still holds for its saved projects that were away: a restarted worker never gets back an
7397
+ // authority an earlier one gave up (connected, or removed from the saved list).
7398
+ deferredRepos: [...deferredRepos], deferredGenerations: { ...deferredGenerations }, repoLines,
7379
7399
  forMs: forGiven === undefined ? null : Number(forGiven), publicUrl: publicUrlFlag ?? null, stopGraceMs, verbose: flags.has("verbose"),
7380
7400
  terminal: context.upSeams?.terminal ?? process.stdout.isTTY === true,
7381
7401
  // A restart waits out a crashed predecessor's lease (at most a minute), then recovers it: as a fresh `up` after a crash.
@@ -7385,6 +7405,12 @@ async function upCommand(flags, context) {
7385
7405
  onMessage: message => {
7386
7406
  if (message.type === "log")
7387
7407
  roleLine("worker")(message.line);
7408
+ else if (message.type === "deferred") {
7409
+ // Only ever narrows: a worker's word can't give back an authority this start no longer holds.
7410
+ const narrowed = upNarrowDeferred(deferredRepos, message.repos);
7411
+ deferredRepos.length = 0;
7412
+ deferredRepos.push(...narrowed);
7413
+ }
7388
7414
  else if (message.type === "repos") {
7389
7415
  // The console's admitted projects widen only to what the builder is bound to in the store (the worker binds
7390
7416
  // through the authenticated door before it reports): a worker's word alone admits nothing.
@@ -12093,15 +12119,9 @@ async function runUpWorkerRole(context, config, port, build) {
12093
12119
  const pinnedRepos = new Set(config.pinnedRepos);
12094
12120
  const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
12095
12121
  const proveRepo = async (input) => {
12096
- const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd: resolve(input) });
12097
- if (top.code !== 0)
12098
- return null;
12099
- try {
12100
- return realpathSync(top.stdout.trim());
12101
- }
12102
- catch {
12103
- return null;
12104
- }
12122
+ // The checkout's real path is read off the thread, under a deadline (folder-look.ts): a folder that doesn't answer
12123
+ // is unavailable, never a wait.
12124
+ return (await upProveSaved(gitRun, input))?.root ?? null;
12105
12125
  };
12106
12126
  // The proved set this worker builds; `up` relays each change to the console.
12107
12127
  const activeRepos = new Set(config.repos);
@@ -12286,8 +12306,17 @@ async function runUpWorkerRole(context, config, port, build) {
12286
12306
  await pause(follower === null ? 2_000 : 5_000);
12287
12307
  }
12288
12308
  })();
12289
- const dynamicCeiling = resolveCeiling(config.repos, config.projectRoots).ceiling;
12309
+ // The start's own projects and folders were resolved by `up`: taken as they are, never read again on this thread.
12310
+ const dynamicCeiling = resolveCeiling(config.repos, config.projectRoots, path => resolve(path)).ceiling;
12290
12311
  const rejected = new Set();
12312
+ // Saved lines' identities, resolved off the thread under a deadline (project-identity.ts): a line whose folder
12313
+ // doesn't answer is skipped this pass, and never holds up the others.
12314
+ const lineIdentities = projectIdentities();
12315
+ /** The saved line(s) each active project came from: `up`'s proof for the start's own, the line admitted for later ones. */
12316
+ const admittedLines = new Map(Object.entries(config.repoLines ?? {}));
12317
+ const deferred = upDeferredRetry(config.deferredRepos ?? [], input => upProveSaved(gitRun, input));
12318
+ const deferredGenerations = config.deferredGenerations ?? {};
12319
+ const reportDeferred = () => port.send({ type: "deferred", repos: deferred.list() });
12291
12320
  const registrySupervisor = (async () => {
12292
12321
  const sleep = (ms) => new Promise(resolveSleep => setTimeout(resolveSleep, ms));
12293
12322
  while (!stopping) {
@@ -12297,11 +12326,19 @@ async function runUpWorkerRole(context, config, port, build) {
12297
12326
  stop();
12298
12327
  break;
12299
12328
  }
12329
+ // A saved line removed while this start runs takes the start's authority for it with it, for good.
12330
+ if (deferred.keepOnly(loaded.repos))
12331
+ reportDeferred();
12332
+ const plain = loaded.repos.filter(one => !deferred.has(one));
12333
+ await lineIdentities.refresh(plain);
12300
12334
  // Removed from the saved list: stop building it and take it out of the console. A pinned start keeps its own.
12301
- const saved = new Set(loaded.repos.map(one => canonicalProject(one) ?? one));
12335
+ // Each project is judged by its own saved line (the one it was proved or admitted from, or itself), never by
12336
+ // whether some other folder answered: an unrelated folder that is away never keeps a removed project building.
12337
+ const saved = new Set(loaded.repos);
12302
12338
  let removed = false;
12303
12339
  for (const repo of [...activeRepos]) {
12304
- if (stopping || pinnedRepos.has(repo) || saved.has(repo))
12340
+ const lines = admittedLines.get(repo) ?? [repo];
12341
+ if (stopping || pinnedRepos.has(repo) || saved.has(repo) || lines.some(line => saved.has(line)))
12305
12342
  continue;
12306
12343
  activeRepos.delete(repo);
12307
12344
  removed = true;
@@ -12314,12 +12351,23 @@ async function runUpWorkerRole(context, config, port, build) {
12314
12351
  for (const candidate of loaded.repos) {
12315
12352
  if (stopping)
12316
12353
  break;
12317
- const canonical = canonicalProject(candidate);
12354
+ // Skipped at start because it didn't answer: connected on the start's own authority once it does.
12355
+ if (deferred.has(candidate)) {
12356
+ await connectDeferred(candidate);
12357
+ continue;
12358
+ }
12359
+ const canonical = lineIdentities.known(candidate);
12360
+ // Not answered yet: looked at again on the next pass.
12361
+ if (canonical === undefined)
12362
+ continue;
12318
12363
  // Still letting go of an earlier removal: it rejoins on the next pass after its watch has stopped.
12319
12364
  if (canonical !== null && (activeRepos.has(canonical) || releasedRepos.has(canonical)))
12320
12365
  continue;
12321
12366
  const approvedAdditions = context.additionalProjectRepos?.() ?? [];
12322
- const admittedCeiling = resolveCeiling([...dynamicCeiling.repos, ...approvedAdditions], dynamicCeiling.roots).ceiling;
12367
+ // A start serving nothing yet (its saved projects all away) is an empty ceiling, never the unscoped mode: a
12368
+ // name that matches no path stands in, so a line saved later still needs its recorded admission.
12369
+ const admittedRepos = [...dynamicCeiling.repos, ...approvedAdditions];
12370
+ const admittedCeiling = { repos: admittedRepos.length === 0 && dynamicCeiling.roots.length === 0 ? ["/\0nothing-admitted"] : admittedRepos, roots: dynamicCeiling.roots, canon: (path) => lineIdentities.known(path) ?? null };
12323
12371
  // A project added after this start (`repos add`, the lead, the console) carries its recorded admission; a
12324
12372
  // registry line alone is not authority. Either way the folder is proved to be that Git checkout again.
12325
12373
  const allowed = canonical !== null &&
@@ -12333,6 +12381,7 @@ async function runUpWorkerRole(context, config, port, build) {
12333
12381
  continue;
12334
12382
  }
12335
12383
  rejected.delete(candidate);
12384
+ admittedLines.set(canonical, [...new Set([...admittedLines.get(canonical) ?? [], candidate])]);
12336
12385
  await launchRepo(canonical, true);
12337
12386
  }
12338
12387
  if (!stopping)
@@ -12342,6 +12391,61 @@ async function runUpWorkerRole(context, config, port, build) {
12342
12391
  fatal = `the project registry stopped (${describe(error)})`;
12343
12392
  stop();
12344
12393
  });
12394
+ /**
12395
+ * A saved project the start couldn't reach, back now: connected only as the very folder that was saved (a parent
12396
+ * checkout found because the saved one lost its .git is another project, needing its own admission), and only while
12397
+ * the line is still saved, checked under the saved list's lock with the binding made inside it, so a removal racing
12398
+ * the proof always wins.
12399
+ */
12400
+ async function connectDeferred(candidate) {
12401
+ const proved = await deferred.retry(candidate);
12402
+ if (proved === null || stopping)
12403
+ return;
12404
+ // Only the very folder that was saved: the saved line must still resolve to itself, and git's checkout must be that
12405
+ // folder. A parent checkout, or the saved path now leading somewhere else, is another project needing admission.
12406
+ if (proved.line !== candidate || proved.root !== candidate) {
12407
+ deferred.revoke(candidate);
12408
+ reportDeferred();
12409
+ progress(proved.root !== proved.line
12410
+ ? `saved project not connected — ${candidate} came back as part of ${proved.root}; add it again to connect it`
12411
+ : `saved project not connected — ${candidate} now leads to ${proved.line}; add it again to connect it`);
12412
+ return;
12413
+ }
12414
+ const root = candidate;
12415
+ if (activeRepos.has(root) || releasedRepos.has(root)) {
12416
+ deferred.revoke(candidate);
12417
+ reportDeferred();
12418
+ return;
12419
+ }
12420
+ // Bound under the saved list's lock: the line is still saved AND has not been removed since this start read it
12421
+ // (its removal count unchanged, so a removal and a save again during the proof still counts as removed).
12422
+ let launching = null;
12423
+ let removedSince = false;
12424
+ await updateProjectRegistry(registryPath, current => {
12425
+ if (stopping || !deferred.has(candidate))
12426
+ return current;
12427
+ const count = registryRemovals(registryPath)?.[candidate] ?? (registryRemovals(registryPath) === null ? -2 : 0);
12428
+ if (!current.repos.includes(candidate) || count !== deferredGenerations[candidate]) {
12429
+ deferred.revoke(candidate);
12430
+ removedSince = true;
12431
+ return current;
12432
+ }
12433
+ deferred.revoke(candidate);
12434
+ admittedLines.set(root, [candidate]);
12435
+ launching = launchRepo(root, true);
12436
+ return current;
12437
+ });
12438
+ if (removedSince) {
12439
+ reportDeferred();
12440
+ progress(`saved project not connected — ${candidate} was removed while it was away; add it again to connect it`);
12441
+ return;
12442
+ }
12443
+ if (launching === null)
12444
+ return;
12445
+ reportDeferred();
12446
+ progress(`saved project is back — connecting ${root}`);
12447
+ await launching;
12448
+ }
12345
12449
  // Provider readiness (v47): this machine's non-spending observations, recorded under its own runner name ONCE at
12346
12450
  // start — so every route projection can say ready / unavailable / unknown per provider, and the dispatch gate can
12347
12451
  // halt a leg this machine cannot run. Production only: a test's injected agent runner is a fake, and its readiness
@@ -12530,3 +12634,57 @@ async function evalsAbandonCommand(flags, context) {
12530
12634
  return fail(write, json, command, ended.reason, ended.message, EXIT.refused);
12531
12635
  return succeed(write, json, command, { campaign: id, state: "incomplete", attempts: ended.attempts, spending: "unknown" }, () => [`Campaign ${id} ended incomplete. ${ended.attempts} unfinished attempt${ended.attempts === 1 ? "" : "s"} recorded as errors; what it spent is unknown. Nothing was stopped: check for its processes yourself.`]);
12532
12636
  }
12637
+ /**
12638
+ * The saved projects an `up` start couldn't reach (upCommand): each is proved again every `everyMs` while the worker
12639
+ * runs, by `prove` (upProveSaved), so a folder that answers again connects without a restart. Only what the start itself
12640
+ * read from the saved list is here, and the set only shrinks: a line connected, removed from the saved list
12641
+ * (`keepOnly`) or come back as some other checkout (`revoke`) is gone for good, so a line saved again later needs its
12642
+ * recorded admission like any other addition. `retry` answers the proof when one is due and answered, else null.
12643
+ */
12644
+ export function upDeferredRetry(inputs, prove, everyMs = 5_000, now = Date.now) {
12645
+ // Wall time, not the store's clock (a test's may stand still): this is only how often a folder is asked.
12646
+ const nextAt = new Map(inputs.map(input => [input, now() + everyMs]));
12647
+ return {
12648
+ has: input => nextAt.has(input),
12649
+ list: () => [...nextAt.keys()],
12650
+ retry: async (input) => {
12651
+ const due = nextAt.get(input);
12652
+ if (due === undefined || now() < due)
12653
+ return null;
12654
+ nextAt.set(input, now() + everyMs);
12655
+ return prove(input);
12656
+ },
12657
+ revoke: input => { nextAt.delete(input); },
12658
+ keepOnly: saved => {
12659
+ const keep = new Set(saved);
12660
+ let changed = false;
12661
+ for (const input of [...nextAt.keys()])
12662
+ if (!keep.has(input)) {
12663
+ nextAt.delete(input);
12664
+ changed = true;
12665
+ }
12666
+ return changed;
12667
+ },
12668
+ };
12669
+ }
12670
+ /**
12671
+ * Whether a saved project's folder is a Git checkout now, read without blocking this thread: `git rev-parse
12672
+ * --show-toplevel` (bounded, its own process), then the real paths of the saved line and of the checkout, resolved off
12673
+ * the thread under a deadline (folder-look.ts). Null when it isn't one, or didn't answer.
12674
+ */
12675
+ export async function upProveSaved(gitRun, input, look = {}) {
12676
+ const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd: resolve(input) });
12677
+ if (top.code !== 0)
12678
+ return null;
12679
+ const answered = await lookIdentities([resolve(input), top.stdout.trim()], look);
12680
+ if (!answered.ok)
12681
+ return null;
12682
+ const line = answered.value.get(resolve(input)) ?? null, root = answered.value.get(top.stdout.trim()) ?? null;
12683
+ return line === null || root === null ? null : { line, root };
12684
+ }
12685
+ /** What `up` hands its next worker after the worker reports which saved projects it still holds start authority for:
12686
+ * only what both still hold. A worker's report can give up an authority, never add or give back one. */
12687
+ export function upNarrowDeferred(held, reported) {
12688
+ const kept = new Set(reported);
12689
+ return held.filter(one => kept.has(one));
12690
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Project identities as the console uses them, without touching a person's folders on its thread.
3
+ *
4
+ * A project's identity is its real path (project.ts canonicalProject). Resolving one reads the folder, and a folder can
5
+ * stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread:
6
+ *
7
+ * - `hot` (display and grouping only): the last answer, else the path as saved. Nothing is started by asking.
8
+ * - `strict` (visibility and authorization): only a resolved identity; an unknown one fails closed until the next
9
+ * refresh resolves it, so a stored path that leads outside its root never passes on its spelling.
10
+ * - `vouched` (a path a request names): only a path whose identity is known, or one that is exactly a saved identity.
11
+ * Anything else is refused this time and resolved by the next background pass, never resolved inline.
12
+ * - `resolveNow` (an action that needs a path checked now, a POST): resolved off the thread under the look's deadline.
13
+ * - `refresh` (the console's start and its background pass): resolves what was asked about and what has gone stale.
14
+ *
15
+ * An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
16
+ */
17
+ import { type FolderLookOptions } from "./folder-look.js";
18
+ export type ProjectIdentities = {
19
+ /** For display and grouping only: the last known identity, else the path as saved (resolved lexically). Reads
20
+ * nothing. Never an authorization answer: use `strict`. */
21
+ hot: (path: string) => string;
22
+ /** For authorization: the known real folder path, else null (unknown or not a folder: fail closed until resolved).
23
+ * Reads nothing; an unknown path is asked about on the next refresh. */
24
+ strict: (path: string) => string | null;
25
+ /** The known identity: a real folder path, null (not a folder), or undefined (never answered). Reads nothing. */
26
+ known: (path: string) => string | null | undefined;
27
+ /** A request's path: its known identity, or the path itself when it is exactly one of `saved` (projects the plane
28
+ * itself proved and admitted); else undefined. */
29
+ vouched: (path: string, saved: () => Iterable<string>) => string | undefined;
30
+ /** This path's identity now, off the thread: a real folder path, null, or "no-answer" past the deadline. */
31
+ resolveNow: (path: string) => Promise<string | null | "no-answer">;
32
+ /** Resolve `paths`, everything asked about since, and answers older than a minute. False when some didn't answer. */
33
+ refresh: (paths: Iterable<string>) => Promise<boolean>;
34
+ };
35
+ export declare function projectIdentities(look?: FolderLookOptions, now?: () => number): ProjectIdentities;
36
+ /** The synchronous reads, as before: for embedders and tests that pass no identities. */
37
+ export declare function syncProjectIdentities(): ProjectIdentities;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Project identities as the console uses them, without touching a person's folders on its thread.
3
+ *
4
+ * A project's identity is its real path (project.ts canonicalProject). Resolving one reads the folder, and a folder can
5
+ * stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread:
6
+ *
7
+ * - `hot` (display and grouping only): the last answer, else the path as saved. Nothing is started by asking.
8
+ * - `strict` (visibility and authorization): only a resolved identity; an unknown one fails closed until the next
9
+ * refresh resolves it, so a stored path that leads outside its root never passes on its spelling.
10
+ * - `vouched` (a path a request names): only a path whose identity is known, or one that is exactly a saved identity.
11
+ * Anything else is refused this time and resolved by the next background pass, never resolved inline.
12
+ * - `resolveNow` (an action that needs a path checked now, a POST): resolved off the thread under the look's deadline.
13
+ * - `refresh` (the console's start and its background pass): resolves what was asked about and what has gone stale.
14
+ *
15
+ * An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
16
+ */
17
+ import { resolve } from "node:path";
18
+ import { lookIdentities } from "./folder-look.js";
19
+ import { canonicalProject } from "./project.js";
20
+ /** How long an answer stands before the background pass resolves the path again. */
21
+ const STALE_MS = 60_000;
22
+ export function projectIdentities(look = {}, now = Date.now) {
23
+ const answers = new Map();
24
+ const wanted = new Set();
25
+ const known = (path) => answers.get(resolve(path))?.real;
26
+ const store = (found) => {
27
+ const at = now();
28
+ for (const [path, real] of found) {
29
+ answers.set(resolve(path), { real, at });
30
+ wanted.delete(resolve(path));
31
+ // A real path is its own identity.
32
+ if (real !== null)
33
+ answers.set(real, { real, at });
34
+ }
35
+ };
36
+ const resolveNow = async (path) => {
37
+ const looked = await lookIdentities([resolve(path)], look);
38
+ if (!looked.ok)
39
+ return "no-answer";
40
+ store(looked.value);
41
+ return looked.value.get(resolve(path)) ?? null;
42
+ };
43
+ return {
44
+ hot: path => {
45
+ const key = resolve(path);
46
+ const answer = answers.get(key);
47
+ if (answer === undefined)
48
+ wanted.add(key);
49
+ return answer?.real ?? key;
50
+ },
51
+ known,
52
+ strict: path => {
53
+ const key = resolve(path);
54
+ const answer = answers.get(key);
55
+ if (answer === undefined)
56
+ wanted.add(key);
57
+ return answer?.real ?? null;
58
+ },
59
+ vouched: (path, saved) => {
60
+ const key = resolve(path);
61
+ const answer = answers.get(key);
62
+ if (answer !== undefined && answer.real !== null)
63
+ return answer.real;
64
+ for (const one of saved())
65
+ if (one === key)
66
+ return key;
67
+ wanted.add(key);
68
+ return undefined;
69
+ },
70
+ resolveNow,
71
+ refresh: async (paths) => {
72
+ const at = now();
73
+ const due = new Set(wanted);
74
+ for (const one of paths) {
75
+ const key = resolve(one);
76
+ const answer = answers.get(key);
77
+ if (answer === undefined || at - answer.at >= STALE_MS)
78
+ due.add(key);
79
+ }
80
+ for (const [key, answer] of answers)
81
+ if (at - answer.at >= STALE_MS)
82
+ due.add(key);
83
+ if (due.size === 0)
84
+ return true;
85
+ const batch = await lookIdentities([...due], look);
86
+ if (batch.ok) {
87
+ store(batch.value);
88
+ return true;
89
+ }
90
+ // One folder that doesn't answer mustn't hold the others: each on its own, in parallel (the looks are capped).
91
+ const each = await Promise.all([...due].map(one => resolveNow(one)));
92
+ return each.every(one => one !== "no-answer");
93
+ },
94
+ };
95
+ }
96
+ /** The synchronous reads, as before: for embedders and tests that pass no identities. */
97
+ export function syncProjectIdentities() {
98
+ return {
99
+ hot: path => canonicalProject(path) ?? path,
100
+ known: path => canonicalProject(path),
101
+ strict: path => canonicalProject(path),
102
+ vouched: path => canonicalProject(path) ?? undefined,
103
+ resolveNow: async (path) => canonicalProject(path),
104
+ refresh: async () => true,
105
+ };
106
+ }
@@ -22,19 +22,17 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
22
22
  export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
- title?: never;
26
- content?: never;
27
25
  history?: never;
28
26
  revision: number;
29
27
  stale: boolean;
30
28
  notice: string;
31
29
  decision?: never;
30
+ title?: never;
31
+ content?: never;
32
32
  instructions?: never;
33
33
  references?: never;
34
34
  decisions?: never;
35
35
  } | {
36
- title?: never;
37
- content?: never;
38
36
  history?: never;
39
37
  stale?: never;
40
38
  decision: {
@@ -52,6 +50,8 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
52
50
  id: number;
53
51
  };
54
52
  notice: string;
53
+ title?: never;
54
+ content?: never;
55
55
  revision?: never;
56
56
  instructions?: never;
57
57
  references?: never;
@@ -68,10 +68,10 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
68
68
  references?: never;
69
69
  decisions?: never;
70
70
  } | {
71
- title?: never;
72
- content?: never;
73
71
  stale?: never;
74
72
  decision?: never;
73
+ title?: never;
74
+ content?: never;
75
75
  revision: number;
76
76
  history: {
77
77
  revision: number;
@@ -103,7 +103,7 @@ export type FoundTool = {
103
103
  * Codex's (from `codex mcp list --json`, passed in). The first of each
104
104
  * name wins; unusable entries are left out.
105
105
  */
106
- export declare function discoverTools(repo: string, codexServers: unknown[] | null, home?: string, environment?: NodeJS.ProcessEnv): FoundTool[];
106
+ export declare function discoverTools(repo: string, codexServers: unknown[] | null, home?: string, environment?: NodeJS.ProcessEnv, projectConfig?: Record<string, unknown> | null): FoundTool[];
107
107
  /** Start the server once, ask for its tools, and stop it: the proof a build can use it. */
108
108
  export declare function testTool(spec: ToolSpec, values: Record<string, string>, options: {
109
109
  timeoutMs?: number;
@@ -313,3 +313,6 @@ export declare function toolServerFor(store: ToolServerStore, repo: string, opti
313
313
  home?: string;
314
314
  available?: () => boolean;
315
315
  }): ToolServer;
316
+ /** Each project's `.mcp.json` as the console last read it off its thread (its background pass): what its pages and the
317
+ * lead offer, so neither reads a project folder on the console's thread. */
318
+ export declare const savedProjectMcp: Map<string, Record<string, unknown> | null>;
@@ -496,7 +496,7 @@ function fromCodexEntry(raw, environment) {
496
496
  * Codex's (from `codex mcp list --json`, passed in). The first of each
497
497
  * name wins; unusable entries are left out.
498
498
  */
499
- export function discoverTools(repo, codexServers, home = homedir(), environment = process.env) {
499
+ export function discoverTools(repo, codexServers, home = homedir(), environment = process.env, projectConfig) {
500
500
  const found = [];
501
501
  const add = (one) => { if (one !== null && !found.some(other => other.spec.name === one.spec.name))
502
502
  found.push(one); };
@@ -509,7 +509,9 @@ export function discoverTools(repo, codexServers, home = homedir(), environment
509
509
  return null;
510
510
  }
511
511
  };
512
- const project = readJson(join(repo, ".mcp.json"));
512
+ // The project's own file as given (the console reads it off its thread: folder-look.ts), else as last read there
513
+ // (savedProjectMcp), else read here (the CLI and the worker).
514
+ const project = projectConfig !== undefined ? projectConfig : savedProjectMcp.has(repo) ? savedProjectMcp.get(repo) ?? null : readJson(join(repo, ".mcp.json"));
513
515
  for (const [name, raw] of Object.entries((project?.["mcpServers"] ?? {})))
514
516
  add(fromClaudeEntry(name, raw, "this project's .mcp.json"));
515
517
  const claude = readJson(join(home, ".claude.json"));
@@ -1149,3 +1151,6 @@ export function toolServerFor(store, repo, options = {}) {
1149
1151
  const untrusted = existsSync(cache) ? [realpathSync(cache)] : [];
1150
1152
  return { fence: fenceFor("tool-server", { databaseFile, worktree: null, home, boundary: null, binding: null, runFolders: runFolders(null), poolRoots, extra: untrusted }) };
1151
1153
  }
1154
+ /** Each project's `.mcp.json` as the console last read it off its thread (its background pass): what its pages and the
1155
+ * lead offer, so neither reads a project folder on the console's thread. */
1156
+ export const savedProjectMcp = new Map();
package/dist/project.d.ts CHANGED
@@ -29,6 +29,9 @@ export type ProjectCeiling = {
29
29
  repos: readonly string[];
30
30
  /** Canonical directories: any git repo under one is authorized. */
31
31
  roots: readonly string[];
32
+ /** How this ceiling resolves a path it is asked about (project-identity.ts): the console's saved identities, which
33
+ * read no folder. Absent: canonicalProject, a synchronous read. */
34
+ canon?: (path: string) => string | null;
32
35
  };
33
36
  /**
34
37
  * Resolve the startup configuration once. A configured path that does not
@@ -36,7 +39,7 @@ export type ProjectCeiling = {
36
39
  * dropping would silently *widen* the server toward unscoped mode, and a
37
40
  * ceiling that loosens itself when a disk is unmounted is not a ceiling.
38
41
  */
39
- export declare function resolveCeiling(repos: readonly string[], roots: readonly string[]): {
42
+ export declare function resolveCeiling(repos: readonly string[], roots: readonly string[], canon?: (path: string) => string | null | undefined): {
40
43
  ceiling: ProjectCeiling;
41
44
  unresolved: string[];
42
45
  };
package/dist/project.js CHANGED
@@ -67,10 +67,10 @@ export async function projectSelection(paths, exec = run) {
67
67
  * dropping would silently *widen* the server toward unscoped mode, and a
68
68
  * ceiling that loosens itself when a disk is unmounted is not a ceiling.
69
69
  */
70
- export function resolveCeiling(repos, roots) {
70
+ export function resolveCeiling(repos, roots, canon = canonicalProject) {
71
71
  const unresolved = [];
72
72
  const keep = (paths) => [...new Set(paths.map(path => {
73
- const canonical = canonicalProject(path);
73
+ const canonical = canon(path) ?? null;
74
74
  if (canonical !== null)
75
75
  return canonical;
76
76
  unresolved.push(path);
@@ -90,7 +90,7 @@ export function unscoped(ceiling) {
90
90
  export async function authorizedProject(ceiling, path, exec = run) {
91
91
  if (unscoped(ceiling))
92
92
  return true;
93
- const canonical = canonicalProject(path);
93
+ const canonical = (ceiling.canon ?? canonicalProject)(path);
94
94
  if (canonical === null)
95
95
  return false;
96
96
  if (ceiling.repos.includes(canonical))
@@ -114,8 +114,15 @@ export function rowVisible(ceiling, repo) {
114
114
  return true;
115
115
  // Exact saved identities are common. Check the whole set before resolving
116
116
  // aliases, so the hundredth project does not stat the other99 directories.
117
- if (ceiling.repos.includes(repo) || ceiling.repos.some(one => sameRepo(one, repo)))
117
+ const canon = ceiling.canon;
118
+ if (ceiling.repos.includes(repo) || ceiling.repos.some(one => canon === undefined ? sameRepo(one, repo) : (canon(one) ?? null) !== null && canon(one) === canon(repo)))
118
119
  return true;
120
+ // With a resolver (the console's saved identities), an identity it can't vouch for fails closed before any root is
121
+ // matched: a path's spelling never stands in for where it leads.
122
+ if (canon !== undefined) {
123
+ const resolved = canon(repo);
124
+ return resolved !== null && ceiling.roots.some(root => resolved.startsWith(root + sep));
125
+ }
119
126
  const canonical = canonicalProject(repo) ?? repo;
120
127
  return ceiling.roots.some(root => canonical.startsWith(root + sep));
121
128
  }
package/dist/repos.d.ts CHANGED
@@ -57,4 +57,11 @@ export declare function updateProjectRegistry(file: string, transform: (registry
57
57
  } | RegistryUpdateFailure>;
58
58
  export declare function addRepos(existing: readonly string[], incoming: readonly string[]): string[];
59
59
  export declare function removeRepos(existing: readonly string[], targets: readonly string[]): string[];
60
+ /** Beside the saved list: how many times each line has been removed from it (written under its lock). */
61
+ export declare function registryRemovalsFile(file: string): string;
62
+ /**
63
+ * How many times each saved line has been removed, as recorded under the saved list's lock. A file that can't be read
64
+ * as one answers null: its caller treats every line as changed (fail closed).
65
+ */
66
+ export declare function registryRemovals(file: string): Record<string, number> | null;
60
67
  export {};