@sigrun/sigrun 0.0.0-stage → 0.1.0

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 (130) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +90 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
@@ -0,0 +1,1053 @@
1
+ /**
2
+ * The sigrun settings store (Task 8a, pi parity spec: the settings file).
3
+ *
4
+ * SPLIT (build-and-webui Task 1): the settings-file TYPES
5
+ * (`SettingsFile`, `ServerSettings`, `DefaultProviderSettings`,
6
+ * `ExtensionSpec`) live in the `@sigrun/shared` package
7
+ * (`shared/src/settings.ts` — the canonical declarations, re-exported
8
+ * below); this module owns the store/validation RUNTIME.
9
+ *
10
+ * A JSON file at `~/.config/sigrun/settings.json` (the config-dir
11
+ * convention; `SIGRUN_CONFIG_DIR` overrides the directory for hermetic
12
+ * tests, matching the repo's env-override style). The store carries the
13
+ * `HarnessSettings` pass-throughs (`steeringMode`, `followUpMode`,
14
+ * `retry`, `stream`) plus sigrun's own resolved fields:
15
+ *
16
+ * - `defaultTools` — a tool-name filter applied at conversation-create
17
+ * time (the server reads it from the store, resolves the names against
18
+ * the registry, and passes them as `AgentChange.modelTools` objects —
19
+ * the MODEL-OFFERED filter, pi-durable 1.3.0; NOT the enabled filter,
20
+ * so filtered-out tools stay callable by nested calls).
21
+ * - `perModelThinking` — per-model thinking-level overrides (`"provider/
22
+ * modelId": level`), applied by the server at conversation create and
23
+ * configure.
24
+ * - `server` — the static boot configuration (port, workspace/storage/
25
+ * extensions paths, compaction policy, backup target). The file is the
26
+ * deployment default UNDER env: `loadConfig(env, file)` applies a
27
+ * `server.*` field only where the corresponding `SIGRUN_*` variable is
28
+ * absent. Changes to `server.*` require a restart (they resolve at
29
+ * boot, unlike the live steeringMode/retry/etc. pass-throughs).
30
+ *
31
+ * The remaining boot knobs are file-configurable too (the 2026-10-13
32
+ * config-surface rulings): top-level `extensions` (the extension package
33
+ * list), top-level `defaultModel` (the boot default model — env or file,
34
+ * one is REQUIRED, no code default), `server.webuiBundle`, and
35
+ * `defaultProvider.baseUrl` (the env-driven OpenAI-compatible provider's
36
+ * endpoint). Env still wins per setting. Credentials live in the sibling
37
+ * `auth.json` (see src/harness/auth.ts), never in this file. The file's
38
+ * own location stays env-only (`SIGRUN_CONFIG_DIR`).
39
+ *
40
+ * Contract (pi-durable's own): "HarnessSettings is read at every
41
+ * resolution and never copied." So the store is a GETTER, not a snapshot:
42
+ * `harnessSettings()` re-reads the file on every call, and `openHarness`
43
+ * passes a live object whose property getters fetch fresh values — a
44
+ * PATCH becomes effective on the next submission/admission without a
45
+ * restart.
46
+ *
47
+ * Absent file = all defaults (empty store). Malformed file = fail-loud
48
+ * at boot (like `loadConfig`), with a distinct error for a well-formed
49
+ * JSON object carrying wrong-shaped or unknown fields.
50
+ */
51
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
52
+ import { dirname, join } from "node:path";
53
+ import { resolveStoreRoots } from "./stores.js";
54
+ import { isAbsoluteHttpUrl } from "../config.js";
55
+ import { isIanaTimeZone, validateJobSpec } from "../jobs/spec.js";
56
+ /** The queue modes pi-durable accepts (its `QueueMode`). */
57
+ const QUEUE_MODES = ["all", "one-at-a-time"];
58
+ /**
59
+ * The `notifications.runSettled` vocabulary (W5 Task 6, spec §9.6) — a
60
+ * list for the same reason `QUEUE_MODES` is one: `includes` on a `const`
61
+ * tuple narrows to the literal union, which an unknown file value can
62
+ * never satisfy.
63
+ */
64
+ const RUN_SETTLED_MODES = [
65
+ "unwatched",
66
+ "cron",
67
+ "always",
68
+ "never",
69
+ ];
70
+ /** The thinking levels the configure surface accepts (pi-ai's union). */
71
+ const THINKING_LEVELS = [
72
+ "off",
73
+ "minimal",
74
+ "low",
75
+ "medium",
76
+ "high",
77
+ "xhigh",
78
+ "max",
79
+ ];
80
+ /** Every settable key (PATCH validation + GET projection). */
81
+ const SETTING_KEYS = [
82
+ "steeringMode",
83
+ "followUpMode",
84
+ "retry",
85
+ "stream",
86
+ "defaultTools",
87
+ "perModelThinking",
88
+ "extensions",
89
+ "jobs",
90
+ "jobHistory",
91
+ "defaultModel",
92
+ "namingModel",
93
+ "defaultProvider",
94
+ "skills",
95
+ "notifications",
96
+ "builtinExtensions",
97
+ "autoEnableCodemode",
98
+ "server",
99
+ ];
100
+ /**
101
+ * Warn once per process when the deprecated `server.webuiBundle` alias
102
+ * appears in a validated file (build-and-webui Task 4, one release):
103
+ * `read()` validates on EVERY access (the live-getter contract), so an
104
+ * ungated warn would fire per resolution. Module-level, like
105
+ * `warnDeprecatedModelEnv` in src/config.ts.
106
+ */
107
+ let deprecatedWebuiBundleWarned = false;
108
+ function warnDeprecatedWebuiBundle() {
109
+ if (deprecatedWebuiBundleWarned)
110
+ return;
111
+ deprecatedWebuiBundleWarned = true;
112
+ console.warn("settings: server.webuiBundle is deprecated, use server.webui.bundle (reads as the same selector for one release)");
113
+ }
114
+ /**
115
+ * Warn once per process when the file carries the retired
116
+ * `server.extensionsDir` key (§6.7): accepted for one release so existing
117
+ * files keep validating, warned because nothing reads it.
118
+ */
119
+ let deprecatedExtensionsDirWarned = false;
120
+ function warnDeprecatedExtensionsDir() {
121
+ if (deprecatedExtensionsDirWarned)
122
+ return;
123
+ deprecatedExtensionsDirWarned = true;
124
+ console.warn("settings: server.extensionsDir is deprecated and ignored (nothing reads the extensions dir)");
125
+ }
126
+ /**
127
+ * Default settings directory: the ONE config-root chain
128
+ * (`resolveStoreRoots`, spec Decision 3) — `SIGRUN_CONFIG_DIR` →
129
+ * `$XDG_CONFIG_HOME`/sigrun → `~/.config/sigrun`.
130
+ */
131
+ export function defaultSettingsDir() {
132
+ return resolveStoreRoots(process.env).configDir;
133
+ }
134
+ /** Default file path: `<dir>/settings.json`. */
135
+ export function settingsPath(dir) {
136
+ return join(dir, "settings.json");
137
+ }
138
+ /**
139
+ * A field-level validation error. The server maps these to 400
140
+ * `invalid_field` + the offending key (matching the repo's other PATCH
141
+ * surfaces); `openHarness`'s boot-time read lets them throw (fail loud).
142
+ */
143
+ export class SettingsValidationError extends Error {
144
+ field;
145
+ constructor(field, reason) {
146
+ super(`settings: ${field} ${reason}`);
147
+ this.field = field;
148
+ }
149
+ }
150
+ /** Validate one `SettingsFile`-shaped object; every field checked in place. */
151
+ function validate(value) {
152
+ for (const key of Object.keys(value)) {
153
+ if (!SETTING_KEYS.includes(key)) {
154
+ throw new SettingsValidationError(key, "is not a settable field");
155
+ }
156
+ }
157
+ const queueMode = (field) => {
158
+ const v = value[field];
159
+ if (v !== undefined &&
160
+ (typeof v !== "string" || !QUEUE_MODES.includes(v))) {
161
+ throw new SettingsValidationError(field, 'must be "all" or "one-at-a-time"');
162
+ }
163
+ };
164
+ queueMode("steeringMode");
165
+ queueMode("followUpMode");
166
+ if (value.retry !== undefined) {
167
+ const retry = value.retry;
168
+ if (typeof retry !== "object" || retry === null || Array.isArray(retry)) {
169
+ throw new SettingsValidationError("retry", "must be an object");
170
+ }
171
+ const retryRecord = retry;
172
+ for (const key of Object.keys(retryRecord)) {
173
+ if (!["enabled", "maxRetries", "baseDelayMs", "maxAgentDelayMs"].includes(key)) {
174
+ throw new SettingsValidationError(`retry.${key}`, "is not a retry field");
175
+ }
176
+ }
177
+ if (retryRecord.maxRetries !== undefined && !isNonNegativeInt(retryRecord.maxRetries)) {
178
+ throw new SettingsValidationError("retry.maxRetries", "must be a non-negative integer");
179
+ }
180
+ if (retryRecord.baseDelayMs !== undefined && !isNonNegativeInt(retryRecord.baseDelayMs)) {
181
+ throw new SettingsValidationError("retry.baseDelayMs", "must be a non-negative integer");
182
+ }
183
+ if (retryRecord.maxAgentDelayMs !== undefined && !isNonNegativeInt(retryRecord.maxAgentDelayMs)) {
184
+ throw new SettingsValidationError("retry.maxAgentDelayMs", "must be a non-negative integer");
185
+ }
186
+ if (retryRecord.enabled !== undefined && typeof retryRecord.enabled !== "boolean") {
187
+ throw new SettingsValidationError("retry.enabled", "must be a boolean");
188
+ }
189
+ }
190
+ if (value.stream !== undefined) {
191
+ const stream = value.stream;
192
+ if (typeof stream !== "object" || stream === null || Array.isArray(stream)) {
193
+ throw new SettingsValidationError("stream", "must be an object");
194
+ }
195
+ const streamRecord = stream;
196
+ for (const key of Object.keys(streamRecord)) {
197
+ if (!["timeoutMs", "maxRetries", "maxRetryDelayMs"].includes(key)) {
198
+ throw new SettingsValidationError(`stream.${key}`, "is not a stream field");
199
+ }
200
+ }
201
+ for (const key of ["timeoutMs", "maxRetries", "maxRetryDelayMs"]) {
202
+ if (streamRecord[key] !== undefined && !isNonNegativeInt(streamRecord[key])) {
203
+ throw new SettingsValidationError(`stream.${key}`, "must be a non-negative integer");
204
+ }
205
+ }
206
+ }
207
+ if (value.defaultTools !== undefined) {
208
+ const tools = value.defaultTools;
209
+ if (!Array.isArray(tools) ||
210
+ tools.some((name) => typeof name !== "string" || name === "")) {
211
+ throw new SettingsValidationError("defaultTools", "must be an array of tool names");
212
+ }
213
+ }
214
+ if (value.perModelThinking !== undefined) {
215
+ const perModel = value.perModelThinking;
216
+ if (typeof perModel !== "object" || perModel === null || Array.isArray(perModel)) {
217
+ throw new SettingsValidationError("perModelThinking", "must be an object");
218
+ }
219
+ for (const [model, level] of Object.entries(perModel)) {
220
+ if (typeof level !== "string" ||
221
+ !THINKING_LEVELS.includes(level)) {
222
+ throw new SettingsValidationError(`perModelThinking.${model}`, `must be one of ${THINKING_LEVELS.join(", ")}`);
223
+ }
224
+ }
225
+ }
226
+ if (value.extensions !== undefined) {
227
+ const extensions = value.extensions;
228
+ if (!Array.isArray(extensions)) {
229
+ throw new SettingsValidationError("extensions", "must be an array of extension specifiers");
230
+ }
231
+ for (const entry of extensions) {
232
+ if (typeof entry === "string") {
233
+ if (entry === "") {
234
+ throw new SettingsValidationError("extensions", "must not contain an empty specifier");
235
+ }
236
+ continue;
237
+ }
238
+ if (typeof entry !== "object" || entry === null) {
239
+ throw new SettingsValidationError("extensions", "each entry must be a string or an object");
240
+ }
241
+ const spec = entry;
242
+ if (typeof spec.source !== "string" || spec.source === "") {
243
+ throw new SettingsValidationError("extensions", "object entries need a non-empty source");
244
+ }
245
+ for (const key of ["entry", "ref"]) {
246
+ const v = spec[key];
247
+ if (v !== undefined && (typeof v !== "string" || v === "")) {
248
+ throw new SettingsValidationError("extensions", `object entry ${key} must be a non-empty string when present`);
249
+ }
250
+ }
251
+ if (spec.enabled !== undefined &&
252
+ typeof spec.enabled !== "boolean") {
253
+ throw new SettingsValidationError("extensions", "object entry enabled must be a boolean");
254
+ }
255
+ const known = new Set(["source", "entry", "ref", "enabled"]);
256
+ for (const key of Object.keys(spec)) {
257
+ if (!known.has(key)) {
258
+ throw new SettingsValidationError("extensions", `unknown object-entry key: ${key}`);
259
+ }
260
+ }
261
+ }
262
+ }
263
+ // The file-declared job list (jobs §8.3): each entry is a full
264
+ // `validateJobSpec` run (the §8.2 grammar), plus the source-level
265
+ // rules — duplicate names are a validation error, and the whole list
266
+ // is restart-required like `extensions` (boot reconciliation owns it).
267
+ if (value.jobs !== undefined) {
268
+ const jobs = value.jobs;
269
+ if (!Array.isArray(jobs)) {
270
+ throw new SettingsValidationError("jobs", "must be an array of job specs");
271
+ }
272
+ const seen = new Set();
273
+ for (const entry of jobs) {
274
+ const result = validateJobSpec(entry);
275
+ if (!result.ok) {
276
+ throw new SettingsValidationError(`jobs`, `${result.field} ${result.message}`);
277
+ }
278
+ if (seen.has(result.spec.name)) {
279
+ throw new SettingsValidationError("jobs", `duplicate job name "${result.spec.name}"`);
280
+ }
281
+ seen.add(result.spec.name);
282
+ }
283
+ }
284
+ // The live job-history retention knobs (§8.1, ruling 14): both
285
+ // positive integers when present.
286
+ if (value.jobHistory !== undefined) {
287
+ const jobHistory = asRecord(value.jobHistory);
288
+ if (jobHistory === undefined) {
289
+ throw new SettingsValidationError("jobHistory", "must be an object");
290
+ }
291
+ checkKeys("jobHistory.", jobHistory, ["runs", "revisions"]);
292
+ for (const key of ["runs", "revisions"]) {
293
+ const v = jobHistory[key];
294
+ if (v !== undefined && !isPositiveInt(v)) {
295
+ throw new SettingsValidationError(`jobHistory.${key}`, "must be a positive integer");
296
+ }
297
+ }
298
+ }
299
+ if (value.defaultModel !== undefined) {
300
+ const spec = value.defaultModel;
301
+ if (typeof spec !== "string" || spec.trim() === "") {
302
+ throw new SettingsValidationError("defaultModel", "must be a non-empty provider/model-id string");
303
+ }
304
+ // Same fail-loud spec rules as SIGRUN_DEFAULT_MODEL
305
+ // (config.parseModelSpec), with the file key named in the error.
306
+ const trimmed = spec.trim();
307
+ const slash = trimmed.indexOf("/");
308
+ if (slash !== -1 &&
309
+ (trimmed.slice(0, slash) === "" || trimmed.slice(slash + 1) === "")) {
310
+ throw new SettingsValidationError("defaultModel", "must have a non-empty provider and model id");
311
+ }
312
+ }
313
+ if (value.namingModel !== undefined && value.namingModel !== false) {
314
+ // Exactly defaultModel's spec rules, plus the `false` disable flag
315
+ // (checked before the string checks, so only the spec shapes fall
316
+ // through to them).
317
+ const spec = value.namingModel;
318
+ if (typeof spec !== "string" || spec.trim() === "") {
319
+ throw new SettingsValidationError("namingModel", "must be a non-empty provider/model-id string or false");
320
+ }
321
+ const trimmed = spec.trim();
322
+ const slash = trimmed.indexOf("/");
323
+ if (slash !== -1 &&
324
+ (trimmed.slice(0, slash) === "" || trimmed.slice(slash + 1) === "")) {
325
+ throw new SettingsValidationError("namingModel", "must have a non-empty provider and model id");
326
+ }
327
+ }
328
+ if (value.defaultProvider !== undefined) {
329
+ const provider = asRecord(value.defaultProvider);
330
+ if (provider === undefined) {
331
+ throw new SettingsValidationError("defaultProvider", "must be an object");
332
+ }
333
+ checkKeys("defaultProvider.", provider, ["baseUrl"]);
334
+ if (provider.baseUrl !== undefined && !isNonEmptyString(provider.baseUrl)) {
335
+ throw new SettingsValidationError("defaultProvider.baseUrl", "must be a non-empty string");
336
+ }
337
+ }
338
+ // The skills surface (W5 Task 4, spec §9.4): a live, flat section —
339
+ // `sectionMaxBytes` a positive integer.
340
+ if (value.skills !== undefined) {
341
+ const skills = asRecord(value.skills);
342
+ if (skills === undefined) {
343
+ throw new SettingsValidationError("skills", "must be an object");
344
+ }
345
+ checkKeys("skills.", skills, ["sectionMaxBytes"]);
346
+ if (skills.sectionMaxBytes !== undefined &&
347
+ !isPositiveInt(skills.sectionMaxBytes)) {
348
+ throw new SettingsValidationError("skills.sectionMaxBytes", "must be a positive integer");
349
+ }
350
+ }
351
+ // The notifications surface (W5 Task 6, spec §9.6): a live section —
352
+ // `runSettled` one of the four modes.
353
+ if (value.notifications !== undefined) {
354
+ const notifications = asRecord(value.notifications);
355
+ if (notifications === undefined) {
356
+ throw new SettingsValidationError("notifications", "must be an object");
357
+ }
358
+ checkKeys("notifications.", notifications, ["runSettled"]);
359
+ if (notifications.runSettled !== undefined &&
360
+ (typeof notifications.runSettled !== "string" ||
361
+ !RUN_SETTLED_MODES.includes(notifications.runSettled))) {
362
+ throw new SettingsValidationError("notifications.runSettled", `must be one of ${RUN_SETTLED_MODES.join(", ")}`);
363
+ }
364
+ }
365
+ if (value.builtinExtensions !== undefined) {
366
+ const builtins = asRecord(value.builtinExtensions);
367
+ if (builtins === undefined) {
368
+ throw new SettingsValidationError("builtinExtensions", "must be an object");
369
+ }
370
+ checkKeys("builtinExtensions.", builtins, ["codemode", "mcp", "toolSearch"]);
371
+ for (const key of ["codemode", "mcp", "toolSearch"]) {
372
+ if (builtins[key] !== undefined && typeof builtins[key] !== "boolean") {
373
+ throw new SettingsValidationError(`builtinExtensions.${key}`, "must be a boolean");
374
+ }
375
+ }
376
+ }
377
+ if (value.autoEnableCodemode !== undefined &&
378
+ typeof value.autoEnableCodemode !== "boolean") {
379
+ throw new SettingsValidationError("autoEnableCodemode", "must be a boolean");
380
+ }
381
+ if (value.server !== undefined) {
382
+ validateServer(value.server);
383
+ }
384
+ }
385
+ /**
386
+ * Validate the `server` section in place (the rules mirror `src/config.ts`'s
387
+ * env parsing exactly: port a positive integer, paths non-empty strings,
388
+ * compaction token counts integers with the same minimums —
389
+ * `backgroundTokens` keeps its designed "0 = background off" minimum —
390
+ * backup exactly one of the two target shapes, like the env XOR rule).
391
+ */
392
+ function validateServer(server) {
393
+ const record = asRecord(server);
394
+ if (record === undefined) {
395
+ throw new SettingsValidationError("server", "must be an object");
396
+ }
397
+ checkKeys("server.", record, [
398
+ "port",
399
+ "workspacePath",
400
+ "storagePath",
401
+ "extensionsDir",
402
+ "maxUploadBytes",
403
+ "maxFileReadBytes",
404
+ "dataDir",
405
+ "cacheDir",
406
+ "stateDir",
407
+ "publicUrl",
408
+ "timezone",
409
+ "webui",
410
+ "webuiBundle",
411
+ "compaction",
412
+ "memorySeam",
413
+ "providers",
414
+ "auth",
415
+ "metrics",
416
+ "backup",
417
+ ]);
418
+ // The jobs default timezone (§8.2): an IANA name — `Intl` accepts
419
+ // exactly the names croner's evaluation path resolves, and throws on
420
+ // everything else, so the gate is one construction. Narrowed to a
421
+ // string first: `asRecord`'s unknown value type flows `null` here
422
+ // otherwise.
423
+ const timezone = record.timezone === undefined || record.timezone === null
424
+ ? undefined
425
+ : record.timezone;
426
+ if (timezone !== undefined && !isIanaTimeZone(timezone)) {
427
+ throw new SettingsValidationError("server.timezone", `must be an IANA name (got ${JSON.stringify(record.timezone)})`);
428
+ }
429
+ if (record.port !== undefined && !isPositiveInt(record.port)) {
430
+ throw new SettingsValidationError("server.port", "must be a positive integer");
431
+ }
432
+ // The upload knob (spec §6.2): same rule as the env parse — a positive
433
+ // integer; `Math.ceil(max*4/3)+4096` derives the base64/import limits.
434
+ if (record.maxUploadBytes !== undefined &&
435
+ !isPositiveInt(record.maxUploadBytes)) {
436
+ throw new SettingsValidationError("server.maxUploadBytes", "must be a positive integer");
437
+ }
438
+ // The raw file-read cap (spec §7.8): same rule as the upload knob — a
439
+ // positive integer.
440
+ if (record.maxFileReadBytes !== undefined &&
441
+ !isPositiveInt(record.maxFileReadBytes)) {
442
+ throw new SettingsValidationError("server.maxFileReadBytes", "must be a positive integer");
443
+ }
444
+ for (const key of ["workspacePath", "storagePath", "extensionsDir", "dataDir", "cacheDir", "stateDir"]) {
445
+ if (record[key] !== undefined && !isNonEmptyString(record[key])) {
446
+ throw new SettingsValidationError(`server.${key}`, "must be a non-empty string");
447
+ }
448
+ }
449
+ // The absolute base URL (W5 Task 2): the `SIGRUN_PUBLIC_URL` grammar —
450
+ // an absolute http(s) URL with a hostname. A trailing slash is NOT a
451
+ // validation error (config resolution strips it); a non-URL is.
452
+ if (record.publicUrl !== undefined &&
453
+ (typeof record.publicUrl !== "string" ||
454
+ !isAbsoluteHttpUrl(record.publicUrl))) {
455
+ throw new SettingsValidationError("server.publicUrl", "must be an absolute http(s) URL");
456
+ }
457
+ // The retired `extensionsDir` key (§6.7) stays ACCEPTED for one
458
+ // release (existing files keep validating) but warns — never throws —
459
+ // exactly like the legacy `webuiBundle` alias below; nothing reads it.
460
+ if (record.extensionsDir !== undefined) {
461
+ warnDeprecatedExtensionsDir();
462
+ }
463
+ // The webui channel surface (build-and-webui Task 4). The LEGACY
464
+ // `webuiBundle` key stays ACCEPTED for one release (a read-side alias
465
+ // for `webui.bundle` in config resolution) but warns — never throws —
466
+ // so existing deployments' files keep booting while Task 7's docs
467
+ // migrate them.
468
+ if (record.webuiBundle !== undefined) {
469
+ if (!isNonEmptyString(record.webuiBundle)) {
470
+ throw new SettingsValidationError("server.webuiBundle", "must be a non-empty string");
471
+ }
472
+ warnDeprecatedWebuiBundle();
473
+ }
474
+ if (record.webui !== undefined) {
475
+ const webui = asRecord(record.webui);
476
+ if (webui === undefined) {
477
+ throw new SettingsValidationError("server.webui", "must be an object");
478
+ }
479
+ checkKeys("server.webui.", webui, ["enabled", "bundle"]);
480
+ if (webui.enabled !== undefined && typeof webui.enabled !== "boolean") {
481
+ throw new SettingsValidationError("server.webui.enabled", "must be a boolean");
482
+ }
483
+ // Non-empty only — name/path discrimination is Task 5's
484
+ // serve-time concern, not a file-validation rule.
485
+ if (webui.bundle !== undefined && !isNonEmptyString(webui.bundle)) {
486
+ throw new SettingsValidationError("server.webui.bundle", "must be a non-empty string");
487
+ }
488
+ }
489
+ if (record.compaction !== undefined) {
490
+ const compaction = asRecord(record.compaction);
491
+ if (compaction === undefined) {
492
+ throw new SettingsValidationError("server.compaction", "must be an object");
493
+ }
494
+ checkKeys("server.compaction.", compaction, [
495
+ "enabled",
496
+ "reserveTokens",
497
+ "keepRecentTokens",
498
+ "backgroundTokens",
499
+ ]);
500
+ if (compaction.enabled !== undefined && typeof compaction.enabled !== "boolean") {
501
+ throw new SettingsValidationError("server.compaction.enabled", "must be a boolean");
502
+ }
503
+ for (const key of ["reserveTokens", "keepRecentTokens", "backgroundTokens"]) {
504
+ const v = compaction[key];
505
+ if (v === undefined)
506
+ continue;
507
+ // backgroundTokens' minimum is 0 — the designed "background off"
508
+ // value, same as `SIGRUN_COMPACTION_BACKGROUND_TOKENS`.
509
+ const minimum = key === "backgroundTokens" ? 0 : 1;
510
+ if (typeof v !== "number" ||
511
+ !Number.isInteger(v) ||
512
+ v < minimum) {
513
+ throw new SettingsValidationError(`server.compaction.${key}`, minimum === 0 ? "must be a non-negative integer" : "must be a positive integer");
514
+ }
515
+ }
516
+ }
517
+ if (record.memorySeam !== undefined) {
518
+ const memorySeam = asRecord(record.memorySeam);
519
+ if (memorySeam === undefined) {
520
+ throw new SettingsValidationError("server.memorySeam", "must be an object");
521
+ }
522
+ checkKeys("server.memorySeam.", memorySeam, [
523
+ "hookDeadlineMs",
524
+ "idleThresholdMs",
525
+ "workerDeadlineMs",
526
+ "queueMax",
527
+ ]);
528
+ for (const key of [
529
+ "hookDeadlineMs",
530
+ "idleThresholdMs",
531
+ "workerDeadlineMs",
532
+ "queueMax",
533
+ ]) {
534
+ const v = memorySeam[key];
535
+ if (v !== undefined && !isPositiveInt(v)) {
536
+ throw new SettingsValidationError(`server.memorySeam.${key}`, "must be a positive integer");
537
+ }
538
+ }
539
+ }
540
+ if (record.providers !== undefined) {
541
+ const providers = asRecord(record.providers);
542
+ if (providers === undefined) {
543
+ throw new SettingsValidationError("server.providers", "must be an object");
544
+ }
545
+ checkKeys("server.providers.", providers, ["refreshIntervalMinutes"]);
546
+ if (providers.refreshIntervalMinutes !== undefined &&
547
+ !isNonNegativeInt(providers.refreshIntervalMinutes)) {
548
+ throw new SettingsValidationError("server.providers.refreshIntervalMinutes", "must be a non-negative integer");
549
+ }
550
+ }
551
+ if (record.auth !== undefined) {
552
+ const auth = asRecord(record.auth);
553
+ if (auth === undefined) {
554
+ throw new SettingsValidationError("server.auth", "must be an object");
555
+ }
556
+ checkKeys("server.auth.", auth, [
557
+ "methods",
558
+ "proxy",
559
+ "adminGroups",
560
+ "allowedHosts",
561
+ "allowedOrigins",
562
+ ]);
563
+ if (auth.methods !== undefined) {
564
+ if (!Array.isArray(auth.methods) ||
565
+ auth.methods.some((m) => m !== "token" && m !== "proxy")) {
566
+ throw new SettingsValidationError("server.auth.methods", 'must be an array of "token" and/or "proxy"');
567
+ }
568
+ }
569
+ if (auth.proxy !== undefined) {
570
+ const proxy = asRecord(auth.proxy);
571
+ if (proxy === undefined) {
572
+ throw new SettingsValidationError("server.auth.proxy", "must be an object");
573
+ }
574
+ // `secret` is deliberately NOT in the list: the shared secret is a
575
+ // credential, env-only (SIGRUN_PROXY_SECRET) — key material never
576
+ // lives in the settings file (auth.json's monopoly).
577
+ checkKeys("server.auth.proxy.", proxy, [
578
+ "trustedProxies",
579
+ "secretHeader",
580
+ "userHeader",
581
+ "groupsHeader",
582
+ "nameHeader",
583
+ "emailHeader",
584
+ "autoProvision",
585
+ ]);
586
+ if (proxy.trustedProxies !== undefined && !isNonEmptyStringArray(proxy.trustedProxies)) {
587
+ throw new SettingsValidationError("server.auth.proxy.trustedProxies", "must be an array of non-empty CIDR/IP strings");
588
+ }
589
+ for (const key of ["secretHeader", "userHeader", "groupsHeader", "nameHeader", "emailHeader"]) {
590
+ if (proxy[key] !== undefined && !isNonEmptyString(proxy[key])) {
591
+ throw new SettingsValidationError(`server.auth.proxy.${key}`, "must be a non-empty string");
592
+ }
593
+ }
594
+ if (proxy.autoProvision !== undefined && typeof proxy.autoProvision !== "boolean") {
595
+ throw new SettingsValidationError("server.auth.proxy.autoProvision", "must be a boolean");
596
+ }
597
+ }
598
+ for (const key of ["adminGroups", "allowedHosts", "allowedOrigins"]) {
599
+ if (auth[key] !== undefined && !isNonEmptyStringArray(auth[key])) {
600
+ throw new SettingsValidationError(`server.auth.${key}`, "must be an array of non-empty strings");
601
+ }
602
+ }
603
+ }
604
+ if (record.metrics !== undefined) {
605
+ const metrics = asRecord(record.metrics);
606
+ if (metrics === undefined) {
607
+ throw new SettingsValidationError("server.metrics", "must be an object");
608
+ }
609
+ checkKeys("server.metrics.", metrics, ["public"]);
610
+ if (metrics.public !== undefined && typeof metrics.public !== "boolean") {
611
+ throw new SettingsValidationError("server.metrics.public", "must be a boolean");
612
+ }
613
+ }
614
+ if (record.backup !== undefined) {
615
+ const backup = asRecord(record.backup);
616
+ if (backup === undefined) {
617
+ throw new SettingsValidationError("server.backup", "must be an object");
618
+ }
619
+ checkKeys("server.backup.", backup, ["path", "s3", "staleAfterHours"]);
620
+ if (backup.path !== undefined && backup.s3 !== undefined) {
621
+ throw new SettingsValidationError("server.backup", "must carry exactly one target (path or s3, not both)");
622
+ }
623
+ // §6.8: the honesty knob — a positive NUMBER (not integer-only:
624
+ // a sub-hour window like 0.5 is a legal tuning), defaulting to 36
625
+ // at resolution; not a target, so it may ride either shape or sit
626
+ // alone (a no-target backup section holding only the knob is inert).
627
+ if (backup.staleAfterHours !== undefined &&
628
+ !isPositiveNumber(backup.staleAfterHours)) {
629
+ throw new SettingsValidationError("server.backup.staleAfterHours", "must be a positive number");
630
+ }
631
+ if (backup.path !== undefined && !isNonEmptyString(backup.path)) {
632
+ throw new SettingsValidationError("server.backup.path", "must be a non-empty string");
633
+ }
634
+ if (backup.s3 !== undefined) {
635
+ const s3 = asRecord(backup.s3);
636
+ if (s3 === undefined) {
637
+ throw new SettingsValidationError("server.backup.s3", "must be an object");
638
+ }
639
+ checkKeys("server.backup.s3.", s3, ["bucket", "prefix", "endpoint"]);
640
+ if (!isNonEmptyString(s3.bucket)) {
641
+ throw new SettingsValidationError("server.backup.s3.bucket", "is required (and must be a non-empty string) when server.backup.s3 is set");
642
+ }
643
+ for (const key of ["prefix", "endpoint"]) {
644
+ if (s3[key] !== undefined && !isNonEmptyString(s3[key])) {
645
+ throw new SettingsValidationError(`server.backup.s3.${key}`, "must be a non-empty string");
646
+ }
647
+ }
648
+ }
649
+ }
650
+ }
651
+ function isNonNegativeInt(value) {
652
+ return (typeof value === "number" &&
653
+ Number.isInteger(value) &&
654
+ value >= 0);
655
+ }
656
+ /** A positive integer (the `server.port` rule, mirroring `SIGRUN_PORT`). */
657
+ function isPositiveInt(value) {
658
+ return (typeof value === "number" &&
659
+ Number.isInteger(value) &&
660
+ value > 0);
661
+ }
662
+ /** A positive finite number (§6.8's `server.backup.staleAfterHours`). */
663
+ function isPositiveNumber(value) {
664
+ return (typeof value === "number" &&
665
+ Number.isFinite(value) &&
666
+ value > 0);
667
+ }
668
+ /** A non-empty string (the file's path/string fields). */
669
+ function isNonEmptyString(value) {
670
+ return typeof value === "string" && value !== "";
671
+ }
672
+ function isNonEmptyStringArray(value) {
673
+ return (Array.isArray(value) &&
674
+ value.every((entry) => isNonEmptyString(entry)));
675
+ }
676
+ /** An enumerable record (not null, not an array). */
677
+ function asRecord(value) {
678
+ return typeof value === "object" && value !== null && !Array.isArray(value)
679
+ ? value
680
+ : undefined;
681
+ }
682
+ /** Every key not in `allowed` → a `SettingsValidationError` naming it. */
683
+ function checkKeys(prefix, record, allowed, reason = "is not a settable field") {
684
+ for (const key of Object.keys(record)) {
685
+ if (!allowed.includes(key)) {
686
+ throw new SettingsValidationError(`${prefix}${key}`, reason);
687
+ }
688
+ }
689
+ }
690
+ /**
691
+ * A file-backed settings store. All reads go through `read()` — fresh
692
+ * file contents every call, so a PATCH from another process or a hand
693
+ * edit lands on the next resolution.
694
+ */
695
+ export class SettingsStore {
696
+ path;
697
+ constructor(options = {}) {
698
+ this.path = settingsPath(options.dir ?? defaultSettingsDir());
699
+ }
700
+ /** The current settings; absent file = `{}` (all defaults). */
701
+ read() {
702
+ if (!existsSync(this.path))
703
+ return {};
704
+ const raw = readFileSync(this.path, "utf8");
705
+ return this.parse(raw);
706
+ }
707
+ /**
708
+ * First-boot initialization (the settings bootstrap, ruling 5 /
709
+ * spec §6.6): when the file is ABSENT, write ONLY what the
710
+ * environment set — the env-sourced `server` fields (the
711
+ * `envServerSection` projection) plus the top-level `extensions`
712
+ * list and `defaultModel` when env supplied them. Defaults are
713
+ * NEVER persisted, so later releases' default changes reach existing
714
+ * installs; a `server` section with zero entries is omitted entirely
715
+ * (a nothing-env-set boot writes at most the top-level keys). When
716
+ * the file already exists (any content), bootstrap is a no-op: the
717
+ * file is never overwritten, and env vars continue to win over it on
718
+ * the next boot (env-over-file precedence). Idempotent: bootstrapping
719
+ * twice leaves the file byte-identical.
720
+ */
721
+ bootstrap(resolvedServer, resolved) {
722
+ if (existsSync(this.path))
723
+ return;
724
+ mkdirSync(dirname(this.path), { recursive: true });
725
+ const file = {
726
+ ...(resolved?.extensions !== undefined
727
+ ? { extensions: resolved.extensions }
728
+ : {}),
729
+ ...(resolved?.defaultModel !== undefined
730
+ ? { defaultModel: resolved.defaultModel }
731
+ : {}),
732
+ ...(Object.keys(resolvedServer).length === 0
733
+ ? {}
734
+ : { server: resolvedServer }),
735
+ };
736
+ writeFileSync(this.path, `${JSON.stringify(file, null, "\t")}\n`);
737
+ }
738
+ /**
739
+ * Parse file contents. A malformed file throws fail-loud — at boot
740
+ * `openHarness` surfaces this exactly like a bad `SIGRUN_*` value
741
+ * (the same fail-loud posture as `loadConfig`).
742
+ */
743
+ parse(raw) {
744
+ let parsed;
745
+ try {
746
+ parsed = JSON.parse(raw);
747
+ }
748
+ catch (cause) {
749
+ throw new Error(`settings: ${this.path} is not valid JSON (${cause instanceof Error ? cause.message : String(cause)})`);
750
+ }
751
+ if (typeof parsed !== "object" ||
752
+ parsed === null ||
753
+ Array.isArray(parsed)) {
754
+ throw new Error(`settings: ${this.path} must contain a JSON object`);
755
+ }
756
+ const value = parsed;
757
+ validate(value);
758
+ return value;
759
+ }
760
+ /**
761
+ * Merge `patch` over the current settings and persist. An explicit
762
+ * `null` value clears the key; a sub-object field merges per-key
763
+ * (`null` inside a sub-object clears that sub-field). Returns the
764
+ * new effective settings.
765
+ *
766
+ * @throws SettingsValidationError naming the offending field.
767
+ */
768
+ update(patch) {
769
+ const merged = {
770
+ ...this.read(),
771
+ };
772
+ for (const [key, value] of Object.entries(patch)) {
773
+ if (!SETTING_KEYS.includes(key)) {
774
+ throw new SettingsValidationError(key, "is not a settable field");
775
+ }
776
+ if (value === null) {
777
+ delete merged[key];
778
+ continue;
779
+ }
780
+ if ((key === "retry" || key === "stream") &&
781
+ typeof value === "object" &&
782
+ value !== null) {
783
+ // Sub-merge: PATCH {retry: {maxRetries: 0}} preserves other
784
+ // retry fields; a null sub-field clears just that one.
785
+ const sub = {
786
+ ...(merged[key] ?? {}),
787
+ };
788
+ for (const [subKey, subValue] of Object.entries(value)) {
789
+ if (subValue === null)
790
+ delete sub[subKey];
791
+ else
792
+ sub[subKey] = subValue;
793
+ }
794
+ if (Object.keys(sub).length === 0)
795
+ delete merged[key];
796
+ else
797
+ merged[key] = sub;
798
+ continue;
799
+ }
800
+ if (key === "server" &&
801
+ typeof value === "object" &&
802
+ value !== null) {
803
+ // Server sub-merge: PATCH {server: {port: 4080}} preserves the
804
+ // rest of the section (including the bootstrap-written fields);
805
+ // `compaction`/`memorySeam`/`providers`/`webui`/`auth`/`metrics`
806
+ // sub-merge one level deeper (`auth.proxy` two levels); `backup`
807
+ // replaces wholesale (it is a discriminated union — a partial
808
+ // union merge could mix a path target with an s3 one).
809
+ const incoming = asRecord(value);
810
+ if (incoming === undefined) {
811
+ throw new SettingsValidationError("server", "must be an object");
812
+ }
813
+ const sub = {
814
+ ...(merged.server ?? {}),
815
+ };
816
+ for (const [subKey, subValue] of Object.entries(incoming)) {
817
+ if (subValue === null)
818
+ delete sub[subKey];
819
+ else if (subKey === "compaction" &&
820
+ typeof subValue === "object") {
821
+ const mergedCompaction = {
822
+ ...(sub.compaction ?? {}),
823
+ };
824
+ for (const [compKey, compValue] of Object.entries(subValue)) {
825
+ if (compValue === null)
826
+ delete mergedCompaction[compKey];
827
+ else
828
+ mergedCompaction[compKey] = compValue;
829
+ }
830
+ if (Object.keys(mergedCompaction).length === 0)
831
+ delete sub.compaction;
832
+ else
833
+ sub.compaction = mergedCompaction;
834
+ }
835
+ else if (subKey === "memorySeam" &&
836
+ typeof subValue === "object") {
837
+ // Same sub-merge as compaction: a flat record, merged per-key
838
+ // (a wholesale replace would silently drop unset knobs).
839
+ const mergedSeam = {
840
+ ...(sub.memorySeam ?? {}),
841
+ };
842
+ for (const [seamKey, seamValue] of Object.entries(subValue)) {
843
+ if (seamValue === null)
844
+ delete mergedSeam[seamKey];
845
+ else
846
+ mergedSeam[seamKey] = seamValue;
847
+ }
848
+ if (Object.keys(mergedSeam).length === 0)
849
+ delete sub.memorySeam;
850
+ else
851
+ sub.memorySeam = mergedSeam;
852
+ }
853
+ else if (subKey === "providers" &&
854
+ typeof subValue === "object") {
855
+ // Same sub-merge for the provider runtime knobs: a flat
856
+ // record merged per-key (the refresh cadence PATCH never
857
+ // drops the section's other server fields).
858
+ const mergedProviders = {
859
+ ...(sub.providers ?? {}),
860
+ };
861
+ for (const [provKey, provValue] of Object.entries(subValue)) {
862
+ if (provValue === null)
863
+ delete mergedProviders[provKey];
864
+ else
865
+ mergedProviders[provKey] = provValue;
866
+ }
867
+ if (Object.keys(mergedProviders).length === 0)
868
+ delete sub.providers;
869
+ else
870
+ sub.providers = mergedProviders;
871
+ }
872
+ else if (subKey === "webui" &&
873
+ typeof subValue === "object") {
874
+ // Same sub-merge for the webui channel section (build-and-webui
875
+ // Task 4): PATCH {server:{webui:{enabled:false}}} preserves a
876
+ // previously set bundle — a wholesale replace would silently
877
+ // drop the operator's selector.
878
+ const mergedWebui = {
879
+ ...(sub.webui ?? {}),
880
+ };
881
+ for (const [webuiKey, webuiValue] of Object.entries(subValue)) {
882
+ if (webuiValue === null)
883
+ delete mergedWebui[webuiKey];
884
+ else
885
+ mergedWebui[webuiKey] = webuiValue;
886
+ }
887
+ if (Object.keys(mergedWebui).length === 0)
888
+ delete sub.webui;
889
+ else
890
+ sub.webui = mergedWebui;
891
+ }
892
+ else if (subKey === "auth" &&
893
+ typeof subValue === "object") {
894
+ // The auth section (core-hardening W1) sub-merges per-key like
895
+ // webui, with `proxy` one level deeper still: PATCH
896
+ // {server:{auth:{proxy:{autoProvision:false}}}} preserves a
897
+ // stored trustedProxies — a wholesale replace would silently
898
+ // turn a proxy deployment into "trust no one" (or worse, keep
899
+ // trusting with different headers).
900
+ const mergedAuth = {
901
+ ...(sub.auth ?? {}),
902
+ };
903
+ for (const [authKey, authValue] of Object.entries(subValue)) {
904
+ if (authValue === null)
905
+ delete mergedAuth[authKey];
906
+ else if (authKey === "proxy" &&
907
+ typeof authValue === "object") {
908
+ const mergedProxy = {
909
+ ...(mergedAuth.proxy ?? {}),
910
+ };
911
+ for (const [proxyKey, proxyValue] of Object.entries(authValue)) {
912
+ if (proxyValue === null)
913
+ delete mergedProxy[proxyKey];
914
+ else
915
+ mergedProxy[proxyKey] = proxyValue;
916
+ }
917
+ if (Object.keys(mergedProxy).length === 0)
918
+ delete mergedAuth.proxy;
919
+ else
920
+ mergedAuth.proxy = mergedProxy;
921
+ }
922
+ else
923
+ mergedAuth[authKey] = authValue;
924
+ }
925
+ if (Object.keys(mergedAuth).length === 0)
926
+ delete sub.auth;
927
+ else
928
+ sub.auth = mergedAuth;
929
+ }
930
+ else if (subKey === "metrics" &&
931
+ typeof subValue === "object") {
932
+ const mergedMetrics = {
933
+ ...(sub.metrics ?? {}),
934
+ };
935
+ for (const [metricsKey, metricsValue] of Object.entries(subValue)) {
936
+ if (metricsValue === null)
937
+ delete mergedMetrics[metricsKey];
938
+ else
939
+ mergedMetrics[metricsKey] = metricsValue;
940
+ }
941
+ if (Object.keys(mergedMetrics).length === 0)
942
+ delete sub.metrics;
943
+ else
944
+ sub.metrics = mergedMetrics;
945
+ }
946
+ else
947
+ sub[subKey] = subValue;
948
+ }
949
+ if (Object.keys(sub).length === 0)
950
+ delete merged.server;
951
+ else
952
+ merged.server = sub;
953
+ continue;
954
+ }
955
+ if (key === "defaultProvider" &&
956
+ typeof value === "object" &&
957
+ value !== null) {
958
+ // Same sub-merge as retry/stream: a null sub-field clears just
959
+ // that field of the provider section.
960
+ const sub = {
961
+ ...(merged[key] ?? {}),
962
+ };
963
+ for (const [subKey, subValue] of Object.entries(value)) {
964
+ if (subValue === null)
965
+ delete sub[subKey];
966
+ else
967
+ sub[subKey] = subValue;
968
+ }
969
+ if (Object.keys(sub).length === 0)
970
+ delete merged[key];
971
+ else
972
+ merged[key] = sub;
973
+ continue;
974
+ }
975
+ if (key === "perModelThinking" &&
976
+ typeof value === "object" &&
977
+ value !== null) {
978
+ // Same sub-merge for the model-ref map: a null level clears
979
+ // that model's override.
980
+ const sub = {
981
+ ...(merged[key] ?? {}),
982
+ };
983
+ for (const [model, level] of Object.entries(value)) {
984
+ if (level === null)
985
+ delete sub[model];
986
+ else
987
+ sub[model] = level;
988
+ }
989
+ if (Object.keys(sub).length === 0)
990
+ delete merged[key];
991
+ else
992
+ merged[key] = sub;
993
+ continue;
994
+ }
995
+ if (key === "skills" &&
996
+ typeof value === "object" &&
997
+ value !== null) {
998
+ // Same sub-merge as defaultProvider: the live skills section
999
+ // merges per-key (a null sub-field clears just that knob).
1000
+ const sub = {
1001
+ ...(merged[key] ?? {}),
1002
+ };
1003
+ for (const [subKey, subValue] of Object.entries(value)) {
1004
+ if (subValue === null)
1005
+ delete sub[subKey];
1006
+ else
1007
+ sub[subKey] = subValue;
1008
+ }
1009
+ if (Object.keys(sub).length === 0)
1010
+ delete merged[key];
1011
+ else
1012
+ merged[key] = sub;
1013
+ continue;
1014
+ }
1015
+ merged[key] = value;
1016
+ }
1017
+ validate(merged);
1018
+ const file = merged;
1019
+ mkdirSync(dirname(this.path), { recursive: true });
1020
+ writeFileSync(this.path, `${JSON.stringify(file, null, "\t")}\n`);
1021
+ return file;
1022
+ }
1023
+ /**
1024
+ * A live `HarnessSettings` object for `openHarness`: every property
1025
+ * reads the file AT ACCESS TIME, so pi-durable's per-resolution
1026
+ * `resolveSettings()` observes PATCHed changes without a restart.
1027
+ * Called once per harness boot; the returned object must never be
1028
+ * copied or snapshotted (pi-durable's contract) — `openHarness`
1029
+ * composes it getter-preserving (caller seam first, file second),
1030
+ * never via a spread, which would freeze the boot values.
1031
+ */
1032
+ harnessSettings() {
1033
+ const store = this;
1034
+ // Eager validation: a malformed file throws HERE, at boot (the
1035
+ // fail-loud posture), even though the getters below defer their
1036
+ // first read to pi-durable's first resolution.
1037
+ store.read();
1038
+ return {
1039
+ get steeringMode() {
1040
+ return store.read().steeringMode;
1041
+ },
1042
+ get followUpMode() {
1043
+ return store.read().followUpMode;
1044
+ },
1045
+ get retry() {
1046
+ return store.read().retry;
1047
+ },
1048
+ get stream() {
1049
+ return store.read().stream;
1050
+ },
1051
+ };
1052
+ }
1053
+ }