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
@@ -0,0 +1,44 @@
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, and
6
+ * never waits for one before it answers:
7
+ *
8
+ * - `hot` (display and grouping only): the last answer, else the path as saved. Nothing is started by asking.
9
+ * - `strict` (visibility and authorization): only a resolved identity; an unknown one fails closed until the next
10
+ * refresh resolves it, so a stored path that leads outside its root never passes on its spelling.
11
+ * - `vouched` (a path a request names): only a path whose identity is known, or one that is exactly a saved identity.
12
+ * Anything else is refused this time and resolved by the next background pass, never resolved inline.
13
+ * - `resolveNow` (an action that needs a path checked now, a POST): resolved off the thread under the look's deadline.
14
+ * - `refresh` (the console's background pass, the first started right after it listens): resolves what was asked about
15
+ * and what has gone stale. The console answers before any of it does; until a path answers it fails closed.
16
+ *
17
+ * An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
18
+ */
19
+ import { type FolderLookOptions } from "./folder-look.js";
20
+ export type ProjectIdentities = {
21
+ /** For display and grouping only: the last known identity, else the path as saved (resolved lexically). Reads
22
+ * nothing. Never an authorization answer: use `strict`. */
23
+ hot: (path: string) => string;
24
+ /** For authorization: the known real folder path, else null (unknown or not a folder: fail closed until resolved).
25
+ * Reads nothing; an unknown path is asked about on the next refresh. */
26
+ strict: (path: string) => string | null;
27
+ /** The known identity: a real folder path, null (not a folder), or undefined (never answered). Reads nothing. */
28
+ known: (path: string) => string | null | undefined;
29
+ /** A request's path: its known identity, or the path itself when it is exactly one of `saved` (projects the plane
30
+ * itself proved and admitted); else undefined. */
31
+ vouched: (path: string, saved: () => Iterable<string>) => string | undefined;
32
+ /** This path's identity now, off the thread: a real folder path, null, or "no-answer" past the deadline. */
33
+ resolveNow: (path: string) => Promise<string | null | "no-answer">;
34
+ /** Resolve `paths`, everything asked about since, and answers older than a minute. False when some didn't answer. */
35
+ refresh: (paths: Iterable<string>) => Promise<boolean>;
36
+ /** Moves whenever a known identity changes or goes: a decision made before an await is re-checked against it right
37
+ * before its write. */
38
+ generation: () => number;
39
+ };
40
+ /** How many folders a pass looks at side by side when they're looked at one by one (within FOLDER_LOOK_CHILDREN). */
41
+ export declare const IDENTITY_LOOKS = 4;
42
+ export declare function projectIdentities(look?: FolderLookOptions, now?: () => number): ProjectIdentities;
43
+ /** The synchronous reads, as before: for embedders and tests that pass no identities. */
44
+ export declare function syncProjectIdentities(): ProjectIdentities;
@@ -0,0 +1,153 @@
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, and
6
+ * never waits for one before it answers:
7
+ *
8
+ * - `hot` (display and grouping only): the last answer, else the path as saved. Nothing is started by asking.
9
+ * - `strict` (visibility and authorization): only a resolved identity; an unknown one fails closed until the next
10
+ * refresh resolves it, so a stored path that leads outside its root never passes on its spelling.
11
+ * - `vouched` (a path a request names): only a path whose identity is known, or one that is exactly a saved identity.
12
+ * Anything else is refused this time and resolved by the next background pass, never resolved inline.
13
+ * - `resolveNow` (an action that needs a path checked now, a POST): resolved off the thread under the look's deadline.
14
+ * - `refresh` (the console's background pass, the first started right after it listens): resolves what was asked about
15
+ * and what has gone stale. The console answers before any of it does; until a path answers it fails closed.
16
+ *
17
+ * An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
18
+ */
19
+ import { resolve } from "node:path";
20
+ import { lookIdentities } from "./folder-look.js";
21
+ import { canonicalProject } from "./project.js";
22
+ /** How long an answer stands before the background pass resolves the path again. */
23
+ const STALE_MS = 60_000;
24
+ /** How many folders a pass looks at side by side when they're looked at one by one (within FOLDER_LOOK_CHILDREN). */
25
+ export const IDENTITY_LOOKS = 4;
26
+ export function projectIdentities(look = {}, now = Date.now) {
27
+ /** Each answer with when the look that answered it started (folder-look.ts `started`, kept across joined looks): an
28
+ * answer from a look started earlier never overwrites one from a look started later. */
29
+ const answers = new Map();
30
+ const wanted = new Set();
31
+ /** Paths whose last look didn't answer: looked at only on their own (joining a look still under way), never in a
32
+ * batch, so one folder that never answers holds one child, not one per differently made-up batch. */
33
+ const suspect = new Set();
34
+ let generation = 0;
35
+ const known = (path) => answers.get(resolve(path))?.real;
36
+ const put = (key, real, at, seq) => {
37
+ const before = answers.get(key);
38
+ // A known identity that changes (or goes) moves authority: anything decided on the old one is out of date.
39
+ if (before !== undefined && before.real !== real)
40
+ generation++;
41
+ answers.set(key, { real, at, seq });
42
+ };
43
+ const store = (found, seq) => {
44
+ const at = now();
45
+ for (const [path, real] of found) {
46
+ const key = resolve(path);
47
+ // Superseded: a later lookup already answered for this path. Its real path's entry goes with it.
48
+ if ((answers.get(key)?.seq ?? -1) > seq)
49
+ continue;
50
+ put(key, real, at, seq);
51
+ wanted.delete(key);
52
+ // A real path is its own identity.
53
+ if (real !== null && (answers.get(real)?.seq ?? -1) <= seq)
54
+ put(real, real, at, seq);
55
+ }
56
+ };
57
+ const resolveNow = async (path) => {
58
+ const key = resolve(path);
59
+ const looked = await lookIdentities([key], look);
60
+ if (!looked.ok) {
61
+ suspect.add(key);
62
+ return "no-answer";
63
+ }
64
+ suspect.delete(key);
65
+ // No start known: older than every answer, so it only fills what nothing answered yet.
66
+ store(looked.value, looked.started ?? 0);
67
+ return answers.get(key)?.real ?? null;
68
+ };
69
+ const refreshNow = async (paths) => {
70
+ const at = now();
71
+ const due = new Set(wanted);
72
+ for (const one of paths) {
73
+ const key = resolve(one);
74
+ const answer = answers.get(key);
75
+ if (answer === undefined || at - answer.at >= STALE_MS)
76
+ due.add(key);
77
+ }
78
+ for (const [key, answer] of answers)
79
+ if (at - answer.at >= STALE_MS)
80
+ due.add(key);
81
+ if (due.size === 0)
82
+ return true;
83
+ const alone = [...due].filter(one => suspect.has(one));
84
+ const batched = [...due].filter(one => !suspect.has(one));
85
+ if (batched.length > 0) {
86
+ const batch = await lookIdentities(batched, look);
87
+ if (batch.ok)
88
+ store(batch.value, batch.started ?? 0);
89
+ else
90
+ alone.push(...batched);
91
+ }
92
+ // One folder that doesn't answer mustn't hold the others: each on its own, a few at a time (inside the looks'
93
+ // child cap, leaving room for a person's actions), each under its deadline, each stored as soon as it answers.
94
+ let missed = false;
95
+ const worker = async () => {
96
+ for (let one = alone.shift(); one !== undefined; one = alone.shift())
97
+ if (await resolveNow(one) === "no-answer")
98
+ missed = true;
99
+ };
100
+ await Promise.all(Array.from({ length: Math.min(IDENTITY_LOOKS, alone.length) }, worker));
101
+ return !missed;
102
+ };
103
+ let passes = Promise.resolve();
104
+ return {
105
+ hot: path => {
106
+ const key = resolve(path);
107
+ const answer = answers.get(key);
108
+ if (answer === undefined)
109
+ wanted.add(key);
110
+ return answer?.real ?? key;
111
+ },
112
+ known,
113
+ strict: path => {
114
+ const key = resolve(path);
115
+ const answer = answers.get(key);
116
+ if (answer === undefined)
117
+ wanted.add(key);
118
+ return answer?.real ?? null;
119
+ },
120
+ vouched: (path, saved) => {
121
+ const key = resolve(path);
122
+ const answer = answers.get(key);
123
+ if (answer !== undefined && answer.real !== null)
124
+ return answer.real;
125
+ for (const one of saved())
126
+ if (one === key)
127
+ return key;
128
+ wanted.add(key);
129
+ return undefined;
130
+ },
131
+ resolveNow,
132
+ generation: () => generation,
133
+ refresh: paths => {
134
+ // One pass at a time: a pass asked for while another runs waits for it, then resolves only what is still due.
135
+ const asked = [...paths];
136
+ const pass = passes.then(() => refreshNow(asked));
137
+ passes = pass.then(() => undefined, () => undefined);
138
+ return pass;
139
+ },
140
+ };
141
+ }
142
+ /** The synchronous reads, as before: for embedders and tests that pass no identities. */
143
+ export function syncProjectIdentities() {
144
+ return {
145
+ hot: path => canonicalProject(path) ?? path,
146
+ known: path => canonicalProject(path),
147
+ strict: path => canonicalProject(path),
148
+ vouched: path => canonicalProject(path) ?? undefined,
149
+ resolveNow: async (path) => canonicalProject(path),
150
+ generation: () => 0,
151
+ refresh: async () => true,
152
+ };
153
+ }
@@ -23,19 +23,17 @@ export declare function knowledgeVersion(store: Store, repo: string, actor: stri
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
25
  title?: never;
26
- content?: never;
27
- history?: never;
28
26
  revision: number;
29
27
  stale: boolean;
30
28
  notice: string;
31
29
  decision?: never;
30
+ content?: never;
31
+ history?: never;
32
32
  instructions?: never;
33
33
  references?: never;
34
34
  decisions?: never;
35
35
  } | {
36
36
  title?: never;
37
- content?: never;
38
- history?: never;
39
37
  stale?: never;
40
38
  decision: {
41
39
  repo: string;
@@ -52,26 +50,28 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
52
50
  id: number;
53
51
  };
54
52
  notice: string;
53
+ content?: never;
55
54
  revision?: never;
55
+ history?: never;
56
56
  instructions?: never;
57
57
  references?: never;
58
58
  decisions?: never;
59
59
  } | {
60
- history?: never;
61
60
  stale?: never;
62
61
  decision?: never;
63
62
  revision: number;
64
63
  title: string;
65
64
  content: string;
66
65
  notice: string;
66
+ history?: never;
67
67
  instructions?: never;
68
68
  references?: never;
69
69
  decisions?: never;
70
70
  } | {
71
71
  title?: never;
72
- content?: never;
73
72
  stale?: never;
74
73
  decision?: 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 {};
package/dist/repos.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * to edit it by hand or check it into their dotfiles — which also means it is
11
11
  * untrusted input and gets validated like any other.
12
12
  */
13
+ import { closeSync as closeFd, constants as fsConstants, fstatSync, openSync as openFd, readSync, renameSync as renameFile, writeFileSync } from "node:fs";
13
14
  import { mkdir, readFile, writeFile } from "node:fs/promises";
14
15
  import { dirname } from "node:path";
15
16
  import { configBase, namedPath } from "./names.js";
@@ -213,6 +214,11 @@ export async function updateProjectRegistry(file, transform) {
213
214
  held = false; // abandon: no unlink, no publication
214
215
  return { ok: false, reason: "abandoned", message: "the registry lock aged out before the update could publish — nothing changed; try again" };
215
216
  }
217
+ // Each line this update removes counts one more removal, written BEFORE the list is published: whoever holds a
218
+ // start's authority for a saved line (upDeferredRetry) sees that it was removed, even if it was saved again since.
219
+ const gone = current.repos.filter(one => !next.repos.includes(one));
220
+ if (gone.length > 0)
221
+ bumpRegistryRemovals(file, gone, `${process.pid}.${token.slice(0, 8)}`);
216
222
  const temp = `${file}.${process.pid}.${token.slice(0, 8)}.tmp`;
217
223
  const fd = openSync(temp, "wx");
218
224
  writeSync(fd, `${JSON.stringify({ version: CONFIG_VERSION, ...next }, null, 2)}
@@ -267,3 +273,54 @@ function readProjectRegistry(parsed) {
267
273
  function sortUnique(repos) {
268
274
  return [...new Set(repos)].sort();
269
275
  }
276
+ /** Beside the saved list: how many times each line has been removed from it (written under its lock). */
277
+ export function registryRemovalsFile(file) {
278
+ return `${file}.removals.json`;
279
+ }
280
+ /**
281
+ * How many times each saved line has been removed, as recorded under the saved list's lock. A file that can't be read
282
+ * as one answers null: its caller treats every line as changed (fail closed).
283
+ */
284
+ export function registryRemovals(file) {
285
+ let fd;
286
+ try {
287
+ fd = openFd(registryRemovalsFile(file), fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
288
+ }
289
+ catch (error) {
290
+ return error.code === "ENOENT" ? {} : null;
291
+ }
292
+ try {
293
+ const held = fstatSync(fd);
294
+ if (!held.isFile() || held.size > 4 * 1024 * 1024)
295
+ return null;
296
+ const buffer = Buffer.alloc(held.size);
297
+ const read = held.size === 0 ? 0 : readSync(fd, buffer, 0, buffer.length, 0);
298
+ const parsed = JSON.parse(buffer.toString("utf8", 0, read));
299
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
300
+ return null;
301
+ const out = {};
302
+ for (const [line, count] of Object.entries(parsed)) {
303
+ if (!Number.isSafeInteger(count) || count < 0)
304
+ return null;
305
+ out[line] = count;
306
+ }
307
+ return out;
308
+ }
309
+ catch {
310
+ return null;
311
+ }
312
+ finally {
313
+ closeFd(fd);
314
+ }
315
+ }
316
+ /** One more removal for each of `lines`, written whole and renamed into place. Called only under the list's lock. */
317
+ function bumpRegistryRemovals(file, lines, tag) {
318
+ const kept = registryRemovals(file);
319
+ // An unreadable record starts again from the clock, so no count read before can come round again.
320
+ const counts = kept ?? {};
321
+ for (const line of lines)
322
+ counts[line] = kept === null ? Date.now() : (counts[line] ?? 0) + 1;
323
+ const temp = `${registryRemovalsFile(file)}.${tag}.tmp`;
324
+ writeFileSync(temp, `${JSON.stringify(counts)}\n`, { flag: "wx", mode: 0o600 });
325
+ renameFile(temp, registryRemovalsFile(file));
326
+ }