@gajae-code/coding-agent 0.15.5 → 0.15.6

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 (155) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  4. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  5. package/dist/types/cli/update-cli.d.ts +2 -0
  6. package/dist/types/commands/auth-gateway.d.ts +3 -0
  7. package/dist/types/commands/gc.d.ts +13 -0
  8. package/dist/types/commands/model-presets.d.ts +21 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +96 -0
  10. package/dist/types/config/file-lock.d.ts +49 -2
  11. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  12. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  13. package/dist/types/config/keybindings.d.ts +3 -2
  14. package/dist/types/config/model-preset-registry.d.ts +368 -0
  15. package/dist/types/config/model-profile-activation.d.ts +39 -0
  16. package/dist/types/config/model-profiles.d.ts +5 -3
  17. package/dist/types/config/model-registry.d.ts +11 -5
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  20. package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  22. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  23. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  25. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  26. package/dist/types/index.d.ts +1 -0
  27. package/dist/types/main.d.ts +3 -1
  28. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  29. package/dist/types/modes/jobs-observer.d.ts +36 -0
  30. package/dist/types/sdk/broker/broker.d.ts +3 -0
  31. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  32. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  33. package/dist/types/sdk/client/discovery.d.ts +1 -0
  34. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  35. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  36. package/dist/types/sdk/host/session-runtime.d.ts +15 -1
  37. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  38. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  39. package/dist/types/sdk/router/session-router.d.ts +9 -0
  40. package/dist/types/session/account-inventory.d.ts +1 -0
  41. package/dist/types/session/agent-session.d.ts +19 -7
  42. package/dist/types/session/fold-coordinator.d.ts +201 -0
  43. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  44. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  45. package/dist/types/telemetry/control.d.ts +11 -0
  46. package/dist/types/telemetry/events.d.ts +20 -0
  47. package/dist/types/telemetry/index.d.ts +3 -0
  48. package/dist/types/telemetry/transport.d.ts +22 -0
  49. package/dist/types/tools/bash-interactive.d.ts +23 -0
  50. package/dist/types/tools/index.d.ts +6 -5
  51. package/dist/types/web/search/providers/base.d.ts +9 -4
  52. package/dist/types/web/search/providers/xai.d.ts +2 -1
  53. package/dist/types/web/search/types.d.ts +14 -0
  54. package/package.json +10 -7
  55. package/scripts/generate-hotkeys-docs.ts +4 -0
  56. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  57. package/src/async/job-manager.ts +671 -39
  58. package/src/cli/auth-broker-cli.ts +40 -12
  59. package/src/cli/auth-gateway-cli.ts +273 -97
  60. package/src/cli/config-cli.ts +2 -2
  61. package/src/cli/fast-help.ts +3 -0
  62. package/src/cli/model-presets-cli.ts +100 -0
  63. package/src/cli/update-cli.ts +40 -5
  64. package/src/cli.ts +6 -2
  65. package/src/commands/auth-gateway.ts +12 -8
  66. package/src/commands/gc.ts +19 -0
  67. package/src/commands/model-presets.ts +24 -0
  68. package/src/config/autorouting-tier-map.ts +32 -0
  69. package/src/config/file-lock-gc.ts +9 -4
  70. package/src/config/file-lock.ts +775 -59
  71. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  72. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  73. package/src/config/keybindings.ts +6 -2
  74. package/src/config/model-preset-registry.ts +2799 -0
  75. package/src/config/model-profile-activation.ts +115 -28
  76. package/src/config/model-profile-contract.ts +4 -1
  77. package/src/config/model-profiles.ts +51 -9
  78. package/src/config/model-registry.ts +1021 -122
  79. package/src/config/settings-schema.ts +11 -0
  80. package/src/config/settings.ts +6 -1
  81. package/src/coordinator-mcp/model-preset.ts +32 -17
  82. package/src/coordinator-mcp/projection-scan.ts +571 -0
  83. package/src/coordinator-mcp/server.ts +19 -24
  84. package/src/coordinator-mcp/session-reaper.ts +9 -3
  85. package/src/extensibility/extensions/loader.ts +0 -1
  86. package/src/extensibility/extensions/types.ts +2 -3
  87. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  88. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  89. package/src/gjc-runtime/gc-render.ts +29 -0
  90. package/src/gjc-runtime/gc-runtime.ts +174 -11
  91. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  92. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  93. package/src/index.ts +1 -0
  94. package/src/internal-urls/docs-index.generated.ts +9 -8
  95. package/src/lsp/config.ts +4 -3
  96. package/src/lsp/path-trust.ts +12 -6
  97. package/src/main.ts +9 -2
  98. package/src/modes/acp/acp-agent.ts +11 -5
  99. package/src/modes/components/jobs-overlay-model.ts +99 -16
  100. package/src/modes/components/model-selector.ts +158 -18
  101. package/src/modes/components/status-line/segments.ts +6 -1
  102. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  103. package/src/modes/controllers/input-controller.ts +23 -6
  104. package/src/modes/interactive-mode.ts +14 -1
  105. package/src/modes/jobs-observer.ts +149 -16
  106. package/src/modes/runtime-init.ts +1 -1
  107. package/src/modes/utils/injected-user-submission.ts +15 -1
  108. package/src/prompts/tools/bash.md +2 -2
  109. package/src/prompts/tools/fold-receipt.md +3 -0
  110. package/src/prompts/tools/job.md +1 -1
  111. package/src/sdk/acp/adapter.ts +17 -2
  112. package/src/sdk/broker/broker.ts +48 -21
  113. package/src/sdk/broker/lifecycle.ts +32 -6
  114. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  115. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  116. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  117. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  118. package/src/sdk/bus/slack-conversation.ts +5 -1
  119. package/src/sdk/cli/session-cli.ts +12 -1
  120. package/src/sdk/client/discovery.ts +3 -1
  121. package/src/sdk/host/control/dispatch.ts +3 -0
  122. package/src/sdk/host/model-pin.ts +185 -25
  123. package/src/sdk/host/sdk-run-capability.ts +1 -0
  124. package/src/sdk/host/session-runtime.test.ts +371 -37
  125. package/src/sdk/host/session-runtime.ts +364 -52
  126. package/src/sdk/prompt-deadline-manager.ts +16 -1
  127. package/src/sdk/protocol/adapter-validation.ts +27 -0
  128. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  129. package/src/sdk/protocol/operation-registry.ts +4 -1
  130. package/src/sdk/router/session-router.ts +228 -96
  131. package/src/sdk/session.ts +83 -12
  132. package/src/session/account-inventory.ts +27 -11
  133. package/src/session/agent-session.ts +678 -103
  134. package/src/session/fold-coordinator.ts +433 -0
  135. package/src/session/sdk-run-capability-internal.ts +15 -0
  136. package/src/session/sdk-run-capability.ts +2 -0
  137. package/src/session/session-manager.ts +5 -4
  138. package/src/setup/provider-presets.json +1 -1
  139. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  140. package/src/task/executor.ts +19 -3
  141. package/src/telemetry/control.ts +26 -0
  142. package/src/telemetry/events.ts +142 -0
  143. package/src/telemetry/index.ts +3 -0
  144. package/src/telemetry/transport.ts +87 -0
  145. package/src/tools/bash-interactive.ts +182 -85
  146. package/src/tools/bash.ts +772 -67
  147. package/src/tools/index.ts +7 -5
  148. package/src/tools/tool-catalog.generated.ts +1 -1
  149. package/src/web/search/provider.ts +120 -11
  150. package/src/web/search/providers/anthropic.ts +89 -17
  151. package/src/web/search/providers/base.ts +9 -4
  152. package/src/web/search/providers/gemini.ts +87 -15
  153. package/src/web/search/providers/openai-compatible.ts +23 -8
  154. package/src/web/search/providers/xai.ts +98 -12
  155. package/src/web/search/types.ts +16 -0
