@gajae-code/coding-agent 0.18.11 → 0.18.12

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 (270) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/dist/types/async/job-manager.d.ts +7 -3
  3. package/dist/types/async/result-formatting.d.ts +2 -0
  4. package/dist/types/autoresearch/git.d.ts +1 -1
  5. package/dist/types/capability/fs.d.ts +2 -2
  6. package/dist/types/cli/accounts-cli.d.ts +2 -2
  7. package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
  8. package/dist/types/cli/install-activation.d.ts +10 -4
  9. package/dist/types/cli/public-command-errors.d.ts +1 -0
  10. package/dist/types/cli/update-cli.d.ts +14 -1
  11. package/dist/types/cli-ordinary.d.ts +2 -2
  12. package/dist/types/commands/accounts.d.ts +2 -1
  13. package/dist/types/commands/migrate.d.ts +1 -1
  14. package/dist/types/commands/sdk.d.ts +5 -0
  15. package/dist/types/commit/changelog/index.d.ts +1 -0
  16. package/dist/types/config/autorouting-tier-map.d.ts +30 -0
  17. package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
  18. package/dist/types/config/config-hot-reload.d.ts +39 -0
  19. package/dist/types/config/file-lock.d.ts +8 -1
  20. package/dist/types/config/model-discovery-manager.d.ts +2 -0
  21. package/dist/types/config/model-profile-activation.d.ts +62 -6
  22. package/dist/types/config/model-registry.d.ts +56 -5
  23. package/dist/types/config/model-resolver.d.ts +2 -0
  24. package/dist/types/config/models-config-schema.d.ts +10 -0
  25. package/dist/types/config/settings-schema.d.ts +46 -3
  26. package/dist/types/config/settings.d.ts +33 -1
  27. package/dist/types/dap/config.d.ts +8 -4
  28. package/dist/types/debug/system-info.d.ts +6 -1
  29. package/dist/types/discovery/mcp-compat.d.ts +3 -1
  30. package/dist/types/edit/modes/patch.d.ts +5 -0
  31. package/dist/types/edit/modes/replace.d.ts +3 -0
  32. package/dist/types/exec/bash-executor.d.ts +12 -0
  33. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
  34. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  35. package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
  36. package/dist/types/migrate/adapters/codex.d.ts +2 -1
  37. package/dist/types/migrate/adapters/index.d.ts +10 -1
  38. package/dist/types/migrate/executor.d.ts +3 -1
  39. package/dist/types/migrate/types.d.ts +15 -3
  40. package/dist/types/modes/components/settings-selector.d.ts +6 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
  42. package/dist/types/modes/theme/theme.d.ts +1 -0
  43. package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
  44. package/dist/types/runtime-mcp/manager.d.ts +7 -0
  45. package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
  46. package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
  47. package/dist/types/sdk/broker/broker-exit.d.ts +2 -2
  48. package/dist/types/sdk/broker/broker.d.ts +7 -1
  49. package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
  50. package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
  51. package/dist/types/sdk/broker/ensure.d.ts +136 -7
  52. package/dist/types/sdk/broker/hop.d.ts +36 -0
  53. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
  54. package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +4 -1
  56. package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
  57. package/dist/types/sdk/bus/index.d.ts +23 -0
  58. package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
  59. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
  60. package/dist/types/sdk/cli/session-cli.d.ts +0 -1
  61. package/dist/types/sdk/host/session-runtime.d.ts +32 -0
  62. package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
  63. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
  64. package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
  65. package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
  66. package/dist/types/sdk/session-list.d.ts +1 -1
  67. package/dist/types/session/agent-session.d.ts +58 -2
  68. package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
  69. package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
  70. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
  71. package/dist/types/session/messages.d.ts +5 -1
  72. package/dist/types/session/session-manager.d.ts +13 -3
  73. package/dist/types/session/startup-auth-config.d.ts +24 -2
  74. package/dist/types/session/streaming-output.d.ts +11 -0
  75. package/dist/types/session/terminal-abort.d.ts +3 -1
  76. package/dist/types/session-import/service.d.ts +1 -1
  77. package/dist/types/skill-state/workflow-mutation-guard.d.ts +7 -3
  78. package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
  79. package/dist/types/task/executor.d.ts +19 -2
  80. package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
  81. package/dist/types/tools/atomic-file-write.d.ts +9 -7
  82. package/dist/types/tools/bash-allowed-prefixes.d.ts +11 -0
  83. package/dist/types/tools/descriptors.d.ts +2 -0
  84. package/dist/types/tools/fetch.d.ts +1 -1
  85. package/dist/types/tools/index.d.ts +2 -0
  86. package/dist/types/tools/python.d.ts +1 -1
  87. package/dist/types/tools/report-issue.d.ts +41 -0
  88. package/dist/types/tools/subagent.d.ts +23 -0
  89. package/dist/types/web/scrapers/types.d.ts +8 -1
  90. package/dist/types/web/search/providers/insane.d.ts +7 -1
  91. package/dist/types/web/search/providers/searxng.d.ts +5 -0
  92. package/package.json +8 -8
  93. package/scripts/build-binary.ts +2 -1
  94. package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
  95. package/scripts/generate-docs-index.ts +8 -1
  96. package/scripts/generate-sdk-operation-inventory.ts +22 -4
  97. package/src/async/job-manager.ts +356 -56
  98. package/src/async/result-formatting.ts +40 -0
  99. package/src/autoresearch/git.ts +9 -2
  100. package/src/autoresearch/prompts/prompt-setup.md +1 -1
  101. package/src/capability/fs.ts +27 -19
  102. package/src/capability/index.ts +2 -2
  103. package/src/cli/accounts-cli.ts +176 -8
  104. package/src/cli/auth-broker-cli.ts +21 -11
  105. package/src/cli/doctor/install-repairs.ts +35 -5
  106. package/src/cli/fast-help.ts +1 -1
  107. package/src/cli/install-activation.ts +47 -4
  108. package/src/cli/migrate-cli.ts +7 -5
  109. package/src/cli/public-command-errors.ts +4 -0
  110. package/src/cli/root-flags.ts +2 -2
  111. package/src/cli/stats-cli.ts +9 -5
  112. package/src/cli/update-cli.ts +224 -7
  113. package/src/cli-ordinary.ts +7 -2
  114. package/src/commands/accounts.ts +10 -5
  115. package/src/commands/migrate.ts +3 -3
  116. package/src/commands/sdk.ts +199 -42
  117. package/src/commands/ultragoal.ts +16 -8
  118. package/src/commit/changelog/index.ts +39 -1
  119. package/src/commit/message.ts +10 -3
  120. package/src/config/autorouting-tier-map.ts +30 -0
  121. package/src/config/config-hot-reload-trust.ts +148 -0
  122. package/src/config/config-hot-reload.ts +734 -0
  123. package/src/config/file-lock.ts +216 -56
  124. package/src/config/model-bindings-applier.ts +85 -47
  125. package/src/config/model-discovery-manager.ts +10 -0
  126. package/src/config/model-profile-activation.ts +716 -113
  127. package/src/config/model-registry.ts +1334 -158
  128. package/src/config/model-resolver.ts +3 -1
  129. package/src/config/models-config-schema.ts +6 -0
  130. package/src/config/settings-schema.ts +60 -3
  131. package/src/config/settings.ts +458 -24
  132. package/src/coordinator-mcp/policy.ts +26 -1
  133. package/src/crash/sanitize.ts +4 -0
  134. package/src/customization/import.ts +3 -1
  135. package/src/dap/config.ts +31 -11
  136. package/src/dap/defaults.json +1 -1
  137. package/src/debug/crash-diagnostics.ts +2 -1
  138. package/src/debug/report-bundle.ts +19 -0
  139. package/src/debug/system-info.ts +8 -3
  140. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
  141. package/src/discovery/agents-md.ts +1 -1
  142. package/src/discovery/helpers.ts +1 -2
  143. package/src/discovery/mcp-compat.ts +120 -17
  144. package/src/edit/apply-patch/index.ts +12 -1
  145. package/src/edit/index.ts +13 -17
  146. package/src/edit/modes/patch.ts +187 -8
  147. package/src/edit/modes/replace.ts +136 -0
  148. package/src/eval/js/context-manager.ts +29 -3
  149. package/src/eval/js/worker-entry.ts +51 -18
  150. package/src/eval/py/executor.ts +14 -4
  151. package/src/eval/py/runner.py +144 -5
  152. package/src/exec/bash-executor.ts +81 -4
  153. package/src/exec/bash-shell-guardian.ts +4 -1
  154. package/src/exec/bash-shell-supervisor.ts +8 -4
  155. package/src/exec/isolated-shell.ts +5 -1
  156. package/src/export/html/index.ts +46 -6
  157. package/src/extensibility/custom-tools/wrapper.ts +3 -1
  158. package/src/extensibility/extensions/loader.ts +8 -1
  159. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
  160. package/src/gjc-runtime/gc-runtime.ts +33 -6
  161. package/src/gjc-runtime/launch-tmux.ts +7 -2
  162. package/src/gjc-runtime/launch-worktree.test.ts +148 -0
  163. package/src/gjc-runtime/launch-worktree.ts +101 -14
  164. package/src/gjc-runtime/managed-owner-admission.ts +46 -2
  165. package/src/gjc-runtime/session-resolution.ts +38 -6
  166. package/src/gjc-runtime/session-state-lock.ts +59 -14
  167. package/src/gjc-runtime/state-runtime.ts +23 -19
  168. package/src/hooks/native-skill-hook.ts +14 -11
  169. package/src/internal-urls/docs-index.generated.ts +14 -13
  170. package/src/internal-urls/local-protocol.ts +142 -8
  171. package/src/lsp/index.ts +29 -0
  172. package/src/lsp/workspace-path.ts +23 -0
  173. package/src/migrate/action-planner.ts +57 -0
  174. package/src/migrate/adapters/claude-code.ts +23 -4
  175. package/src/migrate/adapters/codex.ts +22 -4
  176. package/src/migrate/adapters/index.ts +22 -4
  177. package/src/migrate/adapters/opencode.ts +6 -1
  178. package/src/migrate/executor.ts +84 -7
  179. package/src/migrate/report.ts +19 -2
  180. package/src/migrate/types.ts +14 -4
  181. package/src/modes/DESIGN.md +51 -0
  182. package/src/modes/acp/permission-mode.ts +18 -1
  183. package/src/modes/components/assistant-message.ts +55 -1
  184. package/src/modes/components/settings-selector.ts +103 -37
  185. package/src/modes/controllers/command-controller.ts +38 -2
  186. package/src/modes/controllers/plan-mode-controller.ts +17 -7
  187. package/src/modes/controllers/selector-controller.ts +62 -6
  188. package/src/modes/interactive-mode.ts +68 -0
  189. package/src/modes/theme/theme.ts +15 -0
  190. package/src/prompts/tools/debug.md +1 -1
  191. package/src/prompts/tools/read.md +1 -1
  192. package/src/prompts/tools/report-issue.md +13 -0
  193. package/src/prompts/tools/task.md +1 -1
  194. package/src/runtime-credential-selector.ts +2 -2
  195. package/src/runtime-mcp/content-limits.ts +7 -0
  196. package/src/runtime-mcp/manager-pool.test.ts +5 -4
  197. package/src/runtime-mcp/manager.ts +32 -0
  198. package/src/runtime-mcp/oauth-flow.ts +32 -6
  199. package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
  200. package/src/runtime-mcp/render.ts +16 -1
  201. package/src/runtime-mcp/tool-bridge.ts +126 -17
  202. package/src/sdk/broker/broker-exit.ts +8 -5
  203. package/src/sdk/broker/broker.ts +342 -82
  204. package/src/sdk/broker/daemon-entry.ts +338 -116
  205. package/src/sdk/broker/doctor-restart.ts +2 -1
  206. package/src/sdk/broker/ensure.ts +1143 -265
  207. package/src/sdk/broker/hop.ts +274 -0
  208. package/src/sdk/broker/lifecycle-ledger.ts +35 -11
  209. package/src/sdk/broker/lock-host-identity.ts +29 -0
  210. package/src/sdk/broker/session-index.ts +62 -12
  211. package/src/sdk/bus/chat-command-policy.ts +16 -3
  212. package/src/sdk/bus/index.ts +52 -21
  213. package/src/sdk/bus/reconciliation-store.ts +201 -8
  214. package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
  215. package/src/sdk/cli/session-cli.ts +21 -6
  216. package/src/sdk/host/session-runtime.test.ts +118 -23
  217. package/src/sdk/host/session-runtime.ts +352 -47
  218. package/src/sdk/lifecycle/service.ts +1 -0
  219. package/src/sdk/prompt-deadline-flush.ts +78 -5
  220. package/src/sdk/prompt-deadline-manager.ts +68 -14
  221. package/src/sdk/protocol/adapter-validation.ts +53 -11
  222. package/src/sdk/protocol/operation-inventory.generated.json +101 -2
  223. package/src/sdk/reconciliation-extensions.ts +1 -0
  224. package/src/sdk/session-list.ts +1 -0
  225. package/src/sdk/session.ts +114 -61
  226. package/src/session/agent-session.ts +1043 -81
  227. package/src/session/internal/managed-session-scope.ts +126 -39
  228. package/src/session/internal/managed-session-storage.ts +14 -11
  229. package/src/session/internal/native-publish-outcome.ts +5 -1
  230. package/src/session/messages.ts +47 -6
  231. package/src/session/session-manager.ts +236 -14
  232. package/src/session/startup-auth-config.ts +116 -8
  233. package/src/session/streaming-output.ts +262 -4
  234. package/src/session/terminal-abort.ts +14 -2
  235. package/src/session-import/service.ts +1 -0
  236. package/src/skill-state/workflow-mutation-guard.ts +491 -32
  237. package/src/slash-commands/builtin-registry.ts +15 -3
  238. package/src/slash-commands/helpers/credential-switch.ts +10 -5
  239. package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
  240. package/src/task/executor.ts +103 -12
  241. package/src/task/index.ts +389 -175
  242. package/src/tools/ast-edit-write-path.ts +64 -0
  243. package/src/tools/ast-edit.ts +18 -1
  244. package/src/tools/atomic-file-write.ts +46 -15
  245. package/src/tools/auto-generated-guard.ts +3 -0
  246. package/src/tools/bash-allowed-prefixes.ts +153 -5
  247. package/src/tools/bash-interactive.ts +14 -2
  248. package/src/tools/bash.ts +14 -0
  249. package/src/tools/debug.ts +7 -6
  250. package/src/tools/descriptors.test.ts +44 -0
  251. package/src/tools/descriptors.ts +34 -2
  252. package/src/tools/fetch.ts +3 -2
  253. package/src/tools/index.ts +2 -0
  254. package/src/tools/output-meta.ts +23 -2
  255. package/src/tools/python.ts +1 -1
  256. package/src/tools/read.ts +17 -35
  257. package/src/tools/report-issue.ts +184 -0
  258. package/src/tools/subagent-render.ts +114 -12
  259. package/src/tools/subagent.ts +108 -1
  260. package/src/tools/todo-write.ts +41 -3
  261. package/src/tools/tool-catalog.generated.ts +31 -3
  262. package/src/tools/write.ts +35 -3
  263. package/src/utils/commit-message-generator.ts +9 -2
  264. package/src/utils/file-mentions.ts +29 -1
  265. package/src/utils/title-generator.ts +7 -2
  266. package/src/web/scrapers/index.ts +3 -1
  267. package/src/web/scrapers/types.ts +8 -0
  268. package/src/web/scrapers/youtube.ts +3 -2
  269. package/src/web/search/providers/insane.ts +91 -28
  270. package/src/web/search/providers/searxng.ts +62 -10
