@alisio/core 0.1.0-alpha.10
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/LICENSE +21 -0
- package/README.md +25 -0
- package/dist/application.d.ts +225 -0
- package/dist/application.js +950 -0
- package/dist/config.d.ts +481 -0
- package/dist/config.js +592 -0
- package/dist/core/compaction.d.ts +136 -0
- package/dist/core/compaction.js +404 -0
- package/dist/core/contracts.d.ts +147 -0
- package/dist/core/contracts.js +1 -0
- package/dist/core/registry.d.ts +8 -0
- package/dist/core/registry.js +54 -0
- package/dist/core/runner.d.ts +160 -0
- package/dist/core/runner.js +625 -0
- package/dist/extensions/registry.d.ts +28 -0
- package/dist/extensions/registry.js +58 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +28 -0
- package/dist/integrations/herdr.d.ts +34 -0
- package/dist/integrations/herdr.js +174 -0
- package/dist/mcp/connector.d.ts +65 -0
- package/dist/mcp/connector.js +423 -0
- package/dist/plugins/host.d.ts +169 -0
- package/dist/plugins/host.js +585 -0
- package/dist/plugins/install.d.ts +100 -0
- package/dist/plugins/install.js +322 -0
- package/dist/providers/openai-compatible.d.ts +14 -0
- package/dist/providers/openai-compatible.js +228 -0
- package/dist/providers/registry.d.ts +35 -0
- package/dist/providers/registry.js +74 -0
- package/dist/providers/routing.d.ts +11 -0
- package/dist/providers/routing.js +41 -0
- package/dist/providers/settings.d.ts +30 -0
- package/dist/providers/settings.js +80 -0
- package/dist/resources/context.d.ts +44 -0
- package/dist/resources/context.js +171 -0
- package/dist/resources/prompts.d.ts +77 -0
- package/dist/resources/prompts.js +182 -0
- package/dist/resources/skills.d.ts +82 -0
- package/dist/resources/skills.js +214 -0
- package/dist/runtime/fs.d.ts +9 -0
- package/dist/runtime/fs.js +73 -0
- package/dist/runtime/modules.d.ts +16 -0
- package/dist/runtime/modules.js +83 -0
- package/dist/runtime/paths.d.ts +4 -0
- package/dist/runtime/paths.js +42 -0
- package/dist/runtime/process.d.ts +25 -0
- package/dist/runtime/process.js +122 -0
- package/dist/runtime/sqlite.d.ts +3 -0
- package/dist/runtime/sqlite.js +85 -0
- package/dist/runtime/store.d.ts +38 -0
- package/dist/runtime/store.js +259 -0
- package/dist/sessions/children.d.ts +55 -0
- package/dist/sessions/children.js +233 -0
- package/dist/startup/index.d.ts +41 -0
- package/dist/startup/index.js +105 -0
- package/dist/startup/mascot.d.ts +6 -0
- package/dist/startup/mascot.js +30 -0
- package/dist/startup/screen.d.ts +18 -0
- package/dist/startup/screen.js +117 -0
- package/dist/startup/text.d.ts +19 -0
- package/dist/startup/text.js +68 -0
- package/dist/tools/execute.d.ts +16 -0
- package/dist/tools/execute.js +96 -0
- package/dist/tools/search.d.ts +69 -0
- package/dist/tools/search.js +232 -0
- package/dist/tools/standard.d.ts +22 -0
- package/dist/tools/standard.js +419 -0
- package/dist/tools/webfetch.d.ts +26 -0
- package/dist/tools/webfetch.js +119 -0
- package/dist/trust.d.ts +34 -0
- package/dist/trust.js +113 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.js +26 -0
- package/package.json +62 -0
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { configFile, configHome, loadConfigWithProvenance, overridesSavedProviderProfile, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
|
|
4
|
+
import { completeText } from "./core/compaction.js";
|
|
5
|
+
import { ToolRegistry } from "./core/registry.js";
|
|
6
|
+
import { AgentRunner } from "./core/runner.js";
|
|
7
|
+
import { HerdrBridge } from "./integrations/herdr.js";
|
|
8
|
+
import { McpConnector } from "./mcp/connector.js";
|
|
9
|
+
import { discoverPlugins, PluginHost } from "./plugins/host.js";
|
|
10
|
+
import { ActiveProvider, ProviderRegistry, UnconfiguredProvider } from "./providers/registry.js";
|
|
11
|
+
import { availableProviderModels, resolveProviderModel as resolveConfiguredModel, } from "./providers/routing.js";
|
|
12
|
+
import { ProviderSettingsStore } from "./providers/settings.js";
|
|
13
|
+
import { ProjectContext } from "./resources/context.js";
|
|
14
|
+
import { expandSlashPrompt, loadPromptTemplates, promptSources } from "./resources/prompts.js";
|
|
15
|
+
import { Skills, skillRoots } from "./resources/skills.js";
|
|
16
|
+
import { isPathSpec } from "./runtime/modules.js";
|
|
17
|
+
import { findWorkspace } from "./runtime/paths.js";
|
|
18
|
+
import { SQLiteStore } from "./runtime/store.js";
|
|
19
|
+
import { ChildSessions } from "./sessions/children.js";
|
|
20
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
/**
|
|
22
|
+
* Cap per teardown stage of `app.close()`: exit must never wait longer than this on a hanging
|
|
23
|
+
* component (MCP server, provider, plugin). Stages run in parallel where independent and
|
|
24
|
+
* failures are swallowed, so teardown errors never replace the caller's result.
|
|
25
|
+
*/
|
|
26
|
+
const TEARDOWN_STAGE_TIMEOUT_MS = 2_500;
|
|
27
|
+
/** A built-in runs unless its config sets `enabled: false` or it is disabled by id. */
|
|
28
|
+
export function enabledBuiltins(available, config, disabled = []) {
|
|
29
|
+
for (const id of [...Object.keys(config), ...disabled])
|
|
30
|
+
if (!available.some((p) => p.id === id))
|
|
31
|
+
throw new Error(`Unknown built-in plugin: ${id}`);
|
|
32
|
+
return available.filter((p) => config[p.id]?.enabled !== false && !disabled.includes(p.id));
|
|
33
|
+
}
|
|
34
|
+
const nonBlank = (value) => {
|
|
35
|
+
const normalized = value?.trim();
|
|
36
|
+
return normalized || undefined;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Per-profile optional context-window override, in tokens (for local OpenAI-compatible servers
|
|
40
|
+
* like llama.cpp whose `GET /models` omits `context_window`). Numbers are used directly; strings
|
|
41
|
+
* (for example hand-edited providers.json) are parsed. Absent or unparseable means "no override".
|
|
42
|
+
*/
|
|
43
|
+
const parseProfileContextWindow = (value) => {
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
return undefined;
|
|
46
|
+
const window = typeof value === "number" ? value : Number(String(value).trim());
|
|
47
|
+
return Number.isFinite(window) && window > 0 ? window : undefined;
|
|
48
|
+
};
|
|
49
|
+
export async function createApplication(options = {}) {
|
|
50
|
+
const cwd = resolve(options.cwd ?? process.cwd()), workspace = await findWorkspace(cwd);
|
|
51
|
+
const { config, provenance: configProvenance } = await loadConfigWithProvenance(workspace, {
|
|
52
|
+
file: options.config,
|
|
53
|
+
trustProject: options.trustProject,
|
|
54
|
+
model: options.model,
|
|
55
|
+
baseURL: options.baseURL,
|
|
56
|
+
apiMode: options.apiMode,
|
|
57
|
+
});
|
|
58
|
+
// Global-only consent: `mcp.allow` is read from the user layer; the merged config never carries
|
|
59
|
+
// a project value (see loadConfigWithProvenance), so a project cannot grant itself MCP consent.
|
|
60
|
+
const mcpAllow = config.mcp.allow === true;
|
|
61
|
+
let persistedMcpAllow = mcpAllow;
|
|
62
|
+
const store = new SQLiteStore(options.db ?? join(stateHome(), "sessions.sqlite"));
|
|
63
|
+
const registry = new ToolRegistry(), skills = new Skills({ overrides: config.skillOverrides }), context = new ProjectContext(workspace, {
|
|
64
|
+
globalDir: configHome(),
|
|
65
|
+
cwd,
|
|
66
|
+
claudeMdFallback: config.context.claudeMdFallback,
|
|
67
|
+
maxBytes: config.context.maxBytes,
|
|
68
|
+
}), providers = new ProviderRegistry(), plugins = new PluginHost(registry, store, config.pluginHooks, providers), mcp = new McpConnector(config.mcp.servers, registry, workspace, options.readOnly ? "read-only" : options.allowMcp || mcpAllow ? "allowed" : "disabled", config.mcpSources);
|
|
69
|
+
let mcpRuntimePermission = options.readOnly
|
|
70
|
+
? "read-only"
|
|
71
|
+
: options.allowMcp || mcpAllow
|
|
72
|
+
? "granted"
|
|
73
|
+
: "not-granted";
|
|
74
|
+
const herdr = options.noHerdr
|
|
75
|
+
? new HerdrBridge({}, async () => "")
|
|
76
|
+
: HerdrBridge.fromEnvironment(workspace, (error) => process.stderr.write(`${error}\n`));
|
|
77
|
+
try {
|
|
78
|
+
// First-party built-ins load through the trusted path, also under --read-only.
|
|
79
|
+
const builtinContext = {
|
|
80
|
+
workspace,
|
|
81
|
+
cwd,
|
|
82
|
+
stateHome: stateHome(),
|
|
83
|
+
configHome: configHome(),
|
|
84
|
+
home: homedir(),
|
|
85
|
+
trusted: !!options.trustProject || !!options.config,
|
|
86
|
+
configDir: dirname(configFile(workspace, options.config ? { file: options.config } : { trustProject: options.trustProject })),
|
|
87
|
+
};
|
|
88
|
+
const trustedProject = !!options.trustProject || !!options.config;
|
|
89
|
+
const pluginCatalog = new Map();
|
|
90
|
+
const runtimePluginEnabled = new Map();
|
|
91
|
+
const enabledBuiltinList = enabledBuiltins(options.builtins ?? [], config.builtinPlugins, options.disablePlugins);
|
|
92
|
+
const enabledBuiltinIds = new Set(enabledBuiltinList.map((item) => item.id));
|
|
93
|
+
for (const builtin of options.builtins ?? []) {
|
|
94
|
+
const enabled = enabledBuiltinIds.has(builtin.id);
|
|
95
|
+
let instance;
|
|
96
|
+
try {
|
|
97
|
+
instance = builtin.create({
|
|
98
|
+
...(config.builtinPlugins[builtin.id] ?? {}),
|
|
99
|
+
...(options.pluginOptions?.[builtin.id] ?? {}),
|
|
100
|
+
}, builtinContext);
|
|
101
|
+
if (enabled)
|
|
102
|
+
await plugins.activate(instance, workspace, { builtin: true });
|
|
103
|
+
pluginCatalog.set(builtin.id, {
|
|
104
|
+
id: builtin.id,
|
|
105
|
+
name: instance.name ?? builtin.name ?? builtin.id,
|
|
106
|
+
description: instance.description ?? builtin.description,
|
|
107
|
+
version: instance.version,
|
|
108
|
+
categories: instance.categories ?? builtin.categories ?? [],
|
|
109
|
+
builtin: true,
|
|
110
|
+
source: "built-in",
|
|
111
|
+
status: enabled ? "active" : "inactive",
|
|
112
|
+
enabled,
|
|
113
|
+
manageable: !(options.disablePlugins ?? []).includes(builtin.id),
|
|
114
|
+
...((options.disablePlugins ?? []).includes(builtin.id)
|
|
115
|
+
? { diagnostic: "Disabled by command-line option" }
|
|
116
|
+
: {}),
|
|
117
|
+
});
|
|
118
|
+
runtimePluginEnabled.set(builtin.id, enabled);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
pluginCatalog.set(builtin.id, {
|
|
122
|
+
id: builtin.id,
|
|
123
|
+
name: instance?.name ?? builtin.name ?? builtin.id,
|
|
124
|
+
description: instance?.description ?? builtin.description,
|
|
125
|
+
version: instance?.version,
|
|
126
|
+
categories: instance?.categories ?? builtin.categories ?? [],
|
|
127
|
+
builtin: true,
|
|
128
|
+
source: "built-in",
|
|
129
|
+
status: "failed",
|
|
130
|
+
enabled,
|
|
131
|
+
manageable: true,
|
|
132
|
+
diagnostic: "Activation failed; registrations were rolled back",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!options.readOnly) {
|
|
137
|
+
const external = [
|
|
138
|
+
...(options.plugin ?? []).map((p) => ({
|
|
139
|
+
spec: isPathSpec(p) ? resolve(cwd, p) : p,
|
|
140
|
+
source: "command line",
|
|
141
|
+
manageable: false,
|
|
142
|
+
})),
|
|
143
|
+
...config.plugins.map((spec) => ({
|
|
144
|
+
spec,
|
|
145
|
+
source: options.config
|
|
146
|
+
? "explicit configuration"
|
|
147
|
+
: options.trustProject
|
|
148
|
+
? isPathSpec(spec)
|
|
149
|
+
? "project configuration"
|
|
150
|
+
: `project package: ${spec}`
|
|
151
|
+
: "global configuration",
|
|
152
|
+
manageable: !!options.trustProject && !options.config,
|
|
153
|
+
})),
|
|
154
|
+
...(await discoverPlugins(join(configHome(), "plugins"))).map((spec) => ({
|
|
155
|
+
spec,
|
|
156
|
+
source: "global plugin directory",
|
|
157
|
+
manageable: false,
|
|
158
|
+
})),
|
|
159
|
+
...(options.trustProject
|
|
160
|
+
? (await discoverPlugins(join(workspace, ".alisio", "plugins"))).map((spec) => ({
|
|
161
|
+
spec,
|
|
162
|
+
source: "project plugin directory",
|
|
163
|
+
manageable: true,
|
|
164
|
+
}))
|
|
165
|
+
: []),
|
|
166
|
+
];
|
|
167
|
+
// Import is isolated from setup so configured disabled plugins remain visible in the catalog.
|
|
168
|
+
for (const entry of external.filter((candidate, index, all) => all.findIndex((x) => x.spec === candidate.spec) === index)) {
|
|
169
|
+
let candidate;
|
|
170
|
+
try {
|
|
171
|
+
candidate = await plugins.inspect(entry.spec, { from: workspace });
|
|
172
|
+
const enabled = config.pluginOverrides[candidate.plugin.id]?.enabled !== false;
|
|
173
|
+
if (enabled)
|
|
174
|
+
await plugins.activate(candidate.plugin, candidate.base);
|
|
175
|
+
pluginCatalog.set(candidate.plugin.id, {
|
|
176
|
+
id: candidate.plugin.id,
|
|
177
|
+
name: candidate.plugin.name ?? candidate.plugin.id,
|
|
178
|
+
description: candidate.plugin.description ?? "No description provided",
|
|
179
|
+
version: candidate.plugin.version,
|
|
180
|
+
categories: candidate.plugin.categories ??
|
|
181
|
+
plugins.metadata().find((metadata) => metadata.id === candidate?.plugin.id)
|
|
182
|
+
?.categories ??
|
|
183
|
+
[],
|
|
184
|
+
builtin: false,
|
|
185
|
+
source: entry.source,
|
|
186
|
+
status: enabled ? "active" : "inactive",
|
|
187
|
+
enabled,
|
|
188
|
+
manageable: entry.manageable,
|
|
189
|
+
});
|
|
190
|
+
runtimePluginEnabled.set(candidate.plugin.id, enabled);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
if (candidate) {
|
|
194
|
+
pluginCatalog.set(candidate.plugin.id, {
|
|
195
|
+
id: candidate.plugin.id,
|
|
196
|
+
name: candidate.plugin.name ?? candidate.plugin.id,
|
|
197
|
+
description: candidate.plugin.description ?? "No description provided",
|
|
198
|
+
version: candidate.plugin.version,
|
|
199
|
+
categories: candidate.plugin.categories ?? [],
|
|
200
|
+
builtin: false,
|
|
201
|
+
source: entry.source,
|
|
202
|
+
status: "failed",
|
|
203
|
+
enabled: true,
|
|
204
|
+
manageable: entry.manageable,
|
|
205
|
+
diagnostic: "Activation failed; registrations were rolled back",
|
|
206
|
+
});
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const id = `unavailable-${pluginCatalog.size + 1}`;
|
|
210
|
+
pluginCatalog.set(id, {
|
|
211
|
+
id,
|
|
212
|
+
name: "Unavailable plugin",
|
|
213
|
+
description: "Plugin metadata could not be loaded",
|
|
214
|
+
categories: [],
|
|
215
|
+
builtin: false,
|
|
216
|
+
source: entry.source,
|
|
217
|
+
status: "failed",
|
|
218
|
+
enabled: true,
|
|
219
|
+
manageable: false,
|
|
220
|
+
diagnostic: "Load failed; no path or credential details are shown here",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Project skills follow the config trust model (--trust-project or an explicit --config).
|
|
226
|
+
await skills.discover(skillRoots({
|
|
227
|
+
workspace,
|
|
228
|
+
cwd,
|
|
229
|
+
home: homedir(),
|
|
230
|
+
configHome: configHome(),
|
|
231
|
+
trusted: !!options.trustProject || !!options.config,
|
|
232
|
+
configSkills: config.skills,
|
|
233
|
+
pluginRoots: plugins.skillSources,
|
|
234
|
+
}));
|
|
235
|
+
context.extras.push(async () => skills.catalog(), ...plugins.contexts);
|
|
236
|
+
const { registerStandard } = await import("./tools/standard.js");
|
|
237
|
+
// `runner` is referenced lazily (constructed just below): safe because these accessors only
|
|
238
|
+
// run once the tool is actually called, well after the runner exists.
|
|
239
|
+
registerStandard(registry, workspace, skills, context, plugins.ui, {
|
|
240
|
+
policy: () => runner.policy,
|
|
241
|
+
}, {
|
|
242
|
+
config: config.websearch,
|
|
243
|
+
resolveExtension: () => plugins.extensions.resolve("websearch"),
|
|
244
|
+
});
|
|
245
|
+
const { registerPluginInstallTool } = await import("./plugins/install.js");
|
|
246
|
+
registerPluginInstallTool(registry, { readOnly: !!options.readOnly });
|
|
247
|
+
if ((options.allowMcp || mcpAllow) && !options.readOnly)
|
|
248
|
+
mcp.register();
|
|
249
|
+
if (options.allowAgents && !options.readOnly)
|
|
250
|
+
herdr.registerTools(registry);
|
|
251
|
+
const providerSettings = new ProviderSettingsStore();
|
|
252
|
+
const saved = options.provider ? undefined : await providerSettings.active();
|
|
253
|
+
const defaultProvider = (options.builtins ?? []).find((item) => item.defaultProvider);
|
|
254
|
+
const legacyEndpointOverride = overridesSavedProviderProfile(configProvenance, !!options.baseURL ||
|
|
255
|
+
!!options.apiMode ||
|
|
256
|
+
!!process.env.OPENAI_BASE_URL ||
|
|
257
|
+
!!process.env.ALISIO_API_MODE);
|
|
258
|
+
const selectedSaved = legacyEndpointOverride ? undefined : saved;
|
|
259
|
+
const supportsNativeSearch = (id, profile) => {
|
|
260
|
+
const capability = id ? providers.get(id)?.capabilities?.nativeWebSearch : undefined;
|
|
261
|
+
return (capability === true ||
|
|
262
|
+
(typeof capability === "object" &&
|
|
263
|
+
capability.values.includes(profile[capability.field] ?? "")));
|
|
264
|
+
};
|
|
265
|
+
let providerInfo = options.provider
|
|
266
|
+
? { id: options.provider.id, profile: {}, persisted: false, profileName: undefined }
|
|
267
|
+
: selectedSaved
|
|
268
|
+
? {
|
|
269
|
+
id: selectedSaved.profile.provider,
|
|
270
|
+
profile: selectedSaved.profile.values,
|
|
271
|
+
persisted: true,
|
|
272
|
+
profileName: selectedSaved.name,
|
|
273
|
+
}
|
|
274
|
+
: defaultProvider
|
|
275
|
+
? {
|
|
276
|
+
id: defaultProvider.id,
|
|
277
|
+
profile: config.provider,
|
|
278
|
+
persisted: false,
|
|
279
|
+
profileName: undefined,
|
|
280
|
+
}
|
|
281
|
+
: undefined;
|
|
282
|
+
let providerError;
|
|
283
|
+
let initial = new UnconfiguredProvider();
|
|
284
|
+
if (options.provider)
|
|
285
|
+
initial = options.provider;
|
|
286
|
+
else if (selectedSaved) {
|
|
287
|
+
try {
|
|
288
|
+
initial = await providers.create(selectedSaved.profile.provider, {
|
|
289
|
+
profile: {
|
|
290
|
+
...selectedSaved.profile.values,
|
|
291
|
+
model: nonBlank(options.model) ??
|
|
292
|
+
nonBlank(process.env.ALISIO_MODEL) ??
|
|
293
|
+
selectedSaved.profile.model,
|
|
294
|
+
},
|
|
295
|
+
credentials: selectedSaved.credentials,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
providerError = error instanceof Error ? error.message : String(error);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else if (defaultProvider && config.provider.model) {
|
|
303
|
+
try {
|
|
304
|
+
initial = await providers.create(defaultProvider.id, {
|
|
305
|
+
profile: {},
|
|
306
|
+
credentials: {},
|
|
307
|
+
legacy: config.provider,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
providerError = error instanceof Error ? error.message : String(error);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const provider = new ActiveProvider(initial);
|
|
315
|
+
if (config.websearch.provider === "native") {
|
|
316
|
+
const id = selectedSaved?.profile.provider ?? defaultProvider?.id;
|
|
317
|
+
const profile = selectedSaved
|
|
318
|
+
? selectedSaved.profile.values
|
|
319
|
+
: config.provider;
|
|
320
|
+
if (!supportsNativeSearch(id, profile))
|
|
321
|
+
throw new Error('websearch.provider is "native" but the active provider does not support native search');
|
|
322
|
+
}
|
|
323
|
+
const models = new Map();
|
|
324
|
+
// Lazily (re)loads the active provider's catalog exactly once, so the ACTIVE model's
|
|
325
|
+
// contextWindow becomes known for the context bar without waiting for an explicit
|
|
326
|
+
// /model picker or autocomplete call. Failed or absent discovery stays retryable.
|
|
327
|
+
let modelsLoading;
|
|
328
|
+
const ensureModels = () => {
|
|
329
|
+
if (!provider.listModels || modelsLoading)
|
|
330
|
+
return modelsLoading;
|
|
331
|
+
modelsLoading = loadModels(AbortSignal.timeout(10_000));
|
|
332
|
+
modelsLoading.catch(() => {
|
|
333
|
+
if (modelsLoading)
|
|
334
|
+
modelsLoading = undefined;
|
|
335
|
+
});
|
|
336
|
+
return modelsLoading;
|
|
337
|
+
};
|
|
338
|
+
// Priority: explicit configuration for the configured model, then the active saved profile's
|
|
339
|
+
// per-model override (user intent for local servers that omit context_window), then GET /models.
|
|
340
|
+
const contextWindow = (model) => {
|
|
341
|
+
const explicit = model === config.provider.model ? config.provider.contextWindow : undefined;
|
|
342
|
+
if (explicit !== undefined)
|
|
343
|
+
return explicit;
|
|
344
|
+
const override = providerInfo?.persisted && model === provider.model
|
|
345
|
+
? parseProfileContextWindow(providerInfo.profile.contextWindow)
|
|
346
|
+
: undefined;
|
|
347
|
+
if (override !== undefined)
|
|
348
|
+
return override;
|
|
349
|
+
const known = models.get(model)?.contextWindow;
|
|
350
|
+
if (known !== undefined)
|
|
351
|
+
return known;
|
|
352
|
+
// Background prime: this call stays synchronous (the runner and the bar are sync), and
|
|
353
|
+
// once the catalog lands the map is populated for every later lookup.
|
|
354
|
+
void ensureModels();
|
|
355
|
+
return undefined;
|
|
356
|
+
};
|
|
357
|
+
const loadModels = async (signal) => {
|
|
358
|
+
if (!provider.listModels)
|
|
359
|
+
return [];
|
|
360
|
+
const list = await provider.listModels(signal);
|
|
361
|
+
models.clear();
|
|
362
|
+
for (const m of list)
|
|
363
|
+
models.set(m.id, m);
|
|
364
|
+
return list;
|
|
365
|
+
};
|
|
366
|
+
let catalogCache;
|
|
367
|
+
const configuredCatalogs = async (signal) => {
|
|
368
|
+
if (catalogCache && Date.now() - catalogCache.at < 15_000)
|
|
369
|
+
return catalogCache.value;
|
|
370
|
+
const settings = await providerSettings.load();
|
|
371
|
+
const value = await Promise.all(Object.entries(settings.profiles).map(async ([name, profile]) => {
|
|
372
|
+
const registration = providers.get(profile.provider);
|
|
373
|
+
const base = {
|
|
374
|
+
profile: name,
|
|
375
|
+
provider: profile.provider,
|
|
376
|
+
title: registration?.name ?? profile.provider,
|
|
377
|
+
configuredModel: profile.model,
|
|
378
|
+
};
|
|
379
|
+
if (!registration)
|
|
380
|
+
return { ...base, models: [], unavailable: true };
|
|
381
|
+
try {
|
|
382
|
+
const resolved = await providerSettings.resolve(name);
|
|
383
|
+
if (!resolved)
|
|
384
|
+
return { ...base, models: [], unavailable: true };
|
|
385
|
+
const candidate = await providers.create(profile.provider, {
|
|
386
|
+
profile: { ...profile.values, model: profile.model },
|
|
387
|
+
credentials: resolved.credentials,
|
|
388
|
+
});
|
|
389
|
+
try {
|
|
390
|
+
return {
|
|
391
|
+
...base,
|
|
392
|
+
models: (await candidate.listModels?.(signal)) ?? [],
|
|
393
|
+
unavailable: false,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
await candidate.dispose?.();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return { ...base, models: [], unavailable: true };
|
|
402
|
+
}
|
|
403
|
+
})).then((catalogs) => catalogs.sort((a, b) => a.title.localeCompare(b.title)));
|
|
404
|
+
catalogCache = { at: Date.now(), value };
|
|
405
|
+
return value;
|
|
406
|
+
};
|
|
407
|
+
const listAvailableModels = async (signal = AbortSignal.timeout(20_000)) => availableProviderModels(await configuredCatalogs(signal));
|
|
408
|
+
const resolveModel = async (reference, signal = AbortSignal.timeout(20_000)) => resolveConfiguredModel(await configuredCatalogs(signal), reference);
|
|
409
|
+
const sessionProviders = new Map();
|
|
410
|
+
const targetProviders = new Map();
|
|
411
|
+
const retainedProviders = new Set();
|
|
412
|
+
const runtimeFor = (target) => {
|
|
413
|
+
const key = `${target.profile}\0${target.model.id}`;
|
|
414
|
+
let pending = targetProviders.get(key);
|
|
415
|
+
if (!pending) {
|
|
416
|
+
pending = providerSettings.resolve(target.profile).then(async (saved) => {
|
|
417
|
+
if (!saved || saved.profile.provider !== target.provider)
|
|
418
|
+
throw new Error(`Configured provider "${target.provider}" is unavailable. Reconnect it with /connect.`);
|
|
419
|
+
try {
|
|
420
|
+
return await providers.create(target.provider, {
|
|
421
|
+
profile: { ...saved.profile.values, model: target.model.id },
|
|
422
|
+
credentials: saved.credentials,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
throw new Error(`Configured provider "${target.provider}" could not be activated. Reconnect it with /connect.`);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
targetProviders.set(key, pending);
|
|
430
|
+
pending.catch(() => targetProviders.delete(key));
|
|
431
|
+
}
|
|
432
|
+
return pending;
|
|
433
|
+
};
|
|
434
|
+
const bindProvider = async (sessionId, target) => {
|
|
435
|
+
const runtime = await runtimeFor(target);
|
|
436
|
+
sessionProviders.set(sessionId, runtime);
|
|
437
|
+
return runtime.id;
|
|
438
|
+
};
|
|
439
|
+
const providerFor = async (session) => {
|
|
440
|
+
const bound = sessionProviders.get(session.id);
|
|
441
|
+
if (bound)
|
|
442
|
+
return bound;
|
|
443
|
+
if (session.parentId) {
|
|
444
|
+
const parent = sessionProviders.get(session.parentId);
|
|
445
|
+
if (parent && parent.id === session.provider) {
|
|
446
|
+
sessionProviders.set(session.id, parent);
|
|
447
|
+
return parent;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const current = provider.currentProvider;
|
|
451
|
+
if (current.id === session.provider) {
|
|
452
|
+
sessionProviders.set(session.id, current);
|
|
453
|
+
retainedProviders.add(current);
|
|
454
|
+
return current;
|
|
455
|
+
}
|
|
456
|
+
// A persisted routed session may be resumed after another profile became the global default.
|
|
457
|
+
// Recreate only configured runtimes whose catalog contains the recorded model, then match the
|
|
458
|
+
// provider's stable public id. Credentials never leave this activation boundary.
|
|
459
|
+
const candidates = (await listAvailableModels()).filter((entry) => entry.model.id === session.model);
|
|
460
|
+
for (const target of candidates) {
|
|
461
|
+
const runtime = await runtimeFor(target).catch(() => undefined);
|
|
462
|
+
if (!runtime)
|
|
463
|
+
continue;
|
|
464
|
+
if (runtime.id !== session.provider)
|
|
465
|
+
continue;
|
|
466
|
+
sessionProviders.set(session.id, runtime);
|
|
467
|
+
return runtime;
|
|
468
|
+
}
|
|
469
|
+
throw new Error(`Session provider "${session.provider}" is unavailable. Reconnect that provider or start a new session.`);
|
|
470
|
+
};
|
|
471
|
+
plugins.setModels({ list: listAvailableModels, resolve: resolveModel });
|
|
472
|
+
plugins.setCompleter(async (request) => {
|
|
473
|
+
if (!request.model) {
|
|
474
|
+
const runtime = request.sessionId
|
|
475
|
+
? await providerFor(store.get(request.sessionId))
|
|
476
|
+
: provider.currentProvider;
|
|
477
|
+
return completeText(runtime, request);
|
|
478
|
+
}
|
|
479
|
+
const target = await resolveModel(request.model, request.signal);
|
|
480
|
+
return completeText(await runtimeFor(target), { ...request, model: target.model.id });
|
|
481
|
+
});
|
|
482
|
+
const runtimePolicy = {
|
|
483
|
+
write: !!options.allowWrite && !options.readOnly,
|
|
484
|
+
process: !!options.allowProcess && !options.readOnly,
|
|
485
|
+
external: !options.readOnly &&
|
|
486
|
+
(!!options.allowExternal ||
|
|
487
|
+
!!options.allowMcp ||
|
|
488
|
+
mcpAllow ||
|
|
489
|
+
!!options.allowAgents ||
|
|
490
|
+
plugins.externalCount > 0 ||
|
|
491
|
+
registry.list().some((t) => t.name.startsWith("p_"))),
|
|
492
|
+
};
|
|
493
|
+
const runner = new AgentRunner({
|
|
494
|
+
provider,
|
|
495
|
+
providerFor,
|
|
496
|
+
registry,
|
|
497
|
+
store,
|
|
498
|
+
context,
|
|
499
|
+
workspace,
|
|
500
|
+
policy: runtimePolicy,
|
|
501
|
+
...config.limits,
|
|
502
|
+
compaction: config.compaction,
|
|
503
|
+
extensions: plugins,
|
|
504
|
+
contextWindow,
|
|
505
|
+
...(options.approve && !options.readOnly ? { approve: options.approve } : {}),
|
|
506
|
+
...(config.websearch.provider === "native"
|
|
507
|
+
? { nativeTools: [{ type: config.websearch.nativeToolType }] }
|
|
508
|
+
: {}),
|
|
509
|
+
onEvent: (event) => {
|
|
510
|
+
herdr.event(event);
|
|
511
|
+
plugins.emit(event);
|
|
512
|
+
options.onEvent?.(event);
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
// Child sessions (generic delegation service for plugins). Contexts are cached per workspace
|
|
516
|
+
// so a child in a git worktree reads that worktree's AGENTS.md files.
|
|
517
|
+
const contexts = new Map([[workspace, context]]);
|
|
518
|
+
plugins.setSessions(new ChildSessions({
|
|
519
|
+
store,
|
|
520
|
+
runner,
|
|
521
|
+
providerId: () => provider.id,
|
|
522
|
+
resolveModel,
|
|
523
|
+
bindProvider,
|
|
524
|
+
rootPolicy: () => runner.policy,
|
|
525
|
+
rootApprovals: runner.approvals,
|
|
526
|
+
readOnly: !!options.readOnly,
|
|
527
|
+
contextFor: (root) => {
|
|
528
|
+
let found = contexts.get(root);
|
|
529
|
+
if (!found) {
|
|
530
|
+
found = new ProjectContext(root, {
|
|
531
|
+
globalDir: configHome(),
|
|
532
|
+
claudeMdFallback: config.context.claudeMdFallback,
|
|
533
|
+
maxBytes: config.context.maxBytes,
|
|
534
|
+
});
|
|
535
|
+
found.extras.push(...context.extras);
|
|
536
|
+
contexts.set(root, found);
|
|
537
|
+
}
|
|
538
|
+
return found;
|
|
539
|
+
},
|
|
540
|
+
}));
|
|
541
|
+
// Project prompts follow the config trust model: --trust-project or an explicit --config.
|
|
542
|
+
const prompts = await loadPromptTemplates(promptSources({
|
|
543
|
+
builtin: options.builtinPrompts ?? [],
|
|
544
|
+
plugins: plugins.promptSources,
|
|
545
|
+
userDir: join(configHome(), "prompts"),
|
|
546
|
+
projectDir: join(workspace, ".alisio", "prompts"),
|
|
547
|
+
trusted: !!options.trustProject || !!options.config,
|
|
548
|
+
}), { reserved: [...(options.reservedPromptNames ?? []), ...plugins.commands.keys()] });
|
|
549
|
+
/**
|
|
550
|
+
* `/name args` → rendered template text, or undefined when not a template. Throws when the
|
|
551
|
+
* template requires a capability that is neither allowed nor approvable.
|
|
552
|
+
*/
|
|
553
|
+
const expandPrompt = (input) => {
|
|
554
|
+
const expanded = expandSlashPrompt(input, prompts.templates);
|
|
555
|
+
if (!expanded)
|
|
556
|
+
return undefined;
|
|
557
|
+
for (const need of expanded.template.requires) {
|
|
558
|
+
const flag = need === "write" ? "--allow-write" : "--allow-process";
|
|
559
|
+
if (options.readOnly)
|
|
560
|
+
throw new Error(`/${expanded.name} needs ${need} access, which --read-only disables. Run it without --read-only (and with ${flag}, or approve the calls in the TUI).`);
|
|
561
|
+
if (!runner.policy[need] && !runner.approvals)
|
|
562
|
+
throw new Error(`/${expanded.name} needs ${need} access: rerun with ${flag}.`);
|
|
563
|
+
}
|
|
564
|
+
return expanded;
|
|
565
|
+
};
|
|
566
|
+
// Startup auto-connect: when runtime permission is granted (--allow-mcp or global mcp.allow),
|
|
567
|
+
// connect every enabled server exactly like pressing Connect for each. Failures are per-server,
|
|
568
|
+
// sanitized and never fatal to startup.
|
|
569
|
+
const mcpStartupFailures = [];
|
|
570
|
+
if (mcpRuntimePermission === "granted") {
|
|
571
|
+
for (const [name, server] of Object.entries(config.mcp.servers)) {
|
|
572
|
+
if (!server.enabled)
|
|
573
|
+
continue;
|
|
574
|
+
try {
|
|
575
|
+
await mcp.connect(name, AbortSignal.timeout(15_000));
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
const diagnostic = mcp.info(name).diagnostic ??
|
|
579
|
+
"Connection failed; inspect the server separately for details";
|
|
580
|
+
mcpStartupFailures.push(`MCP server "${name}" did not auto-connect: ${diagnostic}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
workspace,
|
|
586
|
+
config,
|
|
587
|
+
prompts,
|
|
588
|
+
expandPrompt,
|
|
589
|
+
store,
|
|
590
|
+
registry,
|
|
591
|
+
context,
|
|
592
|
+
skills,
|
|
593
|
+
/** Path- and content-safe metadata for the interactive skills manager. */
|
|
594
|
+
skillCatalog() {
|
|
595
|
+
const pluginStatus = new Map([...pluginCatalog.values()].map((entry) => [entry.id, entry]));
|
|
596
|
+
return skills.catalogEntries().map((skill) => {
|
|
597
|
+
const owner = skill.owner ? pluginStatus.get(skill.owner.id) : undefined;
|
|
598
|
+
return {
|
|
599
|
+
...skill,
|
|
600
|
+
...(owner?.status === "restart-required"
|
|
601
|
+
? { source: "plugin (restart required)" }
|
|
602
|
+
: {}),
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
},
|
|
606
|
+
/** Persist a project-local override and apply it to model-visible skills immediately. */
|
|
607
|
+
async setSkillEnabled(id, enabled) {
|
|
608
|
+
const current = skills.catalogEntries().find((skill) => skill.id === id && skill.effective);
|
|
609
|
+
if (!current)
|
|
610
|
+
throw new Error(`Unknown effective skill: ${id}`);
|
|
611
|
+
if (!current.manageable || current.locked)
|
|
612
|
+
throw new Error("This skill is locked by its plugin; use /plugins to manage the owner");
|
|
613
|
+
await setProjectSkillEnabled({ workspace, id, enabled, trusted: trustedProject });
|
|
614
|
+
return skills.setEnabled(id, enabled);
|
|
615
|
+
},
|
|
616
|
+
plugins,
|
|
617
|
+
/** Credential- and path-safe catalog for the current resolved plugin set. */
|
|
618
|
+
pluginCatalog() {
|
|
619
|
+
return [...pluginCatalog.values()]
|
|
620
|
+
.map((entry) => ({ ...entry, categories: [...entry.categories] }))
|
|
621
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
622
|
+
},
|
|
623
|
+
/** Persist a project-local override. Runtime registrations intentionally change on restart. */
|
|
624
|
+
async setPluginEnabled(id, enabled, lifecycle = {}) {
|
|
625
|
+
const entry = pluginCatalog.get(id);
|
|
626
|
+
if (!entry)
|
|
627
|
+
throw new Error(`Unknown plugin: ${id}`);
|
|
628
|
+
if (!entry.manageable)
|
|
629
|
+
throw new Error(entry.diagnostic ?? "This plugin is managed outside project configuration");
|
|
630
|
+
if (!entry.builtin && !trustedProject)
|
|
631
|
+
throw new Error("Trust this project before enabling or disabling executable plugins");
|
|
632
|
+
if (!enabled) {
|
|
633
|
+
const activeRegistration = providers.get(providerInfo?.id ?? "");
|
|
634
|
+
if (activeRegistration?.plugin === id)
|
|
635
|
+
throw new Error("Switch to a model from another provider, then start a fresh session before disabling this plugin");
|
|
636
|
+
if ([...sessionProviders.values()].some((runtime) => providers.get(runtime.id)?.plugin === id))
|
|
637
|
+
throw new Error("A live routed session still uses this provider plugin; close those sessions or restart Alisio before disabling it");
|
|
638
|
+
if (lifecycle.liveSession && plugins.ownsSessionResources(id))
|
|
639
|
+
throw new Error("This plugin owns resources used by the current session; start a fresh session before disabling it");
|
|
640
|
+
}
|
|
641
|
+
if (entry.enabled === enabled && entry.status !== "restart-required")
|
|
642
|
+
return { ...entry };
|
|
643
|
+
await setProjectPluginEnabled({
|
|
644
|
+
workspace,
|
|
645
|
+
id,
|
|
646
|
+
enabled,
|
|
647
|
+
builtin: entry.builtin,
|
|
648
|
+
trusted: trustedProject,
|
|
649
|
+
});
|
|
650
|
+
const matchesRuntime = runtimePluginEnabled.get(id) === enabled;
|
|
651
|
+
const updated = {
|
|
652
|
+
...entry,
|
|
653
|
+
enabled,
|
|
654
|
+
status: matchesRuntime ? (enabled ? "active" : "inactive") : "restart-required",
|
|
655
|
+
...(matchesRuntime
|
|
656
|
+
? { diagnostic: undefined }
|
|
657
|
+
: {
|
|
658
|
+
diagnostic: enabled
|
|
659
|
+
? "Enabled for the next Alisio start"
|
|
660
|
+
: "Disabled for the next Alisio start",
|
|
661
|
+
}),
|
|
662
|
+
};
|
|
663
|
+
pluginCatalog.set(id, updated);
|
|
664
|
+
return { ...updated, categories: [...updated.categories] };
|
|
665
|
+
},
|
|
666
|
+
mcp,
|
|
667
|
+
/** Current-process MCP grant; deliberately independent from persisted server enablement. */
|
|
668
|
+
mcpRuntimePermission() {
|
|
669
|
+
return mcpRuntimePermission;
|
|
670
|
+
},
|
|
671
|
+
/** Whether global MCP consent (`mcp.allow`) is persisted for this user right now. */
|
|
672
|
+
mcpAllowPersisted() {
|
|
673
|
+
return persistedMcpAllow;
|
|
674
|
+
},
|
|
675
|
+
/**
|
|
676
|
+
* Sanitized per-server failures collected while auto-connecting enabled servers at startup
|
|
677
|
+
* (only when runtime permission was granted). Never includes credentials or command paths.
|
|
678
|
+
*/
|
|
679
|
+
mcpStartupFailures() {
|
|
680
|
+
return [...mcpStartupFailures];
|
|
681
|
+
},
|
|
682
|
+
/** Host-owned interactive consent boundary. Headless callers remain blocked unless flagged. */
|
|
683
|
+
grantMcpRuntimePermission(request) {
|
|
684
|
+
if (!request.confirmed)
|
|
685
|
+
return mcpRuntimePermission;
|
|
686
|
+
if (request.source !== "interactive-tui")
|
|
687
|
+
throw new Error("MCP runtime permission may only be granted by the interactive TUI");
|
|
688
|
+
if (options.readOnly)
|
|
689
|
+
throw new Error("MCP is unavailable under --read-only");
|
|
690
|
+
if (mcpRuntimePermission === "granted")
|
|
691
|
+
return mcpRuntimePermission;
|
|
692
|
+
mcp.grantRuntimePermission();
|
|
693
|
+
runtimePolicy.external = true;
|
|
694
|
+
mcpRuntimePermission = "granted";
|
|
695
|
+
return mcpRuntimePermission;
|
|
696
|
+
},
|
|
697
|
+
/** Persist global consent atomically, then grant runtime permission for this process. */
|
|
698
|
+
async rememberGlobalMcpConsent() {
|
|
699
|
+
if (options.readOnly)
|
|
700
|
+
throw new Error("MCP is unavailable under --read-only");
|
|
701
|
+
await setGlobalMcpAllow({ allow: true });
|
|
702
|
+
persistedMcpAllow = true;
|
|
703
|
+
this.grantMcpRuntimePermission({ source: "interactive-tui", confirmed: true });
|
|
704
|
+
},
|
|
705
|
+
/**
|
|
706
|
+
* Clear the persisted global consent atomically and drop the runtime permission (disconnecting
|
|
707
|
+
* servers as cleanup does). An explicit this-run `--allow-mcp` flag keeps the grant alive.
|
|
708
|
+
*/
|
|
709
|
+
async revokeGlobalMcpConsent() {
|
|
710
|
+
if (options.readOnly)
|
|
711
|
+
throw new Error("MCP is unavailable under --read-only");
|
|
712
|
+
await setGlobalMcpAllow({ allow: false });
|
|
713
|
+
persistedMcpAllow = false;
|
|
714
|
+
if (options.allowMcp)
|
|
715
|
+
return;
|
|
716
|
+
if (mcpRuntimePermission !== "granted")
|
|
717
|
+
return;
|
|
718
|
+
await mcp.revokeRuntimePermission();
|
|
719
|
+
mcpRuntimePermission = "not-granted";
|
|
720
|
+
runtimePolicy.external =
|
|
721
|
+
!!options.allowExternal ||
|
|
722
|
+
!!options.allowAgents ||
|
|
723
|
+
plugins.externalCount > 0 ||
|
|
724
|
+
registry.list().some((t) => t.name.startsWith("p_"));
|
|
725
|
+
},
|
|
726
|
+
/** Persist an MCP toggle in its defining layer, then safely update this process. */
|
|
727
|
+
async setMcpEnabled(id, enabled, connect = false) {
|
|
728
|
+
if (options.readOnly)
|
|
729
|
+
throw new Error("MCP changes are unavailable under --read-only");
|
|
730
|
+
const source = config.mcpSources[id];
|
|
731
|
+
if (!source)
|
|
732
|
+
throw new Error(`Unknown MCP server: ${id}`);
|
|
733
|
+
await setMcpServerEnabled({ source, name: id, enabled });
|
|
734
|
+
await mcp.setEnabled(id, enabled, connect && enabled && mcpRuntimePermission === "granted"
|
|
735
|
+
? AbortSignal.timeout(15_000)
|
|
736
|
+
: undefined);
|
|
737
|
+
return mcp.info(id);
|
|
738
|
+
},
|
|
739
|
+
/**
|
|
740
|
+
* Persist one user-facing setting to the GLOBAL user config (`<config home>/config.json`)
|
|
741
|
+
* and apply it to this process where the value can change live. Compaction and limits land
|
|
742
|
+
* in the runner (honored by the next run/compact), `context.*` lands in the project context
|
|
743
|
+
* (next instructions load), `websearch.provider` mutates the shared config object the search
|
|
744
|
+
* chain reads per call, `pluginHooks.timeoutMs` lands in the plugin host (next hook call),
|
|
745
|
+
* and `tui.*` is persisted for the host to apply. Nothing in this set requires a restart.
|
|
746
|
+
* `mcp.allow` is deliberately not routed here: it flows through
|
|
747
|
+
* `rememberGlobalMcpConsent`/`revokeGlobalMcpConsent` instead.
|
|
748
|
+
* Throws under `--read-only`. Returns the written config file.
|
|
749
|
+
*/
|
|
750
|
+
async updateSetting(key, value) {
|
|
751
|
+
if (options.readOnly)
|
|
752
|
+
throw new Error("Settings changes are unavailable under --read-only");
|
|
753
|
+
const file = await setConfigValue({ key, value });
|
|
754
|
+
const compactionLeaf = (leaf) => ({ [leaf]: value });
|
|
755
|
+
const limitLeaf = (leaf) => ({ [leaf]: value });
|
|
756
|
+
switch (key) {
|
|
757
|
+
case "compaction.auto":
|
|
758
|
+
case "compaction.threshold":
|
|
759
|
+
case "compaction.keepTurns":
|
|
760
|
+
case "compaction.maxOutputTokens": {
|
|
761
|
+
const patch = compactionLeaf(key.slice("compaction.".length));
|
|
762
|
+
config.compaction = { ...config.compaction, ...patch };
|
|
763
|
+
runner.applySettings({ compaction: patch });
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
case "context.claudeMdFallback":
|
|
767
|
+
config.context = { ...config.context, claudeMdFallback: value === true };
|
|
768
|
+
context.update({ claudeMdFallback: value === true });
|
|
769
|
+
break;
|
|
770
|
+
case "context.maxBytes":
|
|
771
|
+
config.context = { ...config.context, maxBytes: Number(value) };
|
|
772
|
+
context.update({ maxBytes: Number(value) });
|
|
773
|
+
break;
|
|
774
|
+
case "limits.maxTurns":
|
|
775
|
+
case "limits.maxOutputTokens":
|
|
776
|
+
case "limits.maxContextChars":
|
|
777
|
+
case "limits.timeoutMs": {
|
|
778
|
+
const patch = limitLeaf(key.slice("limits.".length));
|
|
779
|
+
config.limits = { ...config.limits, ...patch };
|
|
780
|
+
runner.applySettings(patch);
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
case "pluginHooks.timeoutMs":
|
|
784
|
+
config.pluginHooks = { ...config.pluginHooks, timeoutMs: Number(value) };
|
|
785
|
+
plugins.applyTimeoutSettings({ hookTimeoutMs: Number(value) });
|
|
786
|
+
break;
|
|
787
|
+
case "tui.paddingX":
|
|
788
|
+
config.tui = { ...config.tui, paddingX: Number(value) };
|
|
789
|
+
break;
|
|
790
|
+
case "tui.skillSlashCommands":
|
|
791
|
+
config.tui = { ...config.tui, skillSlashCommands: value === true };
|
|
792
|
+
break;
|
|
793
|
+
case "websearch.provider":
|
|
794
|
+
// Mutated in place (same object identity) so the tool chain's per-call
|
|
795
|
+
// `websearchCtx.config` sees the new provider on the very next search call.
|
|
796
|
+
config.websearch.provider =
|
|
797
|
+
value === undefined ? undefined : value;
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
return file;
|
|
801
|
+
},
|
|
802
|
+
provider,
|
|
803
|
+
providers,
|
|
804
|
+
providerSettings,
|
|
805
|
+
get providerError() {
|
|
806
|
+
return providerError;
|
|
807
|
+
},
|
|
808
|
+
get providerInfo() {
|
|
809
|
+
return providerInfo;
|
|
810
|
+
},
|
|
811
|
+
runner,
|
|
812
|
+
herdr,
|
|
813
|
+
contextWindow,
|
|
814
|
+
/** Effective context budget + compaction point shared by the runner and the TUI bar. */
|
|
815
|
+
contextBudget: (model) => runner.contextBudget(model),
|
|
816
|
+
loadModels,
|
|
817
|
+
/** Creates a provider for validation/model discovery without changing the active provider. */
|
|
818
|
+
async probeProvider(id, profile, credentials, signal) {
|
|
819
|
+
const candidate = await providers.create(id, { profile, credentials });
|
|
820
|
+
try {
|
|
821
|
+
return (await candidate.listModels?.(signal)) ?? [];
|
|
822
|
+
}
|
|
823
|
+
finally {
|
|
824
|
+
await candidate.dispose?.();
|
|
825
|
+
}
|
|
826
|
+
},
|
|
827
|
+
/** Lists every plugin-configured profile without returning credentials or provider errors. */
|
|
828
|
+
async configuredProviderCatalogs(signal) {
|
|
829
|
+
return configuredCatalogs(signal);
|
|
830
|
+
},
|
|
831
|
+
/** Lists credential-free models from globally configured `/connect` profiles. */
|
|
832
|
+
listAvailableModels,
|
|
833
|
+
/** Resolves `provider/model` or a unique bare model id without exposing credentials. */
|
|
834
|
+
resolveModel,
|
|
835
|
+
/** Creates and binds a fresh session to an optional configured model selector. */
|
|
836
|
+
async createSession(reference) {
|
|
837
|
+
if (!reference) {
|
|
838
|
+
const session = store.create(workspace, provider.id, provider.model);
|
|
839
|
+
sessionProviders.set(session.id, provider.currentProvider);
|
|
840
|
+
retainedProviders.add(provider.currentProvider);
|
|
841
|
+
return session;
|
|
842
|
+
}
|
|
843
|
+
const target = await resolveModel(reference);
|
|
844
|
+
const runtime = await runtimeFor(target);
|
|
845
|
+
const session = store.create(workspace, runtime.id, target.model.id);
|
|
846
|
+
sessionProviders.set(session.id, runtime);
|
|
847
|
+
return session;
|
|
848
|
+
},
|
|
849
|
+
/** Persists and activates only after construction succeeds; programmatic overrides stay fixed. */
|
|
850
|
+
async activateProvider(id, profile, credentials, model, profileName = id) {
|
|
851
|
+
if (options.provider)
|
|
852
|
+
throw new Error("A programmatic provider override is active");
|
|
853
|
+
if (config.websearch.provider === "native" && !supportsNativeSearch(id, profile))
|
|
854
|
+
throw new Error("The selected provider configuration does not support native web search");
|
|
855
|
+
const candidate = await providers.create(id, {
|
|
856
|
+
profile: { ...profile, model },
|
|
857
|
+
credentials,
|
|
858
|
+
});
|
|
859
|
+
try {
|
|
860
|
+
await providerSettings.saveActive(profileName, { provider: id, values: profile, model }, credentials);
|
|
861
|
+
const previous = provider.currentProvider;
|
|
862
|
+
const retained = retainedProviders.has(previous);
|
|
863
|
+
await provider.replace(candidate, !retained);
|
|
864
|
+
providerError = undefined;
|
|
865
|
+
providerInfo = { id, profile, persisted: true, profileName };
|
|
866
|
+
models.clear();
|
|
867
|
+
modelsLoading = undefined;
|
|
868
|
+
catalogCache = undefined;
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
await Promise.resolve(candidate.dispose?.()).catch(() => { });
|
|
872
|
+
throw error;
|
|
873
|
+
}
|
|
874
|
+
},
|
|
875
|
+
/** Activates a stored profile without exposing its credentials. Returns false for a no-op. */
|
|
876
|
+
async activateProviderProfile(name, model) {
|
|
877
|
+
const saved = await providerSettings.resolve(name);
|
|
878
|
+
if (!saved)
|
|
879
|
+
throw new Error("Configured provider profile is unavailable");
|
|
880
|
+
if (providerInfo?.persisted &&
|
|
881
|
+
saved.name === providerInfo.profileName &&
|
|
882
|
+
saved.profile.provider === providerInfo.id &&
|
|
883
|
+
provider.model === model)
|
|
884
|
+
return false;
|
|
885
|
+
await this.activateProvider(saved.profile.provider, saved.profile.values, saved.credentials, model, saved.name);
|
|
886
|
+
return true;
|
|
887
|
+
},
|
|
888
|
+
/** Resolves, globally activates, and creates the fresh session required by a main switch. */
|
|
889
|
+
async switchModel(reference) {
|
|
890
|
+
const target = await resolveModel(reference);
|
|
891
|
+
const changed = await this.activateProviderProfile(target.profile, target.model.id);
|
|
892
|
+
if (!changed)
|
|
893
|
+
return undefined;
|
|
894
|
+
return this.createSession(target.reference);
|
|
895
|
+
},
|
|
896
|
+
/** Runs session-end plugin hooks (bounded by the host timeout). */
|
|
897
|
+
async endSession(sessionId, reason) {
|
|
898
|
+
if (!plugins.hasSessionEndHooks)
|
|
899
|
+
return { failures: [] };
|
|
900
|
+
const session = store.get(sessionId);
|
|
901
|
+
return plugins.sessionEnd({
|
|
902
|
+
sessionId,
|
|
903
|
+
model: session.model,
|
|
904
|
+
workspace,
|
|
905
|
+
reason,
|
|
906
|
+
messages: store.messages(sessionId),
|
|
907
|
+
});
|
|
908
|
+
},
|
|
909
|
+
async close() {
|
|
910
|
+
// Exit latency is user-facing: tear down herdr + MCP in parallel, then provider +
|
|
911
|
+
// plugins (store last). Every stage is raced against a cap so a single hanging
|
|
912
|
+
// component cannot stall /exit; teardown errors are swallowed for callers.
|
|
913
|
+
const capped = (work) => Promise.race([work.catch(() => { }), delay(TEARDOWN_STAGE_TIMEOUT_MS)]);
|
|
914
|
+
try {
|
|
915
|
+
await capped(Promise.allSettled([herdr.close(), mcp.close()]));
|
|
916
|
+
}
|
|
917
|
+
finally {
|
|
918
|
+
try {
|
|
919
|
+
await capped((async () => {
|
|
920
|
+
const runtimes = new Set([
|
|
921
|
+
...(await Promise.allSettled(targetProviders.values()))
|
|
922
|
+
.filter((x) => x.status === "fulfilled")
|
|
923
|
+
.map((x) => x.value),
|
|
924
|
+
...retainedProviders,
|
|
925
|
+
]);
|
|
926
|
+
await Promise.allSettled([
|
|
927
|
+
provider.dispose(),
|
|
928
|
+
...[...runtimes]
|
|
929
|
+
.filter((runtime) => runtime !== provider.currentProvider)
|
|
930
|
+
.map((runtime) => Promise.resolve(runtime.dispose?.())),
|
|
931
|
+
plugins.close(),
|
|
932
|
+
]);
|
|
933
|
+
})());
|
|
934
|
+
}
|
|
935
|
+
finally {
|
|
936
|
+
store.close();
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
},
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
catch (e) {
|
|
943
|
+
await Promise.race([
|
|
944
|
+
Promise.allSettled([herdr.close(), mcp.close(), plugins.close()]),
|
|
945
|
+
delay(TEARDOWN_STAGE_TIMEOUT_MS),
|
|
946
|
+
]);
|
|
947
|
+
store.close();
|
|
948
|
+
throw e;
|
|
949
|
+
}
|
|
950
|
+
}
|