@@ -1720,6 +1720,17 @@ export const SETTINGS_SCHEMA = {
1720
1720
  },
1721
1721
  },
1722
1722
 
1723
+ "telemetry.enabled": {
1724
+ type: "boolean",
1725
+ default: false,
1726
+ ui: {
1727
+ tab: "interaction",
1728
+ label: "Anonymous Usage Telemetry",
1729
+ description:
1730
+ "Send minimal allowlisted update and adoption events. Disabled by default; no prompts, paths, environment, identity, or arbitrary errors are collected.",
1731
+ },
1732
+ },
1733
+
1723
1734
  "starReminder.enabled": {
1724
1735
  type: "boolean",
1725
1736
  default: true,
@@ -329,7 +329,12 @@ function getByPath(obj: RawSettings, segments: string[]): unknown {
329
329
 
330
330
  const PATH_SCOPED_ARRAY_SETTINGS = new Set<SettingPath>(["enabledModels", "disabledProviders"]);
331
331
  /** Operator-owned settings which must never be workspace-controlled or runtime-overridden. */
332
- const GLOBAL_ONLY_SETTINGS = new Set<SettingPath>(["crashReport.upstream", "crashReport.upstreamDsn", "ui.language"]);
332
+ const GLOBAL_ONLY_SETTINGS = new Set<SettingPath>([
333
+ "crashReport.upstream",
334
+ "crashReport.upstreamDsn",
335
+ "ui.language",
336
+ "telemetry.enabled",
337
+ ]);
333
338
  const LEGACY_THEME_NAME_REPLACEMENTS = {
334
339
  dark: "red-claw",
335
340
  light: "blue-crab",
@@ -1,16 +1,20 @@
1
1
  import * as path from "node:path";
2
2
  import { getAgentDir } from "@gajae-code/utils";
3
- import { YAML } from "bun";
3
+ import { ConfigFile } from "../config/config-file";
4
+ import {
5
+ type AcceptedModelPresetRegistry,
6
+ loadAcceptedModelPresetRegistryAsync,
7
+ } from "../config/model-preset-registry";
4
8
  import { UnknownModelProfileError, validateModelProfileName } from "../config/model-profile-contract";
5
9
  import { mergeModelProfiles } from "../config/model-profiles";
6
- import { ModelsConfigSchema } from "../config/models-config-schema";
10
+ import { type ModelsConfig, ModelsConfigSchema } from "../config/models-config-schema";
7
11
 
8
12
  export interface CoordinatorModelProfile {
9
13
  name: string;
10
14
  }
11
15
 
12
16
  /**
13
- * Loads the merged built-in + custom model-profile names from `models.yml`
17
+ * Loads the merged embedded + accepted registry + custom model-profile names
14
18
  * without loading the session-host model registry. The child still owns
15
19
  * profile activation; this validates the selection before session creation.
16
20
  */
@@ -20,10 +24,11 @@ export type CoordinatorModelProfileLoader = () =>
20
24
 
21
25
  const MAX_ECHOED_MPRESET_LENGTH = 128;
22
26
  /**
23
- * Thrown by the default loader when `models.yml` exists but is invalid or
24
- * unreadable. This lets the resolver fail closed with a distinct, stable reason
25
- * instead of silently collapsing a broken registry to the built-ins-only set
26
- * (which would misreport a caller's valid custom profile as unknown).
27
+ * Thrown by the default loader when `models.yml` or the accepted preset
28
+ * registry is invalid or unreadable. This lets the resolver fail closed with a
29
+ * distinct, stable reason instead of silently collapsing a broken registry to
30
+ * the built-ins-only set (which would misreport a caller's valid custom
31
+ * profile as unknown).
27
32
  */
28
33
  export class CoordinatorModelProfileRegistryError extends Error {
29
34
  constructor(cause?: unknown) {
@@ -34,23 +39,33 @@ export class CoordinatorModelProfileRegistryError extends Error {
34
39
  }
35
40
 
36
41
  function coordinatorModelProfiles(
37
- profiles?: Parameters<typeof mergeModelProfiles>[0],
42
+ profiles?: ModelsConfig["profiles"],
43
+ registryProfiles?: AcceptedModelPresetRegistry["profiles"],
38
44
  ): Map<string, CoordinatorModelProfile> {
39
- return new Map([...mergeModelProfiles(profiles).keys()].map(name => [name, { name }]));
45
+ return new Map([...mergeModelProfiles(profiles, registryProfiles).keys()].map(name => [name, { name }]));
40
46
  }
41
47
 
42
- export const loadCoordinatorModelProfiles: CoordinatorModelProfileLoader = async () => {
43
- const modelsFile = Bun.file(path.join(getAgentDir(), "models.yml"));
44
- if (!(await modelsFile.exists())) return coordinatorModelProfiles();
48
+ export function createCoordinatorModelProfileLoader(agentDir: string): CoordinatorModelProfileLoader {
49
+ return async () => loadCoordinatorModelProfilesFromAgentDir(agentDir);
50
+ }
51
+
52
+ async function loadCoordinatorModelProfilesFromAgentDir(
53
+ agentDir: string,
54
+ ): Promise<Map<string, CoordinatorModelProfile>> {
55
+ const accepted = await loadAcceptedModelPresetRegistryAsync(agentDir);
56
+ if (accepted.error) throw new CoordinatorModelProfileRegistryError(accepted.error);
45
57
  try {
46
- const parsed = YAML.parse(await modelsFile.text());
47
- const config = ModelsConfigSchema.safeParse(parsed);
48
- if (!config.success) throw config.error;
49
- return coordinatorModelProfiles(config.data.profiles);
58
+ const modelsConfig = new ConfigFile("models", ModelsConfigSchema, path.join(agentDir, "models.yml"));
59
+ const loaded = modelsConfig.tryLoad();
60
+ if (loaded.status === "error") throw loaded.error;
61
+ return coordinatorModelProfiles(loaded.value?.profiles, accepted.profiles);
50
62
  } catch (error) {
51
63
  throw new CoordinatorModelProfileRegistryError(error);
52
64
  }
53
- };
65
+ }
66
+
67
+ export const loadCoordinatorModelProfiles: CoordinatorModelProfileLoader = async () =>
68
+ loadCoordinatorModelProfilesFromAgentDir(getAgentDir());
54
69
 
55
70
  export type CoordinatorMpresetResolution =
56
71
  | { ok: true; mpreset: string | null }
@@ -0,0 +1,571 @@
1
+ /**
2
+ * Coordinator directory scans that must not treat native exact-unlink debris
3
+ * as live JSON, and must not make start/status unreadable on a large dirent pile.
4
+ */
5
+ import * as fs from "node:fs/promises";
6
+ import * as path from "node:path";
7
+
8
+ class ProjectionScanRaceError extends Error {
9
+ readonly code = "PROJECTION_SCAN_RACED";
10
+
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = "ProjectionScanRaceError";
14
+ }
15
+ }
16
+
17
+ const PROJECTION_SCAN_UNSUPPORTED_CODE = "coordinator_projection_safe_read_unsupported";
18
+
19
+ class ProjectionScanUnsupportedError extends Error {
20
+ readonly code = PROJECTION_SCAN_UNSUPPORTED_CODE;
21
+
22
+ constructor() {
23
+ super(PROJECTION_SCAN_UNSUPPORTED_CODE);
24
+ this.name = "ProjectionScanUnsupportedError";
25
+ }
26
+ }
27
+
28
+ /** Test-only platform seam; production always follows the host platform. */
29
+ export const ProjectionScanTestHooks: {
30
+ platform?: NodeJS.Platform;
31
+ } = {};
32
+
33
+ function projectionPlatform(): NodeJS.Platform {
34
+ return ProjectionScanTestHooks.platform ?? process.platform;
35
+ }
36
+
37
+ /** Post-filter parse-candidate cap. Exhaustion returns an explicit incomplete result. */
38
+ export const COORDINATOR_JSON_SCAN_CAP = 10_000;
39
+
40
+ export interface ProjectionScanStat {
41
+ size: number | bigint;
42
+ dev?: number | bigint;
43
+ ino?: number | bigint;
44
+ nlink?: number | bigint;
45
+ mtimeNs?: number | bigint;
46
+ ctimeNs?: number | bigint;
47
+ isDirectory?(): boolean;
48
+ isFile(): boolean;
49
+ isSymbolicLink(): boolean;
50
+ }
51
+
52
+ export interface ProjectionScanFs {
53
+ readdir(dir: string): Promise<string[]>;
54
+ lstat(file: string): Promise<ProjectionScanStat>;
55
+ readFile(file: string, encoding: "utf8", expected?: ProjectionScanStat): Promise<string>;
56
+ /** Optional descriptor-bound reader used by the production filesystem. */
57
+ readFileSafe?: (file: string, encoding: "utf8", expected?: ProjectionScanStat) => Promise<string>;
58
+ /** Optional pinned directory authority used for enumeration and every child operation. */
59
+ openDirectory?: (dir: string) => Promise<ProjectionScanDirectory>;
60
+ }
61
+
62
+ export interface ProjectionScanDirectory {
63
+ readonly stat: ProjectionScanStat;
64
+ readdir(): Promise<string[]>;
65
+ lstat(entry: string): Promise<ProjectionScanStat>;
66
+ readFile(entry: string, encoding: "utf8", expected?: ProjectionScanStat): Promise<string>;
67
+ close(): Promise<void>;
68
+ }
69
+
70
+ export interface ProjectionScanResult {
71
+ values: unknown[];
72
+ parsed: number;
73
+ capped: boolean;
74
+ skippedDebris: number;
75
+ skippedEmpty: number;
76
+ /** Candidates that changed or disappeared after enumeration. */
77
+ raced: number;
78
+ /** True when the scan cannot be authoritative for its caller. */
79
+ incomplete: boolean;
80
+ }
81
+
82
+ /**
83
+ * Read one discovered candidate without ever following a final-component symlink or
84
+ * blocking on a FIFO. POSIX opens are descriptor-bound by O_NOFOLLOW/O_NONBLOCK. Windows
85
+ * has neither flag, so the path is bracketed by lstat/fstat/lstat identity checks and the
86
+ * bytes still come from the opened handle rather than the mutable pathname.
87
+ */
88
+ function sameProjectionFile(left: ProjectionScanStat, right: ProjectionScanStat): boolean {
89
+ if (!left.isFile() || !right.isFile()) return false;
90
+ if (
91
+ left.dev === undefined ||
92
+ right.dev === undefined ||
93
+ left.ino === undefined ||
94
+ right.ino === undefined ||
95
+ String(left.dev) !== String(right.dev) ||
96
+ String(left.ino) !== String(right.ino) ||
97
+ String(left.size) !== String(right.size)
98
+ )
99
+ return false;
100
+ if (left.nlink !== undefined && right.nlink !== undefined && String(left.nlink) !== String(right.nlink))
101
+ return false;
102
+ if (left.mtimeNs !== undefined && right.mtimeNs !== undefined && String(left.mtimeNs) !== String(right.mtimeNs))
103
+ return false;
104
+ if (left.ctimeNs !== undefined && right.ctimeNs !== undefined && String(left.ctimeNs) !== String(right.ctimeNs))
105
+ return false;
106
+ return true;
107
+ }
108
+
109
+ async function readProjectionFileSafe(file: string, encoding: "utf8", expected?: ProjectionScanStat): Promise<string> {
110
+ if (encoding !== "utf8") throw new TypeError("Coordinator projection reads require utf8.");
111
+ const platform = projectionPlatform();
112
+ const noFollow = fs.constants.O_NOFOLLOW;
113
+ const nonBlock = fs.constants.O_NONBLOCK;
114
+ if (platform !== "win32" && (typeof noFollow !== "number" || typeof nonBlock !== "number"))
115
+ throw new ProjectionScanUnsupportedError();
116
+ const flags = fs.constants.O_RDONLY | (platform === "win32" ? 0 : (nonBlock as number) | (noFollow as number));
117
+ let before: import("node:fs").BigIntStats | undefined;
118
+ if (platform === "win32") {
119
+ before = await fs.lstat(file, { bigint: true });
120
+ if (before.isSymbolicLink() || !before.isFile())
121
+ throw new ProjectionScanRaceError("candidate is not a regular file");
122
+ }
123
+ let handle: fs.FileHandle | undefined;
124
+ try {
125
+ handle = await fs.open(file, flags);
126
+ const opened = await handle.stat({ bigint: true });
127
+ if (!opened.isFile()) throw new ProjectionScanRaceError("candidate changed to a non-regular file");
128
+ if (expected && !sameProjectionFile(expected, opened))
129
+ throw new ProjectionScanRaceError("candidate changed while opening");
130
+ if (before) {
131
+ if (
132
+ before.dev !== opened.dev ||
133
+ before.ino !== opened.ino ||
134
+ before.nlink !== opened.nlink ||
135
+ before.size !== opened.size ||
136
+ before.mtimeNs !== opened.mtimeNs ||
137
+ before.ctimeNs !== opened.ctimeNs
138
+ )
139
+ throw new ProjectionScanRaceError("candidate changed while opening");
140
+ const relinked = await fs.lstat(file, { bigint: true });
141
+ if (
142
+ relinked.isSymbolicLink() ||
143
+ !relinked.isFile() ||
144
+ relinked.dev !== opened.dev ||
145
+ relinked.ino !== opened.ino ||
146
+ relinked.nlink !== opened.nlink ||
147
+ relinked.size !== opened.size ||
148
+ relinked.mtimeNs !== opened.mtimeNs ||
149
+ relinked.ctimeNs !== opened.ctimeNs
150
+ )
151
+ throw new ProjectionScanRaceError("candidate changed before read");
152
+ }
153
+ return await handle.readFile({ encoding: "utf8" });
154
+ } catch (error) {
155
+ if ((error as NodeJS.ErrnoException).code === "ELOOP")
156
+ throw new ProjectionScanRaceError("candidate became a symlink");
157
+ throw error;
158
+ } finally {
159
+ await handle?.close().catch(() => undefined);
160
+ }
161
+ }
162
+
163
+ function sameProjectionRoot(left: ProjectionScanStat, right: ProjectionScanStat): boolean {
164
+ const leftDirectory = left.isDirectory?.();
165
+ const rightDirectory = right.isDirectory?.();
166
+ return (
167
+ leftDirectory !== false &&
168
+ rightDirectory !== false &&
169
+ left.dev !== undefined &&
170
+ right.dev !== undefined &&
171
+ left.ino !== undefined &&
172
+ right.ino !== undefined &&
173
+ String(left.dev) === String(right.dev) &&
174
+ String(left.ino) === String(right.ino)
175
+ );
176
+ }
177
+
178
+ function hasProjectionRootIdentity(stat: ProjectionScanStat): boolean {
179
+ return stat.dev !== undefined && stat.ino !== undefined;
180
+ }
181
+
182
+ function trimProjectionRootPath(dir: string): string {
183
+ const parsedRoot = path.parse(dir).root;
184
+ let trimmed = dir;
185
+ while (trimmed.length > parsedRoot.length && (trimmed.endsWith("/") || trimmed.endsWith("\\")))
186
+ trimmed = trimmed.slice(0, -1);
187
+ while (trimmed.length > parsedRoot.length && (trimmed.endsWith("/.") || trimmed.endsWith("\\."))) {
188
+ trimmed = trimmed.slice(0, -2);
189
+ while (trimmed.length > parsedRoot.length && (trimmed.endsWith("/") || trimmed.endsWith("\\")))
190
+ trimmed = trimmed.slice(0, -1);
191
+ }
192
+ return trimmed || parsedRoot;
193
+ }
194
+
195
+ /**
196
+ * Windows has no descriptor-relative `openat` binding in Node. Keep the root pathname
197
+ * authoritative by checking its identity before and after every mutable-path operation.
198
+ */
199
+ async function openProjectionDirectoryWindowsSafe(dir: string): Promise<ProjectionScanDirectory> {
200
+ const rootPath = trimProjectionRootPath(dir);
201
+ let opened: import("node:fs").BigIntStats;
202
+ try {
203
+ opened = await fs.lstat(rootPath, { bigint: true });
204
+ } catch (error) {
205
+ if ((error as NodeJS.ErrnoException).code === "ELOOP")
206
+ throw new ProjectionScanRaceError("scan root became a reparse point");
207
+ throw error;
208
+ }
209
+ if (opened.isSymbolicLink() || !opened.isDirectory())
210
+ throw new ProjectionScanRaceError("scan root is not a regular directory");
211
+
212
+ const assertRoot = async (): Promise<void> => {
213
+ let settled: import("node:fs").BigIntStats;
214
+ try {
215
+ settled = await fs.lstat(rootPath, { bigint: true });
216
+ } catch (error) {
217
+ const code = (error as NodeJS.ErrnoException).code;
218
+ if (code === "ENOENT" || code === "ELOOP")
219
+ throw new ProjectionScanRaceError("scan root changed during enumeration");
220
+ throw error;
221
+ }
222
+ if (settled.isSymbolicLink() || !settled.isDirectory() || !sameProjectionRoot(opened, settled))
223
+ throw new ProjectionScanRaceError("scan root changed during enumeration");
224
+ };
225
+
226
+ const entryPath = (entry: string): string => {
227
+ // Directory entries come from readdir and are therefore single components on
228
+ // Windows. Keep the invariant explicit so an injected or corrupted entry can
229
+ // never turn a child operation into mutable pathname traversal.
230
+ if (
231
+ entry.length === 0 ||
232
+ entry === "." ||
233
+ entry === ".." ||
234
+ entry.includes("/") ||
235
+ entry.includes("\\") ||
236
+ entry.includes(":")
237
+ )
238
+ throw new ProjectionScanRaceError("scan entry is not a single directory component");
239
+ return path.join(rootPath, entry);
240
+ };
241
+
242
+ const bracketRoot = async <T>(operation: () => Promise<T>): Promise<T> => {
243
+ let value!: T;
244
+ let operationError: unknown;
245
+ let started = false;
246
+ try {
247
+ await assertRoot();
248
+ started = true;
249
+ value = await operation();
250
+ } catch (error) {
251
+ operationError = error;
252
+ }
253
+ if (started) {
254
+ try {
255
+ await assertRoot();
256
+ } catch (error) {
257
+ operationError = error;
258
+ }
259
+ }
260
+ if (operationError !== undefined) {
261
+ const code = (operationError as NodeJS.ErrnoException).code;
262
+ if (code === "ENOENT" || code === "ELOOP")
263
+ throw new ProjectionScanRaceError("scan operation raced with a replacement or reparse point");
264
+ throw operationError;
265
+ }
266
+ return value;
267
+ };
268
+
269
+ return {
270
+ stat: opened,
271
+ readdir: () => bracketRoot(() => fs.readdir(rootPath)),
272
+ lstat: entry =>
273
+ bracketRoot(async () => {
274
+ const stat = await fs.lstat(entryPath(entry), { bigint: true });
275
+ if (stat.isSymbolicLink()) throw new ProjectionScanRaceError("candidate became a reparse point");
276
+ return stat;
277
+ }),
278
+ readFile: (entry, encoding, expected) =>
279
+ bracketRoot(() => readProjectionFileSafe(entryPath(entry), encoding, expected)),
280
+ close: async () => {},
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Open a no-follow directory authority and keep it alive through enumeration, child
286
+ * lstat, and child reads. Node has no `openat` binding, so POSIX uses the proc fd path
287
+ * as the descriptor-relative namespace; a replacement root or parent cannot redirect it.
288
+ */
289
+ async function openProjectionDirectorySafe(dir: string): Promise<ProjectionScanDirectory> {
290
+ const platform = projectionPlatform();
291
+ if (platform === "win32") return openProjectionDirectoryWindowsSafe(dir);
292
+ if (platform !== "linux") throw new ProjectionScanUnsupportedError();
293
+ const noFollow = fs.constants.O_NOFOLLOW;
294
+ const nonBlock = fs.constants.O_NONBLOCK;
295
+ if (typeof noFollow !== "number" || typeof nonBlock !== "number") throw new ProjectionScanUnsupportedError();
296
+ const directoryFlag = typeof fs.constants.O_DIRECTORY === "number" ? fs.constants.O_DIRECTORY : 0;
297
+ const flags = fs.constants.O_RDONLY | (nonBlock as number) | (noFollow as number) | directoryFlag;
298
+ let handle: fs.FileHandle | undefined;
299
+ try {
300
+ handle = await fs.open(dir, flags);
301
+ const opened = await handle.stat({ bigint: true });
302
+ if (!opened.isDirectory()) throw new ProjectionScanRaceError("scan root is not a directory");
303
+ const pinnedPath = `/proc/self/fd/${handle.fd}`;
304
+ const ownedHandle = handle;
305
+ handle = undefined;
306
+ const assertRoot = async (): Promise<void> => {
307
+ const settled = await ownedHandle.stat({ bigint: true });
308
+ if (!sameProjectionRoot(opened, settled)) throw new ProjectionScanRaceError("scan root descriptor changed");
309
+ };
310
+ return {
311
+ stat: opened,
312
+ readdir: async () => {
313
+ const entries = await fs.readdir(pinnedPath);
314
+ await assertRoot();
315
+ return entries;
316
+ },
317
+ lstat: async entry => {
318
+ const stat = await fs.lstat(path.join(pinnedPath, entry), { bigint: true });
319
+ await assertRoot();
320
+ return stat;
321
+ },
322
+ readFile: async (entry, encoding, expected) => {
323
+ const source = await readProjectionFileSafe(path.join(pinnedPath, entry), encoding, expected);
324
+ await assertRoot();
325
+ return source;
326
+ },
327
+ close: async () => {
328
+ await ownedHandle.close().catch(() => undefined);
329
+ },
330
+ };
331
+ } catch (error) {
332
+ if ((error as NodeJS.ErrnoException).code === "ELOOP")
333
+ throw new ProjectionScanRaceError("scan root became a symlink");
334
+ throw error;
335
+ } finally {
336
+ await handle?.close().catch(() => undefined);
337
+ }
338
+ }
339
+
340
+ const failUnpinnedProjectionOperation = async (): Promise<never> => {
341
+ throw new ProjectionScanUnsupportedError();
342
+ };
343
+
344
+ const defaultFs: ProjectionScanFs = {
345
+ // The default implementation never permits an accidental mutable-path fallback;
346
+ // all production operations must go through openProjectionDirectorySafe above.
347
+ readdir: failUnpinnedProjectionOperation,
348
+ lstat: failUnpinnedProjectionOperation,
349
+ readFile: failUnpinnedProjectionOperation,
350
+ readFileSafe: failUnpinnedProjectionOperation,
351
+ openDirectory: openProjectionDirectorySafe,
352
+ };
353
+
354
+ export function isCoordinatorScanDebrisName(name: string): boolean {
355
+ return name.startsWith(".");
356
+ }
357
+
358
+ async function scanCoordinatorJsonFiles(
359
+ dir: string,
360
+ io: ProjectionScanFs = defaultFs,
361
+ cap: number = COORDINATOR_JSON_SCAN_CAP,
362
+ authority?: ProjectionScanDirectory,
363
+ ): Promise<ProjectionScanResult> {
364
+ let rootStat: ProjectionScanStat;
365
+ try {
366
+ rootStat = authority?.stat ?? (await io.lstat(dir));
367
+ } catch (error) {
368
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
369
+ return {
370
+ values: [],
371
+ parsed: 0,
372
+ capped: false,
373
+ skippedDebris: 0,
374
+ skippedEmpty: 0,
375
+ raced: 0,
376
+ incomplete: false,
377
+ };
378
+ }
379
+ throw error;
380
+ }
381
+ if (rootStat.isSymbolicLink() || rootStat.isDirectory?.() === false) {
382
+ return {
383
+ values: [],
384
+ parsed: 0,
385
+ capped: true,
386
+ skippedDebris: 0,
387
+ skippedEmpty: 0,
388
+ raced: 1,
389
+ incomplete: true,
390
+ };
391
+ }
392
+ let entries: string[];
393
+ try {
394
+ entries = authority ? await authority.readdir() : await io.readdir(dir);
395
+ } catch (error) {
396
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
397
+ return {
398
+ values: [],
399
+ parsed: 0,
400
+ capped: true,
401
+ skippedDebris: 0,
402
+ skippedEmpty: 0,
403
+ raced: 1,
404
+ incomplete: true,
405
+ };
406
+ }
407
+ if (error instanceof ProjectionScanRaceError) {
408
+ return {
409
+ values: [],
410
+ parsed: 0,
411
+ capped: true,
412
+ skippedDebris: 0,
413
+ skippedEmpty: 0,
414
+ raced: 1,
415
+ incomplete: true,
416
+ };
417
+ }
418
+ throw error;
419
+ }
420
+
421
+ let skippedDebris = 0;
422
+ let skippedEmpty = 0;
423
+ let raced = 0;
424
+ const parseCandidates: Array<{ entry: string; stat: ProjectionScanStat }> = [];
425
+ for (const entry of entries) {
426
+ if (!entry.endsWith(".json") || isCoordinatorScanDebrisName(entry)) {
427
+ if (entry.endsWith(".json") && isCoordinatorScanDebrisName(entry)) skippedDebris += 1;
428
+ continue;
429
+ }
430
+ const file = path.join(dir, entry);
431
+ let stat: ProjectionScanStat;
432
+ try {
433
+ stat = authority ? await authority.lstat(entry) : await io.lstat(file);
434
+ } catch (error) {
435
+ if (
436
+ (error as NodeJS.ErrnoException).code === "ENOENT" ||
437
+ (error as NodeJS.ErrnoException).code === "ELOOP" ||
438
+ error instanceof ProjectionScanRaceError
439
+ ) {
440
+ raced += 1;
441
+ continue;
442
+ }
443
+ throw error;
444
+ }
445
+ if (stat.isSymbolicLink() || !stat.isFile()) {
446
+ // The candidate was a parseable-looking directory entry when enumeration
447
+ // completed, but its type changed before inspection. Treat that as a
448
+ // raced candidate rather than ordinary empty/non-regular debris: callers
449
+ // must not consume a partial projection set.
450
+ raced += 1;
451
+ continue;
452
+ }
453
+ parseCandidates.push({ entry, stat });
454
+ }
455
+
456
+ const capped = parseCandidates.length > cap;
457
+ const toParse = capped ? parseCandidates.slice(0, cap) : parseCandidates;
458
+ const values: unknown[] = [];
459
+ for (const candidate of toParse) {
460
+ const { entry, stat } = candidate;
461
+ const file = path.join(dir, entry);
462
+ let source: string;
463
+ try {
464
+ source = authority
465
+ ? await authority.readFile(entry, "utf8", stat)
466
+ : await (io.readFileSafe ?? io.readFile)(file, "utf8", stat);
467
+ } catch (error) {
468
+ if (
469
+ (error as NodeJS.ErrnoException).code === "ENOENT" ||
470
+ (error as NodeJS.ErrnoException).code === "ELOOP" ||
471
+ error instanceof ProjectionScanRaceError
472
+ ) {
473
+ raced += 1;
474
+ skippedEmpty += 1;
475
+ continue;
476
+ }
477
+ throw error;
478
+ }
479
+ try {
480
+ values.push(JSON.parse(source));
481
+ } catch (error) {
482
+ throw new Error(
483
+ `invalid coordinator projection ${file}: ${error instanceof Error ? error.message : String(error)}`,
484
+ );
485
+ }
486
+ }
487
+ let finalRoot: ProjectionScanStat;
488
+ try {
489
+ finalRoot = authority?.stat ?? (await io.lstat(dir));
490
+ } catch (error) {
491
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
492
+ return {
493
+ values: [],
494
+ parsed: 0,
495
+ capped: true,
496
+ skippedDebris,
497
+ skippedEmpty,
498
+ raced: raced + 1,
499
+ incomplete: true,
500
+ };
501
+ }
502
+ if (hasProjectionRootIdentity(rootStat) && !sameProjectionRoot(rootStat, finalRoot)) {
503
+ return {
504
+ values: [],
505
+ parsed: 0,
506
+ capped: true,
507
+ skippedDebris,
508
+ skippedEmpty,
509
+ raced: raced + 1,
510
+ incomplete: true,
511
+ };
512
+ }
513
+ return {
514
+ values: values.filter(value => value !== null),
515
+ parsed: values.length,
516
+ // Existing authoritative callers already refuse a capped scan. Treat a raced
517
+ // candidate as the same incomplete boundary so they cannot consume a partial
518
+ // projection set while the result still reports the precise race count below.
519
+ capped: capped || raced > 0,
520
+ skippedDebris,
521
+ skippedEmpty,
522
+ raced,
523
+ incomplete: capped || raced > 0,
524
+ };
525
+ }
526
+
527
+ export async function listCoordinatorJsonFiles(
528
+ dir: string,
529
+ io: ProjectionScanFs = defaultFs,
530
+ cap: number = COORDINATOR_JSON_SCAN_CAP,
531
+ ): Promise<ProjectionScanResult> {
532
+ let authority: ProjectionScanDirectory | undefined;
533
+ try {
534
+ authority = io.openDirectory ? await io.openDirectory(dir) : undefined;
535
+ return await scanCoordinatorJsonFiles(dir, io, cap, authority);
536
+ } catch (error) {
537
+ if (
538
+ error instanceof ProjectionScanRaceError ||
539
+ error instanceof ProjectionScanUnsupportedError ||
540
+ (error as NodeJS.ErrnoException).code === PROJECTION_SCAN_UNSUPPORTED_CODE
541
+ ) {
542
+ return {
543
+ values: [],
544
+ parsed: 0,
545
+ capped: true,
546
+ skippedDebris: 0,
547
+ skippedEmpty: 0,
548
+ raced: 1,
549
+ incomplete: true,
550
+ };
551
+ }
552
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
553
+ // No authority was returned: this is the lazy first-use shape for an
554
+ // otherwise valid projection namespace, not a partial scan. Once an
555
+ // authority exists, ENOENT is handled by scanCoordinatorJsonFiles as a
556
+ // raced/incomplete result and cannot reach this branch.
557
+ return {
558
+ values: [],
559
+ parsed: 0,
560
+ capped: false,
561
+ skippedDebris: 0,
562
+ skippedEmpty: 0,
563
+ raced: 0,
564
+ incomplete: false,
565
+ };
566
+ }
567
+ throw error;
568
+ } finally {
569
+ await authority?.close().catch(() => undefined);
570
+ }
571
+ }