@crouter/api 0.3.386 → 0.3.388

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/dist/api/__tests__/integration/client.test.js +97 -0
  2. package/dist/api/client.d.ts +7 -0
  3. package/dist/api/client.js +40 -21
  4. package/dist/core/asset-root.d.ts +7 -0
  5. package/dist/core/asset-root.js +18 -0
  6. package/dist/core/canvas/boot-id.d.ts +6 -0
  7. package/dist/core/canvas/boot-id.js +26 -0
  8. package/dist/core/canvas/paths.d.ts +72 -0
  9. package/dist/core/canvas/paths.js +163 -0
  10. package/dist/core/canvas/pid.d.ts +391 -0
  11. package/dist/core/canvas/pid.js +948 -0
  12. package/dist/core/command-plugins/bundle.d.ts +149 -0
  13. package/dist/core/command-plugins/bundle.js +588 -0
  14. package/dist/core/command-plugins/endpoint.d.ts +24 -0
  15. package/dist/core/command-plugins/endpoint.js +51 -0
  16. package/dist/core/config.d.ts +233 -0
  17. package/dist/core/config.js +1120 -0
  18. package/dist/core/env-name.d.ts +6 -0
  19. package/dist/core/env-name.js +9 -0
  20. package/dist/core/errors.d.ts +38 -0
  21. package/dist/core/errors.js +90 -0
  22. package/dist/core/events/emit.d.ts +6 -0
  23. package/dist/core/events/emit.js +42 -0
  24. package/dist/core/events/envelope.d.ts +2 -0
  25. package/dist/core/events/envelope.js +84 -0
  26. package/dist/core/events/errors.d.ts +4 -0
  27. package/dist/core/events/errors.js +69 -0
  28. package/dist/core/events/operation-id.d.ts +4 -0
  29. package/dist/core/events/operation-id.js +24 -0
  30. package/dist/core/events/serialize.d.ts +4 -0
  31. package/dist/core/events/serialize.js +199 -0
  32. package/dist/core/events/source.d.ts +16 -0
  33. package/dist/core/events/source.js +31 -0
  34. package/dist/core/events/types.d.ts +68 -0
  35. package/dist/core/events/types.js +11 -0
  36. package/dist/core/exclusive-lock.d.ts +34 -0
  37. package/dist/core/exclusive-lock.js +197 -0
  38. package/dist/core/fs-utils.d.ts +44 -0
  39. package/dist/core/fs-utils.js +208 -0
  40. package/dist/core/help.d.ts +309 -0
  41. package/dist/core/help.js +406 -0
  42. package/dist/core/human/page-catalog.d.ts +57 -0
  43. package/dist/core/human/page-catalog.js +172 -0
  44. package/dist/core/installed-plugins.d.ts +2 -0
  45. package/dist/core/installed-plugins.js +79 -0
  46. package/dist/core/io.d.ts +122 -0
  47. package/dist/core/io.js +373 -0
  48. package/dist/core/keybindings/attach-control.d.ts +49 -0
  49. package/dist/core/keybindings/attach-control.js +42 -0
  50. package/dist/core/keybindings/catalog.d.ts +18 -0
  51. package/dist/core/keybindings/catalog.js +257 -0
  52. package/dist/core/keybindings/types.d.ts +42 -0
  53. package/dist/core/keybindings/types.js +1 -0
  54. package/dist/core/layout.d.ts +26 -0
  55. package/dist/core/layout.js +94 -0
  56. package/dist/core/locked-file.d.ts +27 -0
  57. package/dist/core/locked-file.js +118 -0
  58. package/dist/core/log.d.ts +9 -0
  59. package/dist/core/log.js +89 -0
  60. package/dist/core/manifest.d.ts +5 -0
  61. package/dist/core/manifest.js +15 -0
  62. package/dist/core/plugin-env.d.ts +8 -0
  63. package/dist/core/plugin-env.js +31 -0
  64. package/dist/core/plugin-extensions.d.ts +29 -0
  65. package/dist/core/plugin-extensions.js +191 -0
  66. package/dist/core/plugin-swap-lock.d.ts +9 -0
  67. package/dist/core/plugin-swap-lock.js +31 -0
  68. package/dist/core/preview-result-path.d.ts +4 -0
  69. package/dist/core/preview-result-path.js +26 -0
  70. package/dist/core/profiles/env-store.d.ts +22 -0
  71. package/dist/core/profiles/env-store.js +163 -0
  72. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  73. package/dist/core/profiles/fuzzy-match.js +92 -0
  74. package/dist/core/profiles/manifest.d.ts +120 -0
  75. package/dist/core/profiles/manifest.js +529 -0
  76. package/dist/core/rate-limit-scope.d.ts +25 -0
  77. package/dist/core/rate-limit-scope.js +64 -0
  78. package/dist/core/render.d.ts +12 -0
  79. package/dist/core/render.js +138 -0
  80. package/dist/core/resolver.d.ts +14 -0
  81. package/dist/core/resolver.js +111 -0
  82. package/dist/core/runtime/branded-host.d.ts +25 -0
  83. package/dist/core/runtime/branded-host.js +264 -0
  84. package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
  85. package/dist/core/runtime/broker/daemon-ops.js +177 -0
  86. package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
  87. package/dist/core/runtime/broker/signal-stream.js +149 -0
  88. package/dist/core/scope.d.ts +32 -0
  89. package/dist/core/scope.js +184 -0
  90. package/dist/core/scoped-state/db.d.ts +17 -0
  91. package/dist/core/scoped-state/db.js +247 -0
  92. package/dist/core/scoped-state/migrate.d.ts +8 -0
  93. package/dist/core/scoped-state/migrate.js +187 -0
  94. package/dist/core/scoped-state/paths.d.ts +9 -0
  95. package/dist/core/scoped-state/paths.js +27 -0
  96. package/dist/core/scoped-state/profiles.d.ts +27 -0
  97. package/dist/core/scoped-state/profiles.js +93 -0
  98. package/dist/core/scoped-state/providers.d.ts +24 -0
  99. package/dist/core/scoped-state/providers.js +19 -0
  100. package/dist/core/scoped-state/schema.d.ts +6 -0
  101. package/dist/core/scoped-state/schema.js +43 -0
  102. package/dist/core/scoped-state/settings.d.ts +28 -0
  103. package/dist/core/scoped-state/settings.js +83 -0
  104. package/dist/core/spaces/open-beneath.d.ts +71 -0
  105. package/dist/core/spaces/open-beneath.js +581 -0
  106. package/dist/core/sqlite-statements.d.ts +4 -0
  107. package/dist/core/sqlite-statements.js +17 -0
  108. package/dist/core/subscription-state.d.ts +121 -0
  109. package/dist/core/subscription-state.js +287 -0
  110. package/dist/core/user-settings.d.ts +377 -0
  111. package/dist/core/user-settings.js +458 -0
  112. package/dist/daemon/broker-signals/bus.d.ts +30 -0
  113. package/dist/daemon/broker-signals/bus.js +87 -0
  114. package/dist/daemon/manage.d.ts +176 -0
  115. package/dist/daemon/manage.js +664 -0
  116. package/dist/daemon/pidfile.d.ts +8 -0
  117. package/dist/daemon/pidfile.js +37 -0
  118. package/dist/daemon/startup-policy.d.ts +1 -0
  119. package/dist/daemon/startup-policy.js +1 -0
  120. package/dist/native/linux.d.ts +29 -0
  121. package/dist/native/linux.js +20 -0
  122. package/dist/shared/env.d.ts +116 -0
  123. package/dist/shared/env.js +271 -0
  124. package/dist/shared/inbox-entry-body.d.ts +22 -0
  125. package/dist/shared/inbox-entry-body.js +116 -0
  126. package/dist/shared/working-activity.d.ts +9 -0
  127. package/dist/shared/working-activity.js +27 -0
  128. package/dist/types.d.ts +562 -0
  129. package/dist/types.js +186 -0
  130. package/package.json +1 -1