@@ -0,0 +1,734 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as nodeFs from "node:fs";
3
+ import * as fs from "node:fs/promises";
4
+ import * as path from "node:path";
5
+
6
+ const DEBOUNCE_QUIET_MS = 45;
7
+ const DEBOUNCE_MAX_MS = 300;
8
+ const WATCH_RECOVERY_INITIAL_MS = 100;
9
+ const WATCH_RECOVERY_MAX_MS = 5_000;
10
+ const APPLY_RETRY_DELAYS_MS = [100, 400] as const;
11
+ const TRANSIENT_APPLY_FAILURE_CODES = new Set(["EAGAIN", "EBUSY", "EINTR", "ETIMEDOUT", "ECONNRESET", "EAI_AGAIN"]);
12
+
13
+ export interface ConfigHotReloadPaths {
14
+ readonly configPath: string;
15
+ readonly modelsPath: string;
16
+ }
17
+
18
+ export interface ConfigHotReloadFileSnapshot {
19
+ readonly path: string;
20
+ readonly text: string | null;
21
+ readonly identity: string;
22
+ }
23
+
24
+ export interface ConfigHotReloadCandidate {
25
+ readonly revision: number;
26
+ readonly config: ConfigHotReloadFileSnapshot;
27
+ readonly models: ConfigHotReloadFileSnapshot;
28
+ }
29
+
30
+ export type ConfigHotReloadErrorOperation = "watch" | "read" | "validate" | "apply";
31
+
32
+ /** A path-free diagnostic for watcher, snapshot, and callback failures. */
33
+ export class ConfigHotReloadError extends Error {
34
+ readonly operation: ConfigHotReloadErrorOperation;
35
+ readonly code: string | undefined;
36
+
37
+ constructor(operation: ConfigHotReloadErrorOperation, code?: string) {
38
+ super(
39
+ code
40
+ ? `Configuration hot reload ${operation} failed (${code})`
41
+ : `Configuration hot reload ${operation} failed`,
42
+ );
43
+ this.name = "ConfigHotReloadError";
44
+ this.operation = operation;
45
+ this.code = code;
46
+ }
47
+ }
48
+
49
+ export interface ConfigHotReloadWatcherOptions {
50
+ /** Validate the snapshots without mutating live state before a candidate may supersede queued work. */
51
+ readonly onValidate: (candidate: ConfigHotReloadCandidate) => void | Promise<void>;
52
+ /** Apply these text snapshots, not a second read of their paths, and honor abort before commit. */
53
+ readonly onCandidate: (candidate: ConfigHotReloadCandidate, signal: AbortSignal) => void | Promise<void>;
54
+ readonly onError: (error: ConfigHotReloadError) => void;
55
+ }
56
+
57
+ interface FileSources {
58
+ readonly config: ConfigHotReloadFileSnapshot;
59
+ readonly models: ConfigHotReloadFileSnapshot;
60
+ }
61
+
62
+ interface Binding {
63
+ readonly generation: number;
64
+ readonly paths: ConfigHotReloadPaths;
65
+ readonly watchers: Map<string, nodeFs.FSWatcher>;
66
+ readonly watcherIdentities: Map<string, string>;
67
+ watcherRecoveryTimer: NodeJS.Timeout | undefined;
68
+ watcherRecoveryDelayMs: number;
69
+ watcherDirectoryDiscoveryFailed: boolean;
70
+ watchTargets: Set<string>;
71
+ applyRetryTimer: NodeJS.Timeout | undefined;
72
+ applyRetryCandidate: ConfigHotReloadCandidate | undefined;
73
+ initializing: boolean;
74
+ baselineIdentity: string | undefined;
75
+ initialReadFailed: boolean;
76
+ debounceStartedAt: number | undefined;
77
+ debounceTimer: NodeJS.Timeout | undefined;
78
+ scanning: boolean;
79
+ scanRequested: boolean;
80
+ }
81
+
82
+ interface PendingCandidate {
83
+ readonly binding: Binding;
84
+ readonly candidate: ConfigHotReloadCandidate;
85
+ readonly applyAttempt: number;
86
+ }
87
+
88
+ interface RunningCandidate extends PendingCandidate {
89
+ readonly controller: AbortController;
90
+ }
91
+
92
+ function failureCode(error: unknown): string | undefined {
93
+ if (!error || typeof error !== "object" || !("code" in error) || typeof error.code !== "string") return undefined;
94
+ return /^[A-Z0-9_]+$/.test(error.code) ? error.code : undefined;
95
+ }
96
+
97
+ function missingFile(error: unknown): boolean {
98
+ if (!error || typeof error !== "object" || !("code" in error) || typeof error.code !== "string") return false;
99
+ return error.code === "ENOENT" || error.code === "ENOTDIR";
100
+ }
101
+
102
+ function transientApplyFailure(error: unknown): boolean {
103
+ if (error && typeof error === "object" && "retryable" in error && error.retryable === true) return true;
104
+ const code = failureCode(error);
105
+ return code !== undefined && TRANSIENT_APPLY_FAILURE_CODES.has(code);
106
+ }
107
+
108
+ function identityFor(filePath: string, text: string | null): string {
109
+ const hash = createHash("sha256");
110
+ hash.update(filePath);
111
+ hash.update("\0");
112
+ hash.update(text === null ? "missing" : "present\0");
113
+ if (text !== null) hash.update(text);
114
+ return hash.digest("hex");
115
+ }
116
+
117
+ function pairIdentity(sources: FileSources): string {
118
+ return `${sources.config.identity}:${sources.models.identity}`;
119
+ }
120
+
121
+ function candidateIdentity(candidate: ConfigHotReloadCandidate): string {
122
+ return `${candidate.config.identity}:${candidate.models.identity}`;
123
+ }
124
+
125
+ function pathsIdentity(paths: ConfigHotReloadPaths): string {
126
+ return `${paths.configPath}\0${paths.modelsPath}`;
127
+ }
128
+
129
+ async function existingDirectory(filePath: string): Promise<string> {
130
+ let current = path.dirname(filePath);
131
+ for (;;) {
132
+ try {
133
+ const stat = await fs.stat(current);
134
+ if (stat.isDirectory()) return current;
135
+ } catch (error) {
136
+ if (!missingFile(error)) throw error;
137
+ }
138
+ const parent = path.dirname(current);
139
+ if (parent === current) return current;
140
+ current = parent;
141
+ }
142
+ }
143
+
144
+ async function existingDirectoryAncestors(filePath: string): Promise<Set<string>> {
145
+ let current = await existingDirectory(filePath);
146
+ const directories = new Set<string>();
147
+ for (;;) {
148
+ directories.add(current);
149
+ const parent = path.dirname(current);
150
+ if (parent === current) return directories;
151
+ current = parent;
152
+ }
153
+ }
154
+
155
+ async function canonicalWatchPath(filePath: string, visited = new Set<string>()): Promise<string> {
156
+ try {
157
+ return await fs.realpath(filePath);
158
+ } catch (error) {
159
+ if (!missingFile(error)) throw error;
160
+ }
161
+ if (visited.has(filePath)) return filePath;
162
+ visited.add(filePath);
163
+ try {
164
+ const stat = await fs.lstat(filePath);
165
+ if (stat.isSymbolicLink()) {
166
+ const target = await fs.readlink(filePath);
167
+ return canonicalWatchPath(path.resolve(path.dirname(filePath), target), visited);
168
+ }
169
+ } catch (error) {
170
+ if (!missingFile(error)) throw error;
171
+ }
172
+ const directory = await existingDirectory(filePath);
173
+ const canonicalDirectory = await fs.realpath(directory);
174
+ return path.join(canonicalDirectory, path.relative(directory, filePath));
175
+ }
176
+
177
+ function nextPathComponent(directory: string, filePath: string): string | undefined {
178
+ const relativePath = path.relative(directory, filePath);
179
+ if (
180
+ !relativePath ||
181
+ path.isAbsolute(relativePath) ||
182
+ relativePath === ".." ||
183
+ relativePath.startsWith(`..${path.sep}`)
184
+ ) {
185
+ return undefined;
186
+ }
187
+ return relativePath.split(path.sep, 1)[0];
188
+ }
189
+
190
+ function normalizePaths(paths: ConfigHotReloadPaths): ConfigHotReloadPaths {
191
+ if (!path.isAbsolute(paths.configPath) || !path.isAbsolute(paths.modelsPath)) {
192
+ throw new TypeError("Configuration hot reload paths must be absolute");
193
+ }
194
+ return Object.freeze({
195
+ configPath: path.resolve(paths.configPath),
196
+ modelsPath: path.resolve(paths.modelsPath),
197
+ });
198
+ }
199
+
200
+ /** Watches resolved config and models files and delivers immutable source snapshots. */
201
+ export class ConfigHotReloadWatcher {
202
+ readonly #onCandidate: ConfigHotReloadWatcherOptions["onCandidate"];
203
+ readonly #onValidate: ConfigHotReloadWatcherOptions["onValidate"];
204
+ readonly #onError: ConfigHotReloadWatcherOptions["onError"];
205
+ readonly #reportedErrors = new Set<string>();
206
+ #binding: Binding | undefined;
207
+ #pending: PendingCandidate | undefined;
208
+ #running: RunningCandidate | undefined;
209
+ #revision = 0;
210
+ #highestQueuedRevision = 0;
211
+ #generation = 0;
212
+ #started = false;
213
+ #disposed = false;
214
+
215
+ constructor(options: ConfigHotReloadWatcherOptions) {
216
+ this.#onCandidate = options.onCandidate;
217
+ this.#onValidate = options.onValidate;
218
+ this.#onError = options.onError;
219
+ }
220
+
221
+ /** Establishes the initial fingerprint baseline without emitting a candidate. */
222
+ async start(paths: ConfigHotReloadPaths): Promise<void> {
223
+ if (this.#disposed) throw new Error("Configuration hot reload watcher is disposed");
224
+ if (this.#started) throw new Error("Configuration hot reload watcher has already started");
225
+ const normalizedPaths = normalizePaths(paths);
226
+ this.#started = true;
227
+ const binding = this.#newBinding(normalizedPaths);
228
+ this.#binding = binding;
229
+ await this.#initializeBinding(binding);
230
+ }
231
+
232
+ /** Rebinds after session or configuration-root changes, fencing old work. */
233
+ async rebind(paths: ConfigHotReloadPaths): Promise<void> {
234
+ if (!this.#started) throw new Error("Configuration hot reload watcher has not started");
235
+ if (this.#disposed) throw new Error("Configuration hot reload watcher is disposed");
236
+ const normalizedPaths = normalizePaths(paths);
237
+ this.#retireBinding(this.#binding);
238
+ this.#pending = undefined;
239
+ this.#running?.controller.abort();
240
+ const binding = this.#newBinding(normalizedPaths);
241
+ this.#binding = binding;
242
+ await this.#initializeBinding(binding);
243
+ }
244
+
245
+ /** Closes all watches and cancels or fences pending and in-flight callbacks. */
246
+ dispose(): void {
247
+ if (this.#disposed) return;
248
+ this.#disposed = true;
249
+ this.#generation += 1;
250
+ this.#pending = undefined;
251
+ this.#running?.controller.abort();
252
+ this.#retireBinding(this.#binding);
253
+ this.#binding = undefined;
254
+ }
255
+
256
+ #newBinding(paths: ConfigHotReloadPaths): Binding {
257
+ return {
258
+ generation: ++this.#generation,
259
+ paths,
260
+ watchers: new Map(),
261
+ watcherIdentities: new Map(),
262
+ watcherRecoveryTimer: undefined,
263
+ watcherRecoveryDelayMs: WATCH_RECOVERY_INITIAL_MS,
264
+ watcherDirectoryDiscoveryFailed: false,
265
+ watchTargets: new Set([paths.configPath, paths.modelsPath]),
266
+ applyRetryTimer: undefined,
267
+ applyRetryCandidate: undefined,
268
+ initializing: true,
269
+ baselineIdentity: undefined,
270
+ initialReadFailed: false,
271
+ debounceStartedAt: undefined,
272
+ debounceTimer: undefined,
273
+ scanning: false,
274
+ scanRequested: false,
275
+ };
276
+ }
277
+
278
+ async #initializeBinding(binding: Binding): Promise<void> {
279
+ let before: FileSources | undefined;
280
+ try {
281
+ before = await this.#readSources(binding);
282
+ } catch {
283
+ if (this.#isCurrent(binding)) {
284
+ binding.initialReadFailed = true;
285
+ }
286
+ }
287
+ if (!this.#isCurrent(binding)) return;
288
+ await this.#refreshWatchers(binding);
289
+ if (!this.#isCurrent(binding)) return;
290
+
291
+ let after: FileSources | undefined;
292
+ try {
293
+ after = await this.#readSources(binding);
294
+ } catch {}
295
+ if (!this.#isCurrent(binding)) return;
296
+
297
+ if (before) {
298
+ binding.baselineIdentity = pairIdentity(before);
299
+ if (after && pairIdentity(after) !== binding.baselineIdentity) {
300
+ binding.baselineIdentity = pairIdentity(after);
301
+ this.#validateCandidate(binding, this.#candidate(after));
302
+ }
303
+ } else if (after) {
304
+ binding.baselineIdentity = pairIdentity(after);
305
+ binding.initialReadFailed = false;
306
+ this.#validateCandidate(binding, this.#candidate(after));
307
+ }
308
+
309
+ binding.initializing = false;
310
+ if (binding.scanRequested) void this.#scan(binding);
311
+ this.#pump();
312
+ }
313
+
314
+ #isCurrent(binding: Binding): boolean {
315
+ return !this.#disposed && this.#binding === binding && this.#generation === binding.generation;
316
+ }
317
+
318
+ #retireBinding(binding: Binding | undefined): void {
319
+ if (!binding) return;
320
+ if (binding.debounceTimer) clearTimeout(binding.debounceTimer);
321
+ binding.debounceTimer = undefined;
322
+ if (binding.watcherRecoveryTimer) clearTimeout(binding.watcherRecoveryTimer);
323
+ binding.watcherRecoveryTimer = undefined;
324
+ this.#cancelApplyRetry(binding);
325
+ for (const watcher of binding.watchers.values()) watcher.close();
326
+ binding.watchers.clear();
327
+ binding.watcherIdentities.clear();
328
+ binding.watchTargets.clear();
329
+ }
330
+
331
+ async #readSources(binding: Binding): Promise<FileSources> {
332
+ const read = async (filePath: string): Promise<ConfigHotReloadFileSnapshot> => {
333
+ let text: string | null;
334
+ try {
335
+ text = await Bun.file(filePath).text();
336
+ } catch (error) {
337
+ if (missingFile(error)) {
338
+ text = null;
339
+ } else {
340
+ if (this.#isCurrent(binding)) this.#report("read", error, filePath);
341
+ throw error;
342
+ }
343
+ }
344
+ this.#clearErrors("read", filePath);
345
+ return Object.freeze({ path: filePath, text, identity: identityFor(filePath, text) });
346
+ };
347
+ const [config, models] = await Promise.all([read(binding.paths.configPath), read(binding.paths.modelsPath)]);
348
+ return Object.freeze({ config, models });
349
+ }
350
+
351
+ async #refreshWatchers(binding: Binding): Promise<void> {
352
+ if (!this.#isCurrent(binding)) return;
353
+ let desired = await this.#desiredWatchDirectories(binding);
354
+ if (!this.#isCurrent(binding)) return;
355
+ const attempted = new Set<string>();
356
+ for (let attempt = 0; attempt < 3; attempt += 1) {
357
+ for (const directory of desired.keys()) await this.#openWatcher(binding, directory, attempted);
358
+ const verified = await this.#desiredWatchDirectories(binding);
359
+ if (!this.#isCurrent(binding)) return;
360
+ const stable =
361
+ desired.size === verified.size && [...desired.keys()].every(directory => verified.has(directory));
362
+ if (stable) {
363
+ desired = verified;
364
+ break;
365
+ }
366
+ desired = verified;
367
+ }
368
+ for (const directory of desired.keys()) await this.#openWatcher(binding, directory, attempted);
369
+ for (const [directory, watcher] of binding.watchers) {
370
+ if (desired.has(directory)) continue;
371
+ watcher.close();
372
+ binding.watchers.delete(directory);
373
+ binding.watcherIdentities.delete(directory);
374
+ }
375
+ if (
376
+ binding.watcherDirectoryDiscoveryFailed ||
377
+ desired.size === 0 ||
378
+ [...desired.keys()].some(directory => !binding.watchers.has(directory))
379
+ ) {
380
+ this.#scheduleWatcherRecovery(binding);
381
+ } else {
382
+ this.#clearWatcherRecovery(binding);
383
+ }
384
+ }
385
+
386
+ async #desiredWatchDirectories(binding: Binding): Promise<Map<string, Set<string>>> {
387
+ let lookupFailed = false;
388
+ const desired = new Map<string, Set<string>>();
389
+ const watchTargets = new Set<string>();
390
+ const addTarget = async (filePath: string): Promise<void> => {
391
+ const targetPaths = new Set([filePath]);
392
+ try {
393
+ targetPaths.add(await canonicalWatchPath(filePath));
394
+ } catch (error) {
395
+ if (this.#isCurrent(binding)) {
396
+ lookupFailed = true;
397
+ this.#report("watch", error, filePath);
398
+ }
399
+ }
400
+ for (const targetPath of targetPaths) {
401
+ watchTargets.add(targetPath);
402
+ let directories: Set<string>;
403
+ try {
404
+ directories = await existingDirectoryAncestors(targetPath);
405
+ } catch (error) {
406
+ if (this.#isCurrent(binding)) {
407
+ lookupFailed = true;
408
+ this.#report("watch", error, targetPath);
409
+ }
410
+ continue;
411
+ }
412
+ for (const directory of directories) {
413
+ const component = nextPathComponent(directory, targetPath);
414
+ if (!component) continue;
415
+ const components = desired.get(directory) ?? new Set<string>();
416
+ components.add(component);
417
+ desired.set(directory, components);
418
+ }
419
+ }
420
+ };
421
+ await Promise.all([addTarget(binding.paths.configPath), addTarget(binding.paths.modelsPath)]);
422
+ if (!this.#isCurrent(binding)) return desired;
423
+ binding.watchTargets = watchTargets;
424
+ if (this.#isCurrent(binding)) {
425
+ binding.watcherDirectoryDiscoveryFailed = lookupFailed;
426
+ if (lookupFailed) this.#scheduleWatcherRecovery(binding);
427
+ }
428
+ return desired;
429
+ }
430
+
431
+ #candidate(sources: FileSources): ConfigHotReloadCandidate {
432
+ return Object.freeze({
433
+ revision: ++this.#revision,
434
+ config: sources.config,
435
+ models: sources.models,
436
+ });
437
+ }
438
+
439
+ async #openWatcher(binding: Binding, directory: string, attempted: Set<string>): Promise<void> {
440
+ if (attempted.has(directory)) return;
441
+ try {
442
+ const stat = await fs.stat(directory);
443
+ if (!this.#isCurrent(binding)) return;
444
+ const identity = `${stat.dev}:${stat.ino}`;
445
+ if (binding.watchers.has(directory)) {
446
+ if (binding.watcherIdentities.get(directory) === identity) return;
447
+ binding.watchers.get(directory)!.close();
448
+ binding.watchers.delete(directory);
449
+ binding.watcherIdentities.delete(directory);
450
+ }
451
+ const watcher = nodeFs.watch(directory, (eventType, filename) => {
452
+ if (!this.#isCurrent(binding) || binding.watchers.get(directory) !== watcher) return;
453
+ if (filename === null) {
454
+ this.#invalidateDescendantWatchers(binding, directory);
455
+ this.#scheduleScan(binding);
456
+ return;
457
+ }
458
+ const name = filename.toString();
459
+ if (eventType === "rename" && name === path.basename(directory)) {
460
+ binding.watchers.delete(directory);
461
+ binding.watcherIdentities.delete(directory);
462
+ watcher.close();
463
+ this.#invalidateDescendantWatchers(binding, directory);
464
+ this.#scheduleScan(binding);
465
+ return;
466
+ }
467
+ if (!this.#matchesWatchedPath(binding, directory, name)) return;
468
+ if (eventType === "rename") {
469
+ this.#invalidateDescendantWatchers(binding, path.join(directory, name), true);
470
+ }
471
+ this.#scheduleScan(binding);
472
+ });
473
+ watcher.unref?.();
474
+ watcher.on("error", error => {
475
+ if (!this.#isCurrent(binding) || binding.watchers.get(directory) !== watcher) return;
476
+ binding.watchers.delete(directory);
477
+ binding.watcherIdentities.delete(directory);
478
+ watcher.close();
479
+ this.#report("watch", error, directory);
480
+ this.#scheduleWatcherRecovery(binding);
481
+ });
482
+ binding.watchers.set(directory, watcher);
483
+ binding.watcherIdentities.set(directory, identity);
484
+ this.#clearErrors("watch", directory);
485
+ } catch (error) {
486
+ if (!this.#isCurrent(binding)) return;
487
+ attempted.add(directory);
488
+ this.#report("watch", error, directory);
489
+ this.#scheduleWatcherRecovery(binding);
490
+ }
491
+ }
492
+
493
+ #scheduleWatcherRecovery(binding: Binding): void {
494
+ if (!this.#isCurrent(binding) || binding.watcherRecoveryTimer) return;
495
+ const delay = binding.watcherRecoveryDelayMs;
496
+ binding.watcherRecoveryDelayMs = Math.min(delay * 2, WATCH_RECOVERY_MAX_MS);
497
+ binding.watcherRecoveryTimer = setTimeout(() => {
498
+ binding.watcherRecoveryTimer = undefined;
499
+ if (this.#isCurrent(binding)) void this.#scan(binding);
500
+ }, delay);
501
+ binding.watcherRecoveryTimer.unref?.();
502
+ }
503
+
504
+ #clearWatcherRecovery(binding: Binding): void {
505
+ if (binding.watcherRecoveryTimer) clearTimeout(binding.watcherRecoveryTimer);
506
+ binding.watcherRecoveryTimer = undefined;
507
+ binding.watcherRecoveryDelayMs = WATCH_RECOVERY_INITIAL_MS;
508
+ }
509
+
510
+ #cancelApplyRetry(binding: Binding): void {
511
+ if (binding.applyRetryTimer) clearTimeout(binding.applyRetryTimer);
512
+ binding.applyRetryTimer = undefined;
513
+ binding.applyRetryCandidate = undefined;
514
+ }
515
+
516
+ #matchesWatchedPath(binding: Binding, directory: string, filename: string): boolean {
517
+ for (const filePath of binding.watchTargets) {
518
+ if (nextPathComponent(directory, filePath) === filename) return true;
519
+ }
520
+ return false;
521
+ }
522
+
523
+ #invalidateDescendantWatchers(binding: Binding, directory: string, includeDirectory = false): void {
524
+ for (const [watchedDirectory, watcher] of binding.watchers) {
525
+ if (watchedDirectory !== directory && !nextPathComponent(directory, watchedDirectory)) continue;
526
+ if (watchedDirectory === directory && !includeDirectory) continue;
527
+ watcher.close();
528
+ binding.watchers.delete(watchedDirectory);
529
+ binding.watcherIdentities.delete(watchedDirectory);
530
+ }
531
+ }
532
+
533
+ #scheduleScan(binding: Binding): void {
534
+ if (!this.#isCurrent(binding)) return;
535
+ const now = performance.now();
536
+ binding.debounceStartedAt ??= now;
537
+ if (binding.debounceTimer) clearTimeout(binding.debounceTimer);
538
+ const deadline = Math.min(now + DEBOUNCE_QUIET_MS, binding.debounceStartedAt + DEBOUNCE_MAX_MS);
539
+ binding.debounceTimer = setTimeout(
540
+ () => {
541
+ binding.debounceTimer = undefined;
542
+ binding.debounceStartedAt = undefined;
543
+ void this.#scan(binding);
544
+ },
545
+ Math.max(0, deadline - now),
546
+ );
547
+ binding.debounceTimer.unref?.();
548
+ }
549
+
550
+ async #scan(binding: Binding): Promise<void> {
551
+ if (!this.#isCurrent(binding)) return;
552
+ if (binding.initializing || binding.scanning) {
553
+ binding.scanRequested = true;
554
+ return;
555
+ }
556
+ binding.scanning = true;
557
+ try {
558
+ do {
559
+ binding.scanRequested = false;
560
+ await this.#refreshWatchers(binding);
561
+ if (!this.#isCurrent(binding)) return;
562
+ let sources: FileSources;
563
+ try {
564
+ sources = await this.#readSources(binding);
565
+ } catch {
566
+ if (!this.#isCurrent(binding)) return;
567
+ continue;
568
+ }
569
+ if (!this.#isCurrent(binding)) return;
570
+ const identity = pairIdentity(sources);
571
+ if (binding.applyRetryCandidate && candidateIdentity(binding.applyRetryCandidate) !== identity) {
572
+ this.#cancelApplyRetry(binding);
573
+ }
574
+ const recoveringInitialRead = binding.baselineIdentity === undefined && binding.initialReadFailed;
575
+ if (binding.baselineIdentity === undefined) {
576
+ binding.baselineIdentity = identity;
577
+ if (!recoveringInitialRead) continue;
578
+ binding.initialReadFailed = false;
579
+ }
580
+ if (!recoveringInitialRead && binding.baselineIdentity === identity) continue;
581
+ binding.baselineIdentity = identity;
582
+ this.#validateCandidate(binding, this.#candidate(sources));
583
+ } while (binding.scanRequested && this.#isCurrent(binding));
584
+ } finally {
585
+ binding.scanning = false;
586
+ if (binding.scanRequested && this.#isCurrent(binding)) void this.#scan(binding);
587
+ }
588
+ }
589
+
590
+ #validateCandidate(binding: Binding, candidate: ConfigHotReloadCandidate): void {
591
+ const resource = pathsIdentity(binding.paths);
592
+ void Promise.resolve()
593
+ .then(() => {
594
+ if (!this.#isCurrent(binding)) return;
595
+ return this.#onValidate(candidate);
596
+ })
597
+ .then(() => {
598
+ if (!this.#isCurrent(binding)) return;
599
+ this.#clearErrors("validate", resource);
600
+ this.#queueCandidate(binding, candidate);
601
+ })
602
+ .catch(error => {
603
+ if (this.#isCurrent(binding)) this.#report("validate", error, resource);
604
+ });
605
+ }
606
+
607
+ #queueCandidate(binding: Binding, candidate: ConfigHotReloadCandidate): void {
608
+ const pendingRevision = this.#pending?.binding === binding ? this.#pending.candidate.revision : -1;
609
+ const runningRevision = this.#running?.binding === binding ? this.#running.candidate.revision : -1;
610
+ if (candidate.revision <= Math.max(this.#highestQueuedRevision, pendingRevision, runningRevision)) return;
611
+ if (binding.applyRetryCandidate && candidate.revision > binding.applyRetryCandidate.revision) {
612
+ this.#cancelApplyRetry(binding);
613
+ }
614
+ this.#highestQueuedRevision = candidate.revision;
615
+ this.#pending = { binding, candidate, applyAttempt: 0 };
616
+ const running = this.#running;
617
+ if (running && (running.binding !== binding || running.candidate.revision < candidate.revision)) {
618
+ running.controller.abort();
619
+ }
620
+ this.#pump();
621
+ }
622
+
623
+ #pump(): void {
624
+ if (this.#disposed || this.#running || !this.#pending || this.#pending.binding.initializing) {
625
+ return;
626
+ }
627
+ const pending = this.#pending;
628
+ this.#pending = undefined;
629
+ if (!this.#isCurrent(pending.binding)) return;
630
+ const running: RunningCandidate = { ...pending, controller: new AbortController() };
631
+ this.#running = running;
632
+ let succeeded = false;
633
+ void Promise.resolve()
634
+ .then(async () => {
635
+ if (!this.#isCurrent(running.binding) || running.controller.signal.aborted) return;
636
+ await this.#onCandidate(running.candidate, running.controller.signal);
637
+ succeeded = true;
638
+ })
639
+ .catch(error => {
640
+ if (!running.controller.signal.aborted && this.#isCurrent(running.binding)) {
641
+ this.#report("apply", error, pathsIdentity(running.binding.paths));
642
+ if (transientApplyFailure(error)) this.#scheduleApplyRetry(running);
643
+ }
644
+ })
645
+ .finally(() => {
646
+ if (this.#running === running) this.#running = undefined;
647
+ if (succeeded && !running.controller.signal.aborted && this.#isCurrent(running.binding)) {
648
+ this.#clearErrors("apply", pathsIdentity(running.binding.paths));
649
+ }
650
+ this.#pump();
651
+ });
652
+ }
653
+
654
+ #scheduleApplyRetry(running: RunningCandidate): void {
655
+ const { binding, candidate } = running;
656
+ const delay = APPLY_RETRY_DELAYS_MS[running.applyAttempt];
657
+ if (
658
+ delay === undefined ||
659
+ !this.#isCurrent(binding) ||
660
+ running.controller.signal.aborted ||
661
+ binding.baselineIdentity !== candidateIdentity(candidate) ||
662
+ this.#highestQueuedRevision > candidate.revision
663
+ ) {
664
+ return;
665
+ }
666
+ this.#cancelApplyRetry(binding);
667
+ const nextAttempt = running.applyAttempt + 1;
668
+ binding.applyRetryCandidate = candidate;
669
+ binding.applyRetryTimer = setTimeout(() => {
670
+ binding.applyRetryTimer = undefined;
671
+ void this.#queueApplyRetry(binding, candidate, nextAttempt);
672
+ }, delay);
673
+ binding.applyRetryTimer.unref?.();
674
+ }
675
+
676
+ async #queueApplyRetry(binding: Binding, candidate: ConfigHotReloadCandidate, applyAttempt: number): Promise<void> {
677
+ if (
678
+ !this.#isCurrent(binding) ||
679
+ binding.applyRetryCandidate !== candidate ||
680
+ binding.baselineIdentity !== candidateIdentity(candidate) ||
681
+ this.#highestQueuedRevision > candidate.revision
682
+ ) {
683
+ this.#cancelApplyRetry(binding);
684
+ return;
685
+ }
686
+ let sources: FileSources;
687
+ try {
688
+ sources = await this.#readSources(binding);
689
+ } catch {
690
+ this.#cancelApplyRetry(binding);
691
+ if (this.#isCurrent(binding)) this.#scheduleScan(binding);
692
+ return;
693
+ }
694
+ if (
695
+ !this.#isCurrent(binding) ||
696
+ binding.applyRetryCandidate !== candidate ||
697
+ binding.baselineIdentity !== candidateIdentity(candidate) ||
698
+ pairIdentity(sources) !== candidateIdentity(candidate) ||
699
+ this.#highestQueuedRevision > candidate.revision
700
+ ) {
701
+ this.#cancelApplyRetry(binding);
702
+ if (this.#isCurrent(binding)) this.#scheduleScan(binding);
703
+ return;
704
+ }
705
+ this.#cancelApplyRetry(binding);
706
+ this.#pending = { binding, candidate, applyAttempt };
707
+ this.#pump();
708
+ }
709
+
710
+ #errorKey(operation: ConfigHotReloadErrorOperation, resource: string | undefined, code: string | undefined): string {
711
+ const resourceIdentity = createHash("sha256")
712
+ .update(resource ?? "global")
713
+ .digest("hex");
714
+ return `${operation}:${resourceIdentity}:${code ?? "unknown"}`;
715
+ }
716
+
717
+ #clearErrors(operation: ConfigHotReloadErrorOperation, resource: string): void {
718
+ const prefix = this.#errorKey(operation, resource, undefined).slice(0, -"unknown".length);
719
+ for (const key of this.#reportedErrors) {
720
+ if (key.startsWith(prefix)) this.#reportedErrors.delete(key);
721
+ }
722
+ }
723
+
724
+ #report(operation: ConfigHotReloadErrorOperation, error: unknown, resource?: string): void {
725
+ const key = this.#errorKey(operation, resource, failureCode(error));
726
+ if (this.#reportedErrors.has(key)) return;
727
+ this.#reportedErrors.add(key);
728
+ try {
729
+ this.#onError(new ConfigHotReloadError(operation, failureCode(error)));
730
+ } catch {
731
+ // Diagnostics must not destabilize the watcher.
732
+ }
733
+ }
734
+ }