@crouter/api 0.3.387 → 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.
- package/dist/api/__tests__/integration/client.test.js +97 -0
- package/dist/api/client.d.ts +7 -0
- package/dist/api/client.js +40 -21
- package/dist/core/asset-root.d.ts +7 -0
- package/dist/core/asset-root.js +18 -0
- package/dist/core/canvas/boot-id.d.ts +6 -0
- package/dist/core/canvas/boot-id.js +26 -0
- package/dist/core/canvas/paths.d.ts +72 -0
- package/dist/core/canvas/paths.js +163 -0
- package/dist/core/canvas/pid.d.ts +391 -0
- package/dist/core/canvas/pid.js +948 -0
- package/dist/core/command-plugins/bundle.d.ts +149 -0
- package/dist/core/command-plugins/bundle.js +588 -0
- package/dist/core/command-plugins/endpoint.d.ts +24 -0
- package/dist/core/command-plugins/endpoint.js +51 -0
- package/dist/core/config.d.ts +233 -0
- package/dist/core/config.js +1120 -0
- package/dist/core/env-name.d.ts +6 -0
- package/dist/core/env-name.js +9 -0
- package/dist/core/errors.d.ts +38 -0
- package/dist/core/errors.js +90 -0
- package/dist/core/events/emit.d.ts +6 -0
- package/dist/core/events/emit.js +42 -0
- package/dist/core/events/envelope.d.ts +2 -0
- package/dist/core/events/envelope.js +84 -0
- package/dist/core/events/errors.d.ts +4 -0
- package/dist/core/events/errors.js +69 -0
- package/dist/core/events/operation-id.d.ts +4 -0
- package/dist/core/events/operation-id.js +24 -0
- package/dist/core/events/serialize.d.ts +4 -0
- package/dist/core/events/serialize.js +199 -0
- package/dist/core/events/source.d.ts +16 -0
- package/dist/core/events/source.js +31 -0
- package/dist/core/events/types.d.ts +68 -0
- package/dist/core/events/types.js +11 -0
- package/dist/core/exclusive-lock.d.ts +34 -0
- package/dist/core/exclusive-lock.js +197 -0
- package/dist/core/fs-utils.d.ts +44 -0
- package/dist/core/fs-utils.js +208 -0
- package/dist/core/help.d.ts +309 -0
- package/dist/core/help.js +406 -0
- package/dist/core/human/page-catalog.d.ts +57 -0
- package/dist/core/human/page-catalog.js +172 -0
- package/dist/core/installed-plugins.d.ts +2 -0
- package/dist/core/installed-plugins.js +79 -0
- package/dist/core/io.d.ts +122 -0
- package/dist/core/io.js +373 -0
- package/dist/core/keybindings/attach-control.d.ts +49 -0
- package/dist/core/keybindings/attach-control.js +42 -0
- package/dist/core/keybindings/catalog.d.ts +18 -0
- package/dist/core/keybindings/catalog.js +257 -0
- package/dist/core/keybindings/types.d.ts +42 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/layout.d.ts +26 -0
- package/dist/core/layout.js +94 -0
- package/dist/core/locked-file.d.ts +27 -0
- package/dist/core/locked-file.js +118 -0
- package/dist/core/log.d.ts +9 -0
- package/dist/core/log.js +89 -0
- package/dist/core/manifest.d.ts +5 -0
- package/dist/core/manifest.js +15 -0
- package/dist/core/plugin-env.d.ts +8 -0
- package/dist/core/plugin-env.js +31 -0
- package/dist/core/plugin-extensions.d.ts +29 -0
- package/dist/core/plugin-extensions.js +191 -0
- package/dist/core/plugin-swap-lock.d.ts +9 -0
- package/dist/core/plugin-swap-lock.js +31 -0
- package/dist/core/preview-result-path.d.ts +4 -0
- package/dist/core/preview-result-path.js +26 -0
- package/dist/core/profiles/env-store.d.ts +22 -0
- package/dist/core/profiles/env-store.js +163 -0
- package/dist/core/profiles/fuzzy-match.d.ts +19 -0
- package/dist/core/profiles/fuzzy-match.js +92 -0
- package/dist/core/profiles/manifest.d.ts +120 -0
- package/dist/core/profiles/manifest.js +529 -0
- package/dist/core/rate-limit-scope.d.ts +25 -0
- package/dist/core/rate-limit-scope.js +64 -0
- package/dist/core/render.d.ts +12 -0
- package/dist/core/render.js +138 -0
- package/dist/core/resolver.d.ts +14 -0
- package/dist/core/resolver.js +111 -0
- package/dist/core/runtime/branded-host.d.ts +25 -0
- package/dist/core/runtime/branded-host.js +264 -0
- package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
- package/dist/core/runtime/broker/daemon-ops.js +177 -0
- package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
- package/dist/core/runtime/broker/signal-stream.js +149 -0
- package/dist/core/scope.d.ts +32 -0
- package/dist/core/scope.js +184 -0
- package/dist/core/scoped-state/db.d.ts +17 -0
- package/dist/core/scoped-state/db.js +247 -0
- package/dist/core/scoped-state/migrate.d.ts +8 -0
- package/dist/core/scoped-state/migrate.js +187 -0
- package/dist/core/scoped-state/paths.d.ts +9 -0
- package/dist/core/scoped-state/paths.js +27 -0
- package/dist/core/scoped-state/profiles.d.ts +27 -0
- package/dist/core/scoped-state/profiles.js +93 -0
- package/dist/core/scoped-state/providers.d.ts +24 -0
- package/dist/core/scoped-state/providers.js +19 -0
- package/dist/core/scoped-state/schema.d.ts +6 -0
- package/dist/core/scoped-state/schema.js +43 -0
- package/dist/core/scoped-state/settings.d.ts +28 -0
- package/dist/core/scoped-state/settings.js +83 -0
- package/dist/core/spaces/open-beneath.d.ts +71 -0
- package/dist/core/spaces/open-beneath.js +581 -0
- package/dist/core/sqlite-statements.d.ts +4 -0
- package/dist/core/sqlite-statements.js +17 -0
- package/dist/core/subscription-state.d.ts +121 -0
- package/dist/core/subscription-state.js +287 -0
- package/dist/core/user-settings.d.ts +377 -0
- package/dist/core/user-settings.js +458 -0
- package/dist/daemon/broker-signals/bus.d.ts +30 -0
- package/dist/daemon/broker-signals/bus.js +87 -0
- package/dist/daemon/manage.d.ts +176 -0
- package/dist/daemon/manage.js +664 -0
- package/dist/daemon/pidfile.d.ts +8 -0
- package/dist/daemon/pidfile.js +37 -0
- package/dist/daemon/startup-policy.d.ts +1 -0
- package/dist/daemon/startup-policy.js +1 -0
- package/dist/native/linux.d.ts +29 -0
- package/dist/native/linux.js +20 -0
- package/dist/shared/env.d.ts +116 -0
- package/dist/shared/env.js +271 -0
- package/dist/shared/inbox-entry-body.d.ts +22 -0
- package/dist/shared/inbox-entry-body.js +116 -0
- package/dist/shared/working-activity.d.ts +9 -0
- package/dist/shared/working-activity.js +27 -0
- package/dist/types.d.ts +562 -0
- package/dist/types.js +186 -0
- 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
|
+
}
|