@@ -0,0 +1,1120 @@
1
+ import { envProfileId } from '../shared/env.js';
2
+ import { basename, join } from 'node:path';
3
+ import { existsSync, mkdirSync, statSync } from 'node:fs';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+ import { CONFIG_FILE, STATE_FILE, CONDENSED_HISTORY_MODES, MOUSE_MODE_DEFAULTS, WHIP_MESSAGE_MODES, DEFAULT_WHIP_MESSAGES, defaultScopeConfig, defaultScopeState, defaultModelLaddersConfig, defaultKindsConfig, defaultRemoteCanvasConfig } from '../types.js';
6
+ import { BINDING_CATALOG, BINDING_IDS, isAttachPaneBinding } from './keybindings/catalog.js';
7
+ import { notFound } from './errors.js';
8
+ import { emitEvent } from './events/emit.js';
9
+ import { atomicWriteJson, readJsonIfExists, ensureDir } from './fs-utils.js';
10
+ import { scopeRoot, requireScopeRoot, findProjectScopeRoots, userScopeRoot } from './scope.js';
11
+ import { listInstalledPluginsInRoot } from './installed-plugins.js';
12
+ import { profileMemoryDir, listProfiles } from './profiles/manifest.js';
13
+ import { canonicalRoot, projectStore, userStore } from './scoped-state/paths.js';
14
+ import { convertLegacyScopedStore, initializeFreshScopedStore } from './scoped-state/migrate.js';
15
+ import { mutateRawSettings, readBookkeeping, readRawSettings, readRawSettingsWithRevision, replaceRawSettings, writeBookkeeping, writeRawSettings } from './scoped-state/settings.js';
16
+ import { withExclusiveDirectoryLock } from './exclusive-lock.js';
17
+ import { SCOPE_CONFIG_KEYS } from './user-settings.js';
18
+ import { normalizeWorkingGerunds } from '../shared/working-activity.js';
19
+ import { mergePageComponentLayers, normalizePageComponents } from './human/page-catalog.js';
20
+ function configPathFor(root) {
21
+ return join(root, CONFIG_FILE);
22
+ }
23
+ function withConfigPathLock(path, run) {
24
+ // A project config write must fail if its root was removed, not recreate it.
25
+ return withExclusiveDirectoryLock(`${path}.lock`, () => run(path), { timeoutError: () => new Error(`timed out waiting for config lock: ${path}`) });
26
+ }
27
+ function statePathFor(root) {
28
+ return join(root, STATE_FILE);
29
+ }
30
+ export function configPath(scope) {
31
+ const root = scopeRoot(scope);
32
+ return root ? configPathFor(root) : null;
33
+ }
34
+ export function statePath(scope) {
35
+ const root = scopeRoot(scope);
36
+ return root ? statePathFor(root) : null;
37
+ }
38
+ function settingsOwnerForRoot(root) {
39
+ const canonical = canonicalRoot(root);
40
+ const userRoot = canonicalRoot(userScopeRoot());
41
+ if (canonical === userRoot)
42
+ return { store: userStore(userRoot), key: 'user', kind: 'user', root: userRoot };
43
+ // Project roots can be classified without opening the user store. This keeps a
44
+ // fresh project usable when an unrelated legacy user root still needs conversion.
45
+ if (basename(canonical) === '.crouter') {
46
+ convertLegacyScopedStore({ kind: 'project', root: canonical });
47
+ return { store: projectStore(canonical), key: 'project', kind: 'project', root: canonical };
48
+ }
49
+ for (const profile of listProfiles()) {
50
+ const profilePath = canonicalRoot(profileMemoryDir(profile.profileId));
51
+ if (canonical === profilePath)
52
+ return { store: userStore(userRoot), key: `profile:${profile.profileId}`, kind: 'profile', root: profilePath, profileId: profile.profileId };
53
+ }
54
+ throw new Error(`settings root is not a recognized owner: ${root}`);
55
+ }
56
+ function readRawAtRoot(root) {
57
+ // Project configuration is a file; reading it must not initialize SQL state
58
+ // (or its lock directory) after the project disappears.
59
+ if (basename(root) === '.crouter') {
60
+ let canonical;
61
+ try {
62
+ canonical = canonicalRoot(root);
63
+ }
64
+ catch (error) {
65
+ if (error.code === 'ENOENT')
66
+ return null;
67
+ throw error;
68
+ }
69
+ if (canonical !== canonicalRoot(userScopeRoot()))
70
+ return readJsonIfExists(configPathFor(root));
71
+ }
72
+ return readRawSettings(settingsOwnerForRoot(root));
73
+ }
74
+ function writeRawAtRoot(root, raw) {
75
+ const owner = settingsOwnerForRoot(root);
76
+ if (owner.kind === 'project') {
77
+ withConfigPathLock(configPathFor(owner.root), (path) => atomicWriteJson(path, raw, { createParent: false }));
78
+ return;
79
+ }
80
+ writeRawSettings(owner, raw);
81
+ }
82
+ function mutateRawAtRoot(root, mutate) {
83
+ const owner = settingsOwnerForRoot(root);
84
+ if (owner.kind === 'project')
85
+ return withConfigPathLock(configPathFor(owner.root), (path) => { const result = mutate(readJsonIfExists(path) ?? {}); atomicWriteJson(path, result.next, { createParent: false }); return result.value; });
86
+ return mutateRawSettings(owner, (raw) => mutate(raw));
87
+ }
88
+ const brokerSnapshotState = globalThis;
89
+ const brokerSnapshotKey = Symbol.for('crtr.broker.config-snapshot');
90
+ export function installBrokerConfigSnapshot(snapshot) {
91
+ brokerSnapshotState[brokerSnapshotKey] = snapshot;
92
+ }
93
+ export function brokerConfigSnapshot() {
94
+ return brokerSnapshotState[brokerSnapshotKey];
95
+ }
96
+ export function readConfig(scope) {
97
+ const snapshot = brokerSnapshotState[brokerSnapshotKey];
98
+ if (snapshot) {
99
+ if (scope !== 'user')
100
+ throw new Error(`broker config snapshot has no ${scope} scope`);
101
+ return snapshot.user;
102
+ }
103
+ const root = scopeRoot(scope);
104
+ return root ? readConfigAtRoot(root) : defaultScopeConfig();
105
+ }
106
+ export function readConfigAtRoot(root) { const existing = readRawAtRoot(root); return existing ? mergeConfig(existing) : defaultScopeConfig(); }
107
+ export function readState(scope) {
108
+ const root = scopeRoot(scope);
109
+ if (!root)
110
+ return defaultScopeState();
111
+ try {
112
+ return readBookkeeping(settingsOwnerForRoot(root));
113
+ }
114
+ catch (error) {
115
+ if (scope === 'project' && !existsSync(root))
116
+ return defaultScopeState();
117
+ throw error;
118
+ }
119
+ }
120
+ export class InvalidRawSettingsError extends Error {
121
+ constructor(cause) {
122
+ super(`settings cannot be merged: ${cause instanceof Error ? cause.message : String(cause)}`);
123
+ this.name = 'InvalidRawSettingsError';
124
+ }
125
+ }
126
+ function rawSettingsOwnerForTarget(target) {
127
+ if (target.scope === 'user')
128
+ return settingsOwnerForRoot(userScopeRoot());
129
+ if (!listProfiles().some((profile) => profile.profileId === target.profileId)) {
130
+ throw notFound(`profile not found: ${target.profileId}`, { received: target.profileId, next: 'Run `crtr profile list` to see available profiles.' });
131
+ }
132
+ return settingsOwnerForRoot(profileMemoryDir(target.profileId));
133
+ }
134
+ export function readRawSettingsForTarget(target) {
135
+ return readRawSettingsWithRevision(rawSettingsOwnerForTarget(target));
136
+ }
137
+ export function replaceRawSettingsForTarget(target, next, ifRevision) {
138
+ return replaceRawSettings(rawSettingsOwnerForTarget(target), next, ifRevision, (settings) => {
139
+ try {
140
+ mergeConfig(settings);
141
+ }
142
+ catch (error) {
143
+ throw new InvalidRawSettingsError(error);
144
+ }
145
+ });
146
+ }
147
+ export function updateRawConfigAtomically(scope, mutate) { const root = requireScopeRoot(scope); return mutateRawAtRoot(root, (latest) => { const next = mutate(latest); return { value: next, next }; }); }
148
+ export function writeConfig(scope, config) { writeRawAtRoot(requireScopeRoot(scope), config); }
149
+ export function writeRawConfig(scope, config) { writeRawAtRoot(requireScopeRoot(scope), config); }
150
+ export function writeState(scope, state) { const root = requireScopeRoot(scope); writeBookkeeping(settingsOwnerForRoot(root), state); }
151
+ export function ensureScopeInitialized(scope, root) {
152
+ if (scope === 'project') {
153
+ try {
154
+ mkdirSync(root);
155
+ }
156
+ catch (error) {
157
+ if (error.code !== 'EEXIST' || !statSync(root).isDirectory())
158
+ throw error;
159
+ }
160
+ }
161
+ else
162
+ ensureDir(root);
163
+ const owner = scope === 'user' ? settingsOwnerForRoot(userScopeRoot()) : settingsOwnerForRoot(root);
164
+ initializeFreshScopedStore({ kind: owner.kind === 'user' ? 'user' : 'project', root: owner.store.root });
165
+ if (owner.kind === 'project' && readJsonIfExists(configPathFor(owner.root)) === null) {
166
+ // A project config holds only what the project overrides. Seeding it with
167
+ // the full default document would make every default (kinds, display
168
+ // toggles, auto_update) a project-level override that silently shadows the
169
+ // user's own settings for every directory beneath it — reads default-fill
170
+ // missing keys from the running build, so a sparse file loses nothing.
171
+ writeRawAtRoot(owner.root, { schema_version: defaultScopeConfig().schema_version });
172
+ }
173
+ }
174
+ function normalizeKeybindings(raw) {
175
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
176
+ return {};
177
+ const out = {};
178
+ for (const [id, gestures] of Object.entries(raw)) {
179
+ if (Array.isArray(gestures) && gestures.every((gesture) => typeof gesture === 'string')) {
180
+ out[id] = [...gestures];
181
+ }
182
+ }
183
+ return out;
184
+ }
185
+ const ATTACH_PANE_BINDING_IDS = new Set(BINDING_CATALOG.filter(isAttachPaneBinding).map((definition) => definition.id));
186
+ /** Normalize the user-owned list of attach actions allowed to bridge through an
187
+ * occupied tmux root binding. Unknown and non-attach ids have no effect. */
188
+ export function normalizeTmuxPassthrough(raw) {
189
+ if (!Array.isArray(raw))
190
+ return [];
191
+ return [...new Set(raw.filter((id) => typeof id === 'string' && BINDING_IDS.has(id) && ATTACH_PANE_BINDING_IDS.has(id)))];
192
+ }
193
+ function mergeModelLadders(raw, base = defaultModelLaddersConfig()) {
194
+ const defaults = base;
195
+ if (raw === null || typeof raw !== 'object')
196
+ return defaults;
197
+ const r = raw;
198
+ const out = {
199
+ anthropic: { ...defaults.anthropic },
200
+ openai: { ...defaults.openai },
201
+ ...(defaults.defaultProvider !== undefined ? { defaultProvider: defaults.defaultProvider } : {}),
202
+ };
203
+ if (r.defaultProvider === 'anthropic' || r.defaultProvider === 'openai') {
204
+ out.defaultProvider = r.defaultProvider;
205
+ }
206
+ for (const provider of ['anthropic', 'openai']) {
207
+ const over = r[provider];
208
+ if (over === null || typeof over !== 'object')
209
+ continue;
210
+ for (const [strength, value] of Object.entries(over)) {
211
+ if ((strength === 'ultra' || strength === 'strong' || strength === 'medium' || strength === 'light') && typeof value === 'string') {
212
+ out[provider][strength] = value;
213
+ }
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+ function validStrength(value) {
219
+ return value === 'ultra' || value === 'strong' || value === 'medium' || value === 'light';
220
+ }
221
+ const MODEL_STRENGTHS = ['ultra', 'strong', 'medium', 'light'];
222
+ function routeRecord() {
223
+ return Object.create(null);
224
+ }
225
+ function setRouteRecord(record, routeId, value) {
226
+ Object.defineProperty(record, routeId, { value, writable: true, enumerable: true, configurable: true });
227
+ }
228
+ function copyRouteRecord(record) {
229
+ const copy = routeRecord();
230
+ for (const [routeId, value] of Object.entries(record ?? {}))
231
+ setRouteRecord(copy, routeId, value);
232
+ return copy;
233
+ }
234
+ function routeInvalid(routeId, rule) {
235
+ emitEvent({ level: 'warn', event: 'config.model_routes.invalid', fields: { route_id: routeId, rule } });
236
+ }
237
+ /** Overlay raw route patches first so a higher scope may replace one cell of a
238
+ * complete lower route. Completeness belongs to the resulting effective route,
239
+ * never an individual raw scope patch. */
240
+ function mergeModelRoutePatches(raw, base = {}) {
241
+ const out = copyRouteRecord(base);
242
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
243
+ return out;
244
+ for (const [routeId, value] of Object.entries(raw)) {
245
+ if (routeId.trim() === '') {
246
+ routeInvalid(routeId, 'route id must be a non-empty string');
247
+ continue;
248
+ }
249
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
250
+ routeInvalid(routeId, 'route must be an object');
251
+ continue;
252
+ }
253
+ const patch = value;
254
+ const current = out[routeId];
255
+ const route = { ...current };
256
+ let rule;
257
+ for (const field of ['family', 'credentialSource', 'providerId']) {
258
+ const candidate = patch[field];
259
+ if (candidate === undefined)
260
+ continue;
261
+ if (typeof candidate !== 'string' || candidate.trim() === '') {
262
+ rule = `${field} must be a non-empty string`;
263
+ break;
264
+ }
265
+ route[field] = candidate;
266
+ }
267
+ if (rule === undefined && patch.models !== undefined) {
268
+ if (patch.models === null || typeof patch.models !== 'object' || Array.isArray(patch.models))
269
+ rule = 'models must be an object';
270
+ else {
271
+ const models = { ...current?.models };
272
+ for (const [strength, spec] of Object.entries(patch.models)) {
273
+ if (!validStrength(strength)) {
274
+ rule = `models.${strength} is not a model strength`;
275
+ break;
276
+ }
277
+ if (typeof spec !== 'string' || spec.trim() === '') {
278
+ rule = `models.${strength} must be a non-empty provider/id ref`;
279
+ break;
280
+ }
281
+ const slash = spec.indexOf('/');
282
+ if (slash <= 0 || slash === spec.length - 1) {
283
+ rule = `models.${strength} must be provider/id`;
284
+ break;
285
+ }
286
+ models[strength] = spec;
287
+ }
288
+ route.models = models;
289
+ }
290
+ }
291
+ if (rule === undefined && route.models !== undefined && route.providerId !== undefined) {
292
+ for (const [strength, spec] of Object.entries(route.models)) {
293
+ if (spec.slice(0, spec.indexOf('/')) !== route.providerId) {
294
+ rule = `models.${strength} provider prefix must equal providerId`;
295
+ break;
296
+ }
297
+ }
298
+ }
299
+ if (rule !== undefined) {
300
+ routeInvalid(routeId, rule);
301
+ continue;
302
+ }
303
+ setRouteRecord(out, routeId, route);
304
+ }
305
+ return out;
306
+ }
307
+ /** Resolve complete routes after every scope has overlaid its sparse raw patch.
308
+ * Sparse standalone routes are read for one compatibility release only: they
309
+ * retain their historical candidate behavior, emit a repair warning, and never
310
+ * gain invented cells. New writes cannot create them. */
311
+ function resolveModelRoutes(routes) {
312
+ const complete = routeRecord();
313
+ const legacy = routeRecord();
314
+ for (const [routeId, route] of Object.entries(routes)) {
315
+ if (route.family === undefined || route.credentialSource === undefined || route.providerId === undefined || route.models === undefined) {
316
+ routeInvalid(routeId, 'route must include family, credentialSource, providerId, and models');
317
+ continue;
318
+ }
319
+ const missing = MODEL_STRENGTHS.filter((strength) => route.models[strength] === undefined);
320
+ if (missing.length > 0) {
321
+ routeInvalid(routeId, `incomplete route; missing strengths: ${missing.join(', ')}`);
322
+ setRouteRecord(legacy, routeId, { family: route.family, credentialSource: route.credentialSource, providerId: route.providerId, models: route.models });
323
+ continue;
324
+ }
325
+ setRouteRecord(complete, routeId, {
326
+ family: route.family,
327
+ credentialSource: route.credentialSource,
328
+ providerId: route.providerId,
329
+ models: route.models,
330
+ });
331
+ }
332
+ return { complete, legacy };
333
+ }
334
+ function assertCompleteModelRoute(routeId, route) {
335
+ if (routeId.trim() === '')
336
+ throw new Error('model route id must be a non-empty string');
337
+ for (const field of ['family', 'credentialSource', 'providerId']) {
338
+ if (typeof route[field] !== 'string' || route[field].trim() === '')
339
+ throw new Error(`model route "${routeId}" ${field} must be a non-empty string`);
340
+ }
341
+ const missing = MODEL_STRENGTHS.filter((strength) => route.models?.[strength] === undefined);
342
+ if (missing.length > 0)
343
+ throw new Error(`model route "${routeId}" is incomplete; missing strengths: ${missing.join(', ')}`);
344
+ for (const strength of MODEL_STRENGTHS) {
345
+ const spec = route.models[strength];
346
+ if (typeof spec !== 'string' || spec.trim() === '')
347
+ throw new Error(`model route "${routeId}" models.${strength} must be a non-empty provider/id ref`);
348
+ const slash = spec.indexOf('/');
349
+ if (slash <= 0 || slash === spec.length - 1)
350
+ throw new Error(`model route "${routeId}" models.${strength} must be provider/id`);
351
+ if (spec.slice(0, slash) !== route.providerId)
352
+ throw new Error(`model route "${routeId}" models.${strength} provider prefix must equal providerId`);
353
+ }
354
+ }
355
+ function mergeModelRouting(raw, base) {
356
+ if (raw === undefined)
357
+ return base;
358
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
359
+ return base;
360
+ const value = raw;
361
+ const strings = (items) => Array.isArray(items) && items.every((item) => typeof item === 'string' && item.trim() !== '') ? [...items] : undefined;
362
+ const strengths = Array.isArray(value.strengthFallback) && value.strengthFallback.every((item) => validStrength(item)) ? [...value.strengthFallback] : undefined;
363
+ return {
364
+ ...(strings(value.credentialSourceOrder) !== undefined ? { credentialSourceOrder: strings(value.credentialSourceOrder) } : {}),
365
+ ...(strings(value.familyOrder) !== undefined ? { familyOrder: strings(value.familyOrder) } : {}),
366
+ ...(strengths !== undefined ? { strengthFallback: strengths } : {}),
367
+ };
368
+ }
369
+ /** True when `pattern` (a `spawnEnv.allow` entry) matches EVERY possible env
370
+ * var name — a bare `*`, or any run of nothing but `*` characters (`**`,
371
+ * `***`, …). This is the one glob shape that reopens the inherit-all hole
372
+ * the whole spawn-env boundary exists to close, so it is rejected loud
373
+ * rather than silently treated as literal or silently dropped (per
374
+ * `cto-rejects-fallback-hedges`: fix the config, don't hedge around it). */
375
+ function isInheritAllGlob(pattern) {
376
+ return pattern !== '' && pattern.replace(/\*/g, '') === '';
377
+ }
378
+ /** Merge a raw `spawnEnv.allow` list over `base` (defaulting to empty):
379
+ * additive/union, not override — each scope only ADDS a name/glob it
380
+ * explicitly admits across the broker spawn-env boundary (`buildBrokerEnv`),
381
+ * never removes a lower-precedence scope's entry. Non-string entries are
382
+ * dropped, never thrown, mirroring `mergeKinds`/`mergeModelLadders` — but an
383
+ * inherit-all glob (`isInheritAllGlob`) throws immediately at config read: it
384
+ * is not a shape any legitimate entry can mean, and silently accepting or
385
+ * dropping it would either reopen the leak or hide a config mistake. */
386
+ function mergeSpawnEnv(raw, base = []) {
387
+ const r = raw;
388
+ const extra = Array.isArray(r?.allow) ? r.allow.filter((v) => typeof v === 'string') : [];
389
+ for (const pattern of extra) {
390
+ if (isInheritAllGlob(pattern)) {
391
+ throw new Error(`spawnEnv.allow: "${pattern}" matches every env var name (inherit-all) — this is exactly the leak the spawn-env boundary closes. Name the specific vars/prefixes you actually need instead (e.g. "AWS_*", "GIT_ASKPASS").`);
392
+ }
393
+ }
394
+ return { allow: [...new Set([...base, ...extra])] };
395
+ }
396
+ /** Per-provider options, merged SHALLOWLY per provider id so a nearer scope
397
+ * that sets one option does not erase a sibling option from a farther scope. */
398
+ function mergeProviderOptions(raw, base = {}) {
399
+ const merged = {};
400
+ for (const [providerId, options] of Object.entries(base))
401
+ merged[providerId] = { ...options };
402
+ if (raw !== null && typeof raw === 'object') {
403
+ for (const [providerId, options] of Object.entries(raw)) {
404
+ if (options === null || typeof options !== 'object')
405
+ continue;
406
+ const next = { ...merged[providerId] };
407
+ const fastMode = options.fastMode;
408
+ if (typeof fastMode === 'boolean')
409
+ next.fastMode = fastMode;
410
+ merged[providerId] = next;
411
+ }
412
+ }
413
+ return merged;
414
+ }
415
+ /** The one bare-command-name rule, shared by the read path (`normalizeBin`)
416
+ * and the strict install gate (`invalidPluginBinReasons`): a name a shell can
417
+ * invoke verbatim and a filesystem can hold as a single symlink entry — no
418
+ * separator, no leading dot, no whitespace, no quoting ever required. */
419
+ const BIN_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
420
+ /** Bare names crouter owns on every node's PATH. A contribution may not take
421
+ * one: shadowing the CLI a node drives its own canvas with breaks the runtime
422
+ * contract rather than extending it. */
423
+ const RESERVED_BIN_NAMES = new Set(['crtr', 'crtrd']);
424
+ /** The one bare-command-name validator shared by `bin` and plugin `requires`.
425
+ * `bin` alone reserves crouter's own commands; a plugin may require any safe
426
+ * executable name because it does not contribute or shadow that command. */
427
+ function bareCommandNameIssue(name, field) {
428
+ if (field === 'bin' && RESERVED_BIN_NAMES.has(name))
429
+ return `${field}.${name} is reserved by crouter — pick another bare command name`;
430
+ if (!BIN_NAME_PATTERN.test(name))
431
+ return `${field}.${name} is not a safe bare command name (expected ${BIN_NAME_PATTERN.source})`;
432
+ return undefined;
433
+ }
434
+ /** Validate raw `bin` declaration shape without touching the filesystem.
435
+ * Install uses these findings as a hard gate for plugin manifests; scope
436
+ * resolution reports them through `crtr sys doctor` and continues. */
437
+ export function binDeclarationIssues(raw) {
438
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
439
+ return [{ name: 'bin', message: 'bin must be a JSON object mapping bare command name to a relative path' }];
440
+ }
441
+ const issues = [];
442
+ for (const [name, target] of Object.entries(raw)) {
443
+ const nameIssue = bareCommandNameIssue(name, 'bin');
444
+ if (nameIssue !== undefined)
445
+ issues.push({ name, message: nameIssue });
446
+ if (typeof target !== 'string' || target.trim() === '') {
447
+ issues.push({ name, message: `bin.${name} must be a non-empty relative path to an executable inside the contributor root` });
448
+ }
449
+ }
450
+ return issues;
451
+ }
452
+ /** Normalize one raw `bin` block (a scope `config.json`'s or a plugin
453
+ * manifest's) to name → root-relative path. Invalid entries never become
454
+ * commands; `binDeclarationIssues` keeps them visible to Doctor. */
455
+ export function normalizeBin(raw) {
456
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
457
+ return {};
458
+ const out = {};
459
+ for (const [name, target] of Object.entries(raw)) {
460
+ if (bareCommandNameIssue(name, 'bin') !== undefined)
461
+ continue;
462
+ if (typeof target !== 'string' || target.trim() === '')
463
+ continue;
464
+ out[name] = target;
465
+ }
466
+ return out;
467
+ }
468
+ const HUMAN_ACTION_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
469
+ /** Validate `humanActions` declaration shape without touching the filesystem.
470
+ * Filesystem checks happen only when resolving an action or running Doctor. */
471
+ export function humanActionDeclarationIssues(raw) {
472
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
473
+ return [{ name: 'humanActions', reason: 'humanActions must be a JSON object mapping action names to {argv, cwd}', block: true }];
474
+ }
475
+ const issues = [];
476
+ for (const [name, value] of Object.entries(raw)) {
477
+ const reasons = [];
478
+ if (!HUMAN_ACTION_NAME_PATTERN.test(name)) {
479
+ reasons.push(`action name must match ${HUMAN_ACTION_NAME_PATTERN.source}`);
480
+ }
481
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
482
+ reasons.push('action must be an object with argv and cwd');
483
+ }
484
+ else {
485
+ const action = value;
486
+ const extras = Object.keys(action).filter((key) => key !== 'argv' && key !== 'cwd');
487
+ if (extras.length > 0)
488
+ reasons.push(`action has unsupported field${extras.length === 1 ? '' : 's'}: ${extras.join(', ')}`);
489
+ if (!Array.isArray(action.argv) || action.argv.length === 0 || !action.argv.every((arg) => typeof arg === 'string' && arg.length > 0)) {
490
+ reasons.push('argv must be a non-empty array of non-empty strings');
491
+ }
492
+ if (typeof action.cwd !== 'string' || action.cwd.length === 0) {
493
+ reasons.push('cwd is required and must be a non-empty string');
494
+ }
495
+ }
496
+ if (reasons.length > 0)
497
+ issues.push({ name, reason: reasons.join('; ') });
498
+ }
499
+ return issues;
500
+ }
501
+ /** Normalize valid action declarations for scope-local config inspection.
502
+ * Invalid entries remain available through raw reads so resolution and Doctor
503
+ * can report them rather than silently treating them as absent. */
504
+ export function normalizeHumanActions(raw) {
505
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
506
+ return {};
507
+ const invalid = new Set(humanActionDeclarationIssues(raw).map((issue) => issue.name));
508
+ const out = {};
509
+ for (const [name, value] of Object.entries(raw)) {
510
+ if (invalid.has(name))
511
+ continue;
512
+ const action = value;
513
+ out[name] = { argv: [...action.argv], cwd: action.cwd };
514
+ }
515
+ return out;
516
+ }
517
+ /** STRICT install-time validation for a plugin-declared `bin` block. Target
518
+ * filesystem state is checked later by Doctor because it can change after
519
+ * install. */
520
+ export function invalidPluginBinReasons(raw) {
521
+ return binDeclarationIssues(raw).map((issue) => issue.message);
522
+ }
523
+ /** Normalize a plugin manifest's advisory executable requirements. Invalid
524
+ * declarations stay out of the read path; source installs reject them through
525
+ * `invalidPluginRequiresReasons`. */
526
+ export function normalizeRequires(raw) {
527
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
528
+ return {};
529
+ const out = {};
530
+ for (const [name, hint] of Object.entries(raw)) {
531
+ if (bareCommandNameIssue(name, 'requires') !== undefined)
532
+ continue;
533
+ if (typeof hint !== 'string' || hint.trim() === '' || /[\r\n]/.test(hint))
534
+ continue;
535
+ out[name] = hint;
536
+ }
537
+ return out;
538
+ }
539
+ /** STRICT install-time validation for a plugin's advisory `requires` block.
540
+ * A missing executable is advisory; only malformed declarations fail install. */
541
+ export function invalidPluginRequiresReasons(raw) {
542
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
543
+ return ['requires must be a JSON object mapping a bare executable name to a one-line install hint'];
544
+ }
545
+ const reasons = [];
546
+ for (const [name, hint] of Object.entries(raw)) {
547
+ const nameIssue = bareCommandNameIssue(name, 'requires');
548
+ if (nameIssue !== undefined)
549
+ reasons.push(nameIssue);
550
+ if (typeof hint !== 'string' || hint.trim() === '') {
551
+ reasons.push(`requires.${name} must be a non-empty one-line install hint`);
552
+ }
553
+ else if (/[\r\n]/.test(hint)) {
554
+ reasons.push(`requires.${name} must be a one-line install hint`);
555
+ }
556
+ }
557
+ return reasons;
558
+ }
559
+ /** Collect the valid `KindConfig` fields of one raw `kinds.<name>` entry —
560
+ * every field is optional here, `whenToUse` included; mistyped fields are
561
+ * dropped, never thrown (an invalid field in config.json must not break
562
+ * config parsing for every other kind — the loud gate is install time, see
563
+ * `invalidPluginKindsReasons`). Returns null when no valid field is present.
564
+ * Whether `whenToUse` is REQUIRED is `mergeKinds`'s call: only an entry that
565
+ * introduces a kind no lower layer defines needs one. */
566
+ function normalizeKindFields(raw) {
567
+ if (raw === null || typeof raw !== 'object')
568
+ return null;
569
+ const r = raw;
570
+ const out = {};
571
+ if (typeof r.whenToUse === 'string' && r.whenToUse.trim() !== '')
572
+ out.whenToUse = r.whenToUse;
573
+ if (typeof r.model === 'string' && r.model.trim() !== '')
574
+ out.model = r.model;
575
+ if (typeof r.orchestratorModel === 'string' && r.orchestratorModel.trim() !== '')
576
+ out.orchestratorModel = r.orchestratorModel;
577
+ const isStringArray = (v) => Array.isArray(v) && v.every((item) => typeof item === 'string');
578
+ if (isStringArray(r.tools))
579
+ out.tools = r.tools;
580
+ if (isStringArray(r.extensions))
581
+ out.extensions = r.extensions;
582
+ if (isStringArray(r.availableTo))
583
+ out.availableTo = r.availableTo;
584
+ return Object.keys(out).length > 0 ? out : null;
585
+ }
586
+ /** Merge a raw `kinds` block over `base` (defaulting to the builtin registry):
587
+ * invalid entries are dropped, never thrown. Mirrors `mergeModelLadders`'s
588
+ * layer-over-defaults shape so a config layer can add or override a single
589
+ * kind without restating the whole registry. One rule, no entry shapes:
590
+ * - A kind some lower layer already defines: the entry FIELD-MERGES over it
591
+ * — any subset of fields, `whenToUse` included, so overriding just the
592
+ * spawn-menu guidance of a builtin kind never strips its model tier
593
+ * (the sparse-override shape `persistDefaultKindModel` writes).
594
+ * - A NEW kind: the entry defines it and must carry `whenToUse` (a kind
595
+ * with no spawn-menu gloss is meaningless); dropped otherwise.
596
+ * Deliberately NOT expressible: wholly replacing a lower layer's kind — a
597
+ * field can be overridden but never removed by omission. */
598
+ function mergeKinds(raw, base = defaultKindsConfig()) {
599
+ const out = { ...base };
600
+ if (raw !== null && typeof raw === 'object') {
601
+ for (const [kind, value] of Object.entries(raw)) {
602
+ const fields = normalizeKindFields(value);
603
+ if (fields === null)
604
+ continue;
605
+ const existing = out[kind];
606
+ if (existing !== undefined) {
607
+ out[kind] = { ...existing, ...fields };
608
+ }
609
+ else if (fields.whenToUse !== undefined) {
610
+ out[kind] = fields;
611
+ }
612
+ }
613
+ }
614
+ return out;
615
+ }
616
+ /** Layer the `kinds` contributions of every ENABLED plugin installed under
617
+ * `scopeRootPath` over `base`, plugin name-sorted for determinism (directory
618
+ * listing order is filesystem-dependent). Each plugin's block goes through
619
+ * the same `mergeKinds` a scope `config.json` does — full entries add or
620
+ * shadow, patches field-merge, invalid entries drop. Called by
621
+ * `readMergedLaunchConfig` BELOW the host scope's own raw `kinds`, so the
622
+ * scope's config.json always overrides its plugins. */
623
+ function layerPluginKinds(scope, scopeRootPath, base) {
624
+ if (scopeRootPath === null)
625
+ return base;
626
+ const plugins = listInstalledPluginsInRoot(scope, scopeRootPath)
627
+ .filter((p) => p.enabled && p.manifest.kinds !== undefined)
628
+ .sort((a, b) => a.name.localeCompare(b.name));
629
+ let out = base;
630
+ for (const plugin of plugins)
631
+ out = mergeKinds(plugin.manifest.kinds, out);
632
+ return out;
633
+ }
634
+ /** The ACTIVE page-component catalog every authoring, validation, daemon,
635
+ * inbox, and TUI consumer resolves: the user scope's own `page_components`
636
+ * entries plus the contributions of every ENABLED plugin installed under that
637
+ * same root, plugin name-sorted for determinism (directory listing order is
638
+ * filesystem-dependent). Installing, updating, or removing a plugin therefore
639
+ * adds, changes, or retires its components with no config edit.
640
+ *
641
+ * User scope only, deliberately: this catalog is read from the daemon, the
642
+ * CLI, and the inbox TUI, whose ambient cwds differ, and a page slot whose
643
+ * validity depended on which directory a process happened to start in would
644
+ * validate in one surface and fail in another. `page_components` has always
645
+ * been a user-scope block (`readConfig('user')`); its plugin channel keeps
646
+ * that boundary.
647
+ *
648
+ * Throws on a duplicate kind or derived JSX tag across sources, and on a
649
+ * plugin whose block is malformed — matching the existing catalog validation
650
+ * style, where a bad `page_components` block already fails config read rather
651
+ * than silently dropping a component the product expects to be authorable.
652
+ * Install-time validation (`invalidPageComponentsReasons`) is what keeps a
653
+ * malformed block from ever reaching this path. */
654
+ export function resolvePageComponents() {
655
+ const layers = [{ origin: 'config.json', components: readConfig('user').page_components }];
656
+ const root = scopeRoot('user');
657
+ if (root !== null) {
658
+ const plugins = listInstalledPluginsInRoot('user', root)
659
+ .filter((plugin) => plugin.enabled && plugin.manifest.page_components !== undefined)
660
+ .sort((a, b) => a.name.localeCompare(b.name));
661
+ for (const plugin of plugins) {
662
+ const label = `plugin "${plugin.name}" page_components`;
663
+ layers.push({ origin: `plugin "${plugin.name}"`, components: normalizePageComponents(plugin.manifest.page_components, label) });
664
+ }
665
+ }
666
+ return mergePageComponentLayers(layers);
667
+ }
668
+ const KIND_CONFIG_KEYS = new Set(['whenToUse', 'model', 'orchestratorModel', 'tools', 'extensions', 'availableTo']);
669
+ /** STRICT install-time validation for a plugin-declared `kinds` block —
670
+ * the loud counterpart to `mergeKinds`'s silent read-time dropping. Read
671
+ * paths must never throw on bad config, but an INSTALL delivering a bad
672
+ * block must fail the install, not ship a kind that silently never
673
+ * registers. Returns one human-readable reason per defect; empty = valid.
674
+ * Used by the archive-bundle validator (`command-plugins/bundle.ts`) on
675
+ * `bundle.json`'s optional `kinds` member. */
676
+ export function invalidPluginKindsReasons(raw) {
677
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
678
+ return ['kinds must be a JSON object keyed by kind name'];
679
+ }
680
+ const reasons = [];
681
+ const isStringArray = (v) => Array.isArray(v) && v.every((item) => typeof item === 'string');
682
+ for (const [kind, value] of Object.entries(raw)) {
683
+ if (kind.trim() === '') {
684
+ reasons.push('kinds contains an empty kind name');
685
+ continue;
686
+ }
687
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
688
+ reasons.push(`kinds.${kind} must be a JSON object`);
689
+ continue;
690
+ }
691
+ const entry = value;
692
+ for (const key of Object.keys(entry)) {
693
+ if (!KIND_CONFIG_KEYS.has(key))
694
+ reasons.push(`kinds.${kind}.${key} is not a KindConfig field (expected one of: ${[...KIND_CONFIG_KEYS].join(', ')})`);
695
+ }
696
+ if (entry['whenToUse'] !== undefined && (typeof entry['whenToUse'] !== 'string' || entry['whenToUse'].trim() === '')) {
697
+ reasons.push(`kinds.${kind}.whenToUse must be a non-empty string`);
698
+ }
699
+ for (const field of ['model', 'orchestratorModel']) {
700
+ if (entry[field] !== undefined && (typeof entry[field] !== 'string' || entry[field].trim() === '')) {
701
+ reasons.push(`kinds.${kind}.${field} must be a non-empty string`);
702
+ }
703
+ }
704
+ for (const field of ['tools', 'extensions', 'availableTo']) {
705
+ if (entry[field] !== undefined && !isStringArray(entry[field])) {
706
+ reasons.push(`kinds.${kind}.${field} must be an array of strings`);
707
+ }
708
+ }
709
+ if (normalizeKindFields(value) === null) {
710
+ reasons.push(`kinds.${kind} has no valid KindConfig field — an entry must set at least one of: ${[...KIND_CONFIG_KEYS].join(', ')} (whenToUse is required when the kind exists in no lower layer)`);
711
+ }
712
+ }
713
+ return reasons;
714
+ }
715
+ /** Validate one raw `remoteCanvas.targets.<name>` entry, or drop it (return
716
+ * null) rather than throwing — same rule `normalizeKindFields` follows. A
717
+ * valid entry needs a non-empty `previewEndpoint` and `relayTokenRef`; the
718
+ * token itself is never stored here (see `RemoteCanvasTarget`). */
719
+ function normalizeRemoteCanvasTarget(raw) {
720
+ if (raw === null || typeof raw !== 'object')
721
+ return null;
722
+ const r = raw;
723
+ if (typeof r.previewEndpoint !== 'string' || r.previewEndpoint.trim() === '')
724
+ return null;
725
+ if (typeof r.relayTokenRef !== 'string' || r.relayTokenRef.trim() === '')
726
+ return null;
727
+ const out = { previewEndpoint: r.previewEndpoint, relayTokenRef: r.relayTokenRef };
728
+ if (typeof r.cpOrigin === 'string' && r.cpOrigin.trim() !== '')
729
+ out.cpOrigin = r.cpOrigin;
730
+ return out;
731
+ }
732
+ /** Merge a raw `remoteCanvas` block over the built-in defaults (no targets):
733
+ * each valid `targets.<name>` entry adds or shadows a target by name;
734
+ * invalid entries are dropped, never thrown. Mirrors `mergeKinds`'s
735
+ * layer-over-defaults shape. */
736
+ function mergeRemoteCanvas(raw, base = defaultRemoteCanvasConfig()) {
737
+ const out = { ...base.targets };
738
+ if (raw !== null && typeof raw === 'object') {
739
+ const r = raw;
740
+ if (r.targets !== null && typeof r.targets === 'object') {
741
+ for (const [name, value] of Object.entries(r.targets)) {
742
+ const normalized = normalizeRemoteCanvasTarget(value);
743
+ if (normalized !== null)
744
+ out[name] = normalized;
745
+ }
746
+ }
747
+ }
748
+ return { targets: out };
749
+ }
750
+ function normalizeMode(value, fallback) {
751
+ if (value === true)
752
+ return 'notify';
753
+ if (value === false)
754
+ return false;
755
+ if (value === 'notify' || value === 'apply')
756
+ return value;
757
+ return fallback;
758
+ }
759
+ function normalizePositiveInteger(value, fallback) {
760
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1
761
+ ? value
762
+ : fallback;
763
+ }
764
+ function mergeBrokerThresholds(raw, defaults) {
765
+ const partial = raw !== null && typeof raw === 'object'
766
+ ? raw
767
+ : {};
768
+ const warning = normalizePositiveInteger(partial.warning, defaults.warning);
769
+ const automaticReviveCap = normalizePositiveInteger(partial.automaticReviveCap, defaults.automaticReviveCap);
770
+ return warning <= automaticReviveCap
771
+ ? { warning, automaticReviveCap }
772
+ : { ...defaults };
773
+ }
774
+ function mergeLifecycle(raw, defaults) {
775
+ const partial = raw !== null && typeof raw === 'object'
776
+ ? raw
777
+ : {};
778
+ return { unattendedParkMs: normalizePositiveInteger(partial.unattendedParkMs, defaults.unattendedParkMs) };
779
+ }
780
+ function normalizeWhipMessages(raw) {
781
+ if (!Array.isArray(raw))
782
+ return [...DEFAULT_WHIP_MESSAGES];
783
+ const messages = raw.filter((message) => typeof message === 'string' && message.trim() !== '').map((message) => message.trim());
784
+ return messages.length > 0 ? messages : [...DEFAULT_WHIP_MESSAGES];
785
+ }
786
+ function mergeApi(raw, defaults) {
787
+ if (raw === null || typeof raw !== 'object')
788
+ return { ...defaults };
789
+ const tcp = raw.tcp;
790
+ return { tcp: typeof tcp === 'string' ? tcp : defaults.tcp };
791
+ }
792
+ function mergeConfig(partial) {
793
+ const defaults = defaultScopeConfig();
794
+ const schema_version = defaults.schema_version;
795
+ const marketplaces = partial.marketplaces === undefined ? {} : partial.marketplaces;
796
+ const plugins = partial.plugins === undefined ? {} : partial.plugins;
797
+ const au = partial.auto_update;
798
+ const rawInterval = au && typeof au.interval_hours === 'number' ? au.interval_hours : undefined;
799
+ const interval_hours = rawInterval !== undefined && Number.isFinite(rawInterval) && rawInterval >= 0
800
+ ? rawInterval
801
+ : defaults.auto_update.interval_hours;
802
+ const auto_update = {
803
+ crtr: normalizeMode(au?.crtr, defaults.auto_update.crtr),
804
+ content: normalizeMode(au?.content, defaults.auto_update.content),
805
+ interval_hours,
806
+ };
807
+ const rawMaxPanes = partial.max_panes_per_window;
808
+ const max_panes_per_window = normalizePositiveInteger(rawMaxPanes, defaults.max_panes_per_window);
809
+ const auto_open_child_viewers = typeof partial.auto_open_child_viewers === 'boolean'
810
+ ? partial.auto_open_child_viewers
811
+ : defaults.auto_open_child_viewers;
812
+ const brokerThresholds = mergeBrokerThresholds(partial.brokerThresholds, defaults.brokerThresholds);
813
+ const lifecycle = mergeLifecycle(partial.lifecycle, defaults.lifecycle);
814
+ const completion_bell = typeof partial.completion_bell === 'boolean' ? partial.completion_bell : defaults.completion_bell;
815
+ const working_gerunds = partial.working_gerunds === undefined
816
+ ? [...defaults.working_gerunds]
817
+ : normalizeWorkingGerunds(partial.working_gerunds);
818
+ const whip_mode = typeof partial.whip_mode === 'boolean' ? partial.whip_mode : defaults.whip_mode;
819
+ const page_components = normalizePageComponents(partial.page_components);
820
+ const page_surface = typeof partial.page_surface === 'boolean' ? partial.page_surface : defaults.page_surface;
821
+ const whip_messages = normalizeWhipMessages(partial.whip_messages);
822
+ const whip_message_mode = WHIP_MESSAGE_MODES.includes(partial.whip_message_mode)
823
+ ? partial.whip_message_mode
824
+ : defaults.whip_message_mode;
825
+ const mouse_mode_default = MOUSE_MODE_DEFAULTS.includes(partial.mouse_mode_default)
826
+ ? partial.mouse_mode_default
827
+ : defaults.mouse_mode_default;
828
+ const live_cycles = normalizePositiveInteger(partial.live_cycles, defaults.live_cycles);
829
+ const condensed_history = CONDENSED_HISTORY_MODES.includes(partial.condensed_history)
830
+ ? partial.condensed_history
831
+ : defaults.condensed_history;
832
+ const bash_tool_purpose = typeof partial.bash_tool_purpose === 'boolean' ? partial.bash_tool_purpose : defaults.bash_tool_purpose;
833
+ const fold_finished_tools = typeof partial.fold_finished_tools === 'boolean' ? partial.fold_finished_tools : defaults.fold_finished_tools;
834
+ const summarize_tool_calls = typeof partial.summarize_tool_calls === 'boolean' ? partial.summarize_tool_calls : defaults.summarize_tool_calls;
835
+ const detailed_tool_recaps = typeof partial.detailed_tool_recaps === 'boolean' ? partial.detailed_tool_recaps : defaults.detailed_tool_recaps;
836
+ const keybindings = normalizeKeybindings(partial.keybindings);
837
+ const tmux_passthrough = normalizeTmuxPassthrough(partial.tmux_passthrough);
838
+ const modelLadders = mergeModelLadders(partial.modelLadders);
839
+ const providerOptions = mergeProviderOptions(partial.providerOptions, defaults.providerOptions);
840
+ const routes = resolveModelRoutes(mergeModelRoutePatches(partial.modelRoutes));
841
+ const modelRoutes = routes.complete;
842
+ const modelRouting = mergeModelRouting(partial.modelRouting, undefined);
843
+ const kinds = mergeKinds(partial.kinds);
844
+ const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
845
+ const api = mergeApi(partial.api, defaults.api);
846
+ const paths = partial.paths;
847
+ if (paths?.user_files !== undefined && (typeof paths.user_files !== 'string' || !paths.user_files.startsWith('/'))) {
848
+ throw new Error('paths.user_files must be an absolute path');
849
+ }
850
+ const store = partial.store;
851
+ const runtime = partial.runtime;
852
+ if (runtime?.person_uids !== undefined && (!Array.isArray(runtime.person_uids)
853
+ || !runtime.person_uids.every((uid) => Number.isSafeInteger(uid) && uid >= 0))) {
854
+ throw new Error('runtime.person_uids must be an array of nonnegative integer uids');
855
+ }
856
+ const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
857
+ const bin = normalizeBin(partial.bin);
858
+ const humanActions = normalizeHumanActions(partial.humanActions);
859
+ return { bin, humanActions, schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, lifecycle, completion_bell, working_gerunds, whip_mode, page_components, page_surface, whip_messages, whip_message_mode, mouse_mode_default, live_cycles, condensed_history, bash_tool_purpose, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, providerOptions, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(Object.keys(routes.legacy).length > 0 ? { legacyModelRoutes: routes.legacy } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, api, ...(paths ? { paths } : {}), ...(store ? { store } : {}), ...(runtime ? { runtime } : {}), spawnEnv };
860
+ }
861
+ /** Raw (un-defaulted) partial config for one scope, or null if the scope has
862
+ * no root or no config.json. Used by `readMergedLaunchConfig` to layer
863
+ * scopes onto each other WITHOUT each scope's own default-fill masking a
864
+ * lower-precedence scope's real customization (see that function's comment
865
+ * for why `readConfig(scope)`, which already defaults-fills, is unusable
866
+ * for cross-scope layering). */
867
+ export function readRawConfigAtRoot(root) {
868
+ return readRawAtRoot(root);
869
+ }
870
+ export function readRawScopeConfig(scope) {
871
+ const root = scopeRoot(scope);
872
+ return root ? readRawConfigAtRoot(root) : null;
873
+ }
874
+ /** Raw SQL-backed profile settings, or null when the profile is absent. */
875
+ function readRawProfileConfig(profileId) {
876
+ if (profileId === null || profileId === '')
877
+ return null;
878
+ try {
879
+ return readRawAtRoot(profileMemoryDir(profileId));
880
+ }
881
+ catch {
882
+ return null;
883
+ }
884
+ }
885
+ /** Raw partial configs for the whole project-scope STACK (`findProjectScopeRoots`
886
+ * — every ancestor `.crouter/`, widened by a selected profile's `projects`),
887
+ * ordered FARTHEST-first so `readMergedLaunchConfig` can layer them on last
888
+ * and have the NEAREST root win — mirroring the resolver's nearest-project-
889
+ * strongest replacement precedence. Walks from `targetCwd`/`targetProfileId`
890
+ * — the SCOPE THE CONFIG IS FOR, not necessarily the calling process's own
891
+ * ambient cwd/profile (see `readMergedLaunchConfig`). */
892
+ function readRawProjectScopeConfigs(targetCwd, targetProfileId) {
893
+ const nearestFirst = findProjectScopeRoots(targetCwd, targetProfileId);
894
+ const farthestFirst = [...nearestFirst].reverse();
895
+ // A root with no config.json still contributes: its installed plugins may
896
+ // declare kinds, so the ROOT rides along and `raw` stays null.
897
+ return farthestFirst.map((root) => ({ root, raw: readRawAtRoot(root) }));
898
+ }
899
+ /** Merge launch knobs (`kinds`, `modelLadders`) across scopes in
900
+ * project stack > profile > user > builtin precedence — the same precedence
901
+ * order used for memory resolution. For `kinds` only, each plugin-bearing
902
+ * scope (user, each project root) additionally layers its enabled plugins'
903
+ * manifest `kinds` blocks directly BELOW that scope's own raw config — see
904
+ * `layerPluginKinds`. A kind or ladder cell declared at a
905
+ * more-specific scope shadows the same key from a less-specific scope; the
906
+ * project STACK (`findProjectScopeRoots` — every ancestor `.crouter/`,
907
+ * widened by a selected profile's `projects`) layers nearest-root-strongest;
908
+ * a key no scope declares falls through to the builtin default registry
909
+ * (`defaultScopeConfig().kinds` / `.modelLadders`).
910
+ *
911
+ * Layers RAW (un-defaulted) partial config per scope, not `readConfig(scope)`
912
+ * — `readConfig` already fills in every default kind for a scope that
913
+ * customizes even one, so naively overlaying two already-defaulted
914
+ * `ScopeConfig.kinds` objects would let an untouched project-scope kind
915
+ * (silently defaulted) clobber a real user-scope customization of that same
916
+ * kind. Layering the raw partials avoids that.
917
+ *
918
+ * Callers (launch, kind registry) go through this function rather than
919
+ * `readConfig` directly, so `ScopeConfig.kinds`/`modelLadders`/tools/
920
+ * extensions/`availableTo` all honor the same profile + multi-project
921
+ * precedence.
922
+ *
923
+ * `targetCwd`/`targetProfileId` default to this PROCESS's own ambient cwd
924
+ * and `CRTR_PROFILE_ID` — correct for a caller resolving config for itself
925
+ * (front-door commands, kind listing). A caller resolving config on behalf
926
+ * of a DIFFERENT node (the broker spawn-env boundary or cron executor — both
927
+ * run inside the daemon, whose ambient cwd/profile is the daemon's own, not
928
+ * the target node's) MUST pass the target's own cwd/profile explicitly, or
929
+ * `spawnEnv.allow` resolves from the wrong scope entirely (the C-1
930
+ * follow-up fix this parameterization exists for). */
931
+ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileId = envProfileId() || null) {
932
+ const snapshot = brokerSnapshotState[brokerSnapshotKey];
933
+ if (snapshot)
934
+ return snapshot.launch;
935
+ const defaults = defaultScopeConfig();
936
+ const userRaw = readRawScopeConfig('user');
937
+ const profileRaw = readRawProfileConfig(targetProfileId);
938
+ const projectLayersFarthestFirst = readRawProjectScopeConfigs(targetCwd, targetProfileId);
939
+ // Kinds layer per scope as: that scope's enabled PLUGINS first, then the
940
+ // scope's own raw config — so a plugin can add or shadow a kind (registry
941
+ // entry in its manifest + gated persona docs in its memory tree) and the
942
+ // scope's config.json still overrides its plugins. Other launch knobs
943
+ // (ladders/routes/spawnEnv) have no plugin channel.
944
+ let kinds = mergeKinds(userRaw?.kinds, layerPluginKinds('user', scopeRoot('user'), defaults.kinds));
945
+ let modelLadders = mergeModelLadders(userRaw?.modelLadders, defaults.modelLadders);
946
+ let providerOptions = mergeProviderOptions(userRaw?.providerOptions, defaults.providerOptions);
947
+ let modelRoutePatches = mergeModelRoutePatches(userRaw?.modelRoutes);
948
+ let modelRouting = mergeModelRouting(userRaw?.modelRouting, undefined);
949
+ let spawnEnv = mergeSpawnEnv(userRaw?.spawnEnv, defaults.spawnEnv?.allow);
950
+ if (profileRaw !== null) {
951
+ kinds = mergeKinds(profileRaw.kinds, kinds);
952
+ modelLadders = mergeModelLadders(profileRaw.modelLadders, modelLadders);
953
+ providerOptions = mergeProviderOptions(profileRaw.providerOptions, providerOptions);
954
+ modelRoutePatches = mergeModelRoutePatches(profileRaw.modelRoutes, modelRoutePatches);
955
+ modelRouting = mergeModelRouting(profileRaw.modelRouting, modelRouting);
956
+ spawnEnv = mergeSpawnEnv(profileRaw.spawnEnv, spawnEnv.allow);
957
+ }
958
+ for (const { root, raw } of projectLayersFarthestFirst) {
959
+ kinds = mergeKinds(raw?.kinds, layerPluginKinds('project', root, kinds));
960
+ if (raw === null)
961
+ continue;
962
+ modelLadders = mergeModelLadders(raw.modelLadders, modelLadders);
963
+ providerOptions = mergeProviderOptions(raw.providerOptions, providerOptions);
964
+ modelRoutePatches = mergeModelRoutePatches(raw.modelRoutes, modelRoutePatches);
965
+ modelRouting = mergeModelRouting(raw.modelRouting, modelRouting);
966
+ spawnEnv = mergeSpawnEnv(raw.spawnEnv, spawnEnv.allow);
967
+ }
968
+ const routes = resolveModelRoutes(modelRoutePatches);
969
+ return {
970
+ kinds,
971
+ modelLadders,
972
+ providerOptions,
973
+ ...(Object.keys(routes.complete).length > 0 ? { modelRoutes: routes.complete } : {}),
974
+ ...(Object.keys(routes.legacy).length > 0 ? { legacyModelRoutes: routes.legacy } : {}),
975
+ ...(modelRouting !== undefined ? { modelRouting } : {}),
976
+ spawnEnv,
977
+ };
978
+ }
979
+ /** The effective `KindConfig` for one full kind string (top-level, e.g.
980
+ * `developer`, or sub-kind, e.g. `audit/security`), across
981
+ * project > user > builtin precedence. Returns `undefined` for a kind no
982
+ * scope registers — existence is deliberately NOT validated here (kind
983
+ * existence/launch-menu enumeration is a caller concern); this only
984
+ * resolves the config for a kind the caller already knows about. */
985
+ export function resolveKindConfig(kind) {
986
+ return readMergedLaunchConfig().kinds[kind];
987
+ }
988
+ /** The sub-kinds available to spawn FROM a given top-level kind — every
989
+ * registered sub-kind (full path contains `/`) whose `availableTo` (default:
990
+ * its own top-level ancestor, e.g. `audit/security` defaults to
991
+ * `['audit']`) includes `kind` or `'*'`. The single source both `sys
992
+ * prompt-review --list`'s `subPersonas` metadata and the live sub-persona
993
+ * spawn-menu splice (`core/substrate/delivery/render-boot.ts`) read, so the two can never
994
+ * drift apart. Sorted by full kind name for stable rendering. */
995
+ export function subKindsAvailableTo(kind) {
996
+ const registry = readMergedLaunchConfig().kinds;
997
+ const out = [];
998
+ for (const [full, cfg] of Object.entries(registry)) {
999
+ const slash = full.indexOf('/');
1000
+ if (slash === -1)
1001
+ continue;
1002
+ const topAncestor = full.slice(0, slash);
1003
+ const availableTo = cfg.availableTo ?? [topAncestor];
1004
+ if (!(availableTo.includes('*') || availableTo.includes(kind)))
1005
+ continue;
1006
+ out.push({ kind: full, whenToUse: cfg.whenToUse });
1007
+ }
1008
+ return out.sort((a, b) => a.kind.localeCompare(b.kind));
1009
+ }
1010
+ /** The SPARSE `modelLadders` to persist after a config mutation: the raw
1011
+ * on-disk sparse ladder (absent rungs stay absent) plus ONLY the specific
1012
+ * rungs / `defaultProvider` the mutation actually changed away from the
1013
+ * merged baseline. Untouched default rungs are never materialized -- they
1014
+ * hold concrete model ids that go stale across builds (the live-Hearth freeze
1015
+ * bug). Returns undefined when nothing ladder-related is on disk and nothing
1016
+ * was touched, so the key is omitted from config.json entirely. This is what
1017
+ * makes a direct `sys config set modelLadders.<provider>.<strength>` persist
1018
+ * only the touched rung through the SAME central write path as every other
1019
+ * mutation, rather than a special case. */
1020
+ function sparseModelLaddersToPersist(rawLadders, baseline, mutated) {
1021
+ const out = { ...(rawLadders ?? {}) };
1022
+ if (out.anthropic !== undefined)
1023
+ out.anthropic = { ...out.anthropic };
1024
+ if (out.openai !== undefined)
1025
+ out.openai = { ...out.openai };
1026
+ if (mutated.defaultProvider !== baseline.defaultProvider && mutated.defaultProvider !== undefined) {
1027
+ out.defaultProvider = mutated.defaultProvider;
1028
+ }
1029
+ for (const provider of ['anthropic', 'openai']) {
1030
+ for (const strength of ['ultra', 'strong', 'medium', 'light']) {
1031
+ if (mutated[provider][strength] !== baseline[provider][strength]) {
1032
+ out[provider] = { ...(out[provider] ?? {}), [strength]: mutated[provider][strength] };
1033
+ }
1034
+ }
1035
+ }
1036
+ const hasContent = out.defaultProvider !== undefined || out.anthropic !== undefined || out.openai !== undefined;
1037
+ return hasContent ? out : undefined;
1038
+ }
1039
+ /** Apply `mutate` to a scope's MERGED config (so callers can read + write any
1040
+ * field), then persist only a RAW PARTIAL: every top-level key the mutation
1041
+ * left untouched keeps its exact on-disk raw value (absent stays absent, a
1042
+ * sparse override stays sparse), and only keys the mutation actually changed
1043
+ * are written. `modelLadders` gets sub-field-granular sparse handling via
1044
+ * `sparseModelLaddersToPersist`. Result: a fresh bootstrap or any unrelated
1045
+ * `sys config set` never freezes a full concrete `modelLadders` to disk,
1046
+ * and a direct ladder-rung set still persists only the touched rung -- all
1047
+ * through one lock-protected atomic update path. */
1048
+ function mutateConfigAtRoot(root, mutate) {
1049
+ return mutateRawAtRoot(root, (raw) => {
1050
+ const baseline = mergeConfig(raw);
1051
+ const cfg = mergeConfig(raw);
1052
+ mutate(cfg);
1053
+ const partial = { ...raw };
1054
+ for (const key of SCOPE_CONFIG_KEYS)
1055
+ if (key !== 'modelLadders' && !isDeepStrictEqual(cfg[key], baseline[key]))
1056
+ partial[key] = cfg[key];
1057
+ const ladders = sparseModelLaddersToPersist(raw.modelLadders, baseline.modelLadders, cfg.modelLadders);
1058
+ if (ladders !== undefined)
1059
+ partial.modelLadders = ladders;
1060
+ else
1061
+ delete partial.modelLadders;
1062
+ return { value: cfg, next: partial };
1063
+ });
1064
+ }
1065
+ /** Persist an EXPLICIT sparse `modelLadders` intent at one config root: the
1066
+ * patch's rungs (and `defaultProvider`) are merged over whatever sparse ladder
1067
+ * is already on disk, and every untouched rung stays absent so it keeps
1068
+ * re-deriving from the running build's registry.
1069
+ *
1070
+ * Ladder edits must come through here rather than `updateConfig`, because that
1071
+ * path infers what to persist by diffing the mutated config against the merged
1072
+ * baseline: setting a rung to the value that happens to be today's compiled
1073
+ * default produces an empty diff and silently persists nothing, so the choice
1074
+ * evaporates as soon as the default moves. Explicit intent is recorded as
1075
+ * written. Mirrors `persistDefaultKindModel`. */
1076
+ export function persistModelLadders(root, patch) {
1077
+ mutateRawAtRoot(root, (raw) => { const current = (raw.modelLadders ?? {}); const next = { ...current }; if (patch.defaultProvider !== undefined)
1078
+ next.defaultProvider = patch.defaultProvider; for (const provider of ['anthropic', 'openai'])
1079
+ if (patch[provider] !== undefined)
1080
+ next[provider] = { ...(current[provider] ?? {}), ...patch[provider] }; return { value: undefined, next: { ...raw, modelLadders: next } }; });
1081
+ }
1082
+ /** Persist one complete route at one exact config root. The route is the only
1083
+ * raw route entry touched; unrelated routes and local ladder defaults remain
1084
+ * absent. Pass `undefined` to remove this scope's entry, which resumes lower
1085
+ * scope inheritance rather than introducing a tombstone. */
1086
+ export function persistModelRoute(root, routeId, route) {
1087
+ if (route !== undefined)
1088
+ assertCompleteModelRoute(routeId, route);
1089
+ else if (routeId.trim() === '')
1090
+ throw new Error('model route id must be a non-empty string');
1091
+ mutateRawAtRoot(root, (raw) => { const routes = copyRouteRecord((raw.modelRoutes ?? {})); if (route === undefined)
1092
+ delete routes[routeId];
1093
+ else
1094
+ setRouteRecord(routes, routeId, { ...route, models: { ...route.models } }); const next = { ...raw }; if (Object.keys(routes).length === 0)
1095
+ delete next.modelRoutes;
1096
+ else
1097
+ next.modelRoutes = routes; return { value: undefined, next }; });
1098
+ }
1099
+ /** Mutate one exact config root. Setup uses this for profile and explicit
1100
+ * project targets that cannot be represented by the user/project Scope enum. */
1101
+ export function updateConfigAtRoot(root, mutate) {
1102
+ return mutateConfigAtRoot(root, mutate);
1103
+ }
1104
+ export function updateConfig(scope, mutate) {
1105
+ return updateConfigAtRoot(requireScopeRoot(scope), mutate);
1106
+ }
1107
+ /** Persist one sparse kind-model field at an exact config root. The sparse
1108
+ * patch keeps every unrelated kind and launch knob inherited from lower
1109
+ * scopes instead of freezing a materialized registry snapshot. */
1110
+ export function persistDefaultKindModel(opts) {
1111
+ const field = opts.orchestrator === true ? 'orchestratorModel' : 'model';
1112
+ mutateRawAtRoot(opts.root, (raw) => { const kinds = { ...(raw.kinds ?? {}), [opts.kind]: { ...(raw.kinds ?? {})[opts.kind], [field]: opts.model } }; return { value: undefined, next: { ...raw, kinds: kinds } }; });
1113
+ return { path: configPathFor(opts.root), field };
1114
+ }
1115
+ export function updateState(scope, mutate) {
1116
+ const s = readState(scope);
1117
+ mutate(s);
1118
+ writeState(scope, s);
1119
+ return s;
1120
+ }