@alisio/core 0.1.0-alpha.5 → 0.1.0-alpha.6

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.
@@ -1,4 +1,4 @@
1
- import type { AvailableProviderModel, ModelInfo, ModelProvider, Plugin, PluginCategory, ResolvedProviderModel, RunEvent } from "@alisio/sdk";
1
+ import type { AvailableProviderModel, ModelInfo, ModelProvider, Plugin, PluginCategory, ProviderConfigurationValue, ResolvedProviderModel, RunEvent } from "@alisio/sdk";
2
2
  import type { ApprovalHandler } from "./core/contracts.ts";
3
3
  import { ToolRegistry } from "./core/registry.ts";
4
4
  import { AgentRunner } from "./core/runner.ts";
@@ -142,11 +142,25 @@ export declare function createApplication(options?: AppOptions): Promise<{
142
142
  mcp: McpConnector;
143
143
  /** Current-process MCP grant; deliberately independent from persisted server enablement. */
144
144
  mcpRuntimePermission(): "granted" | "not-granted" | "read-only";
145
+ /** Whether global MCP consent (`mcp.allow`) is persisted for this user right now. */
146
+ mcpAllowPersisted(): boolean;
147
+ /**
148
+ * Sanitized per-server failures collected while auto-connecting enabled servers at startup
149
+ * (only when runtime permission was granted). Never includes credentials or command paths.
150
+ */
151
+ mcpStartupFailures(): string[];
145
152
  /** Host-owned interactive consent boundary. Headless callers remain blocked unless flagged. */
146
153
  grantMcpRuntimePermission(request: {
147
154
  source: "interactive-tui";
148
155
  confirmed: boolean;
149
156
  }): "granted" | "not-granted" | "read-only";
157
+ /** Persist global consent atomically, then grant runtime permission for this process. */
158
+ rememberGlobalMcpConsent(): Promise<void>;
159
+ /**
160
+ * Clear the persisted global consent atomically and drop the runtime permission (disconnecting
161
+ * servers as cleanup does). An explicit this-run `--allow-mcp` flag keeps the grant alive.
162
+ */
163
+ revokeGlobalMcpConsent(): Promise<void>;
150
164
  /** Persist an MCP toggle in its defining layer, then safely update this process. */
151
165
  setMcpEnabled(id: string, enabled: boolean, connect?: boolean): Promise<import("./index.ts").McpServerInfo>;
152
166
  provider: ActiveProvider;
@@ -155,7 +169,7 @@ export declare function createApplication(options?: AppOptions): Promise<{
155
169
  readonly providerError: string | undefined;
156
170
  readonly providerInfo: {
157
171
  id: string;
158
- profile: Record<string, import("@alisio/sdk").ProviderConfigurationValue>;
172
+ profile: Record<string, ProviderConfigurationValue>;
159
173
  persisted: boolean;
160
174
  profileName: string;
161
175
  } | {
@@ -169,8 +183,8 @@ export declare function createApplication(options?: AppOptions): Promise<{
169
183
  contextWindow: (model: string) => number | undefined;
170
184
  /** Effective context budget + compaction point shared by the runner and the TUI bar. */
171
185
  contextBudget: (model: string) => {
172
- total: number;
173
- basis: "window" | "chars";
186
+ total?: number;
187
+ basis: "window" | "unknown";
174
188
  compactionAt: number;
175
189
  };
176
190
  loadModels: (signal: AbortSignal) => Promise<ModelInfo[]>;
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { configFile, configHome, loadConfigWithProvenance, overridesSavedProviderProfile, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
3
+ import { configFile, configHome, loadConfigWithProvenance, overridesSavedProviderProfile, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
4
4
  import { completeText } from "./core/compaction.js";
5
5
  import { ToolRegistry } from "./core/registry.js";
6
6
  import { AgentRunner } from "./core/runner.js";
@@ -28,6 +28,17 @@ const nonBlank = (value) => {
28
28
  const normalized = value?.trim();
29
29
  return normalized || undefined;
30
30
  };
31
+ /**
32
+ * Per-profile optional context-window override, in tokens (for local OpenAI-compatible servers
33
+ * like llama.cpp whose `GET /models` omits `context_window`). Numbers are used directly; strings
34
+ * (for example hand-edited providers.json) are parsed. Absent or unparseable means "no override".
35
+ */
36
+ const parseProfileContextWindow = (value) => {
37
+ if (value === undefined)
38
+ return undefined;
39
+ const window = typeof value === "number" ? value : Number(String(value).trim());
40
+ return Number.isFinite(window) && window > 0 ? window : undefined;
41
+ };
31
42
  export async function createApplication(options = {}) {
32
43
  const cwd = resolve(options.cwd ?? process.cwd()), workspace = await findWorkspace(cwd);
33
44
  const { config, provenance: configProvenance } = await loadConfigWithProvenance(workspace, {
@@ -37,16 +48,20 @@ export async function createApplication(options = {}) {
37
48
  baseURL: options.baseURL,
38
49
  apiMode: options.apiMode,
39
50
  });
51
+ // Global-only consent: `mcp.allow` is read from the user layer; the merged config never carries
52
+ // a project value (see loadConfigWithProvenance), so a project cannot grant itself MCP consent.
53
+ const mcpAllow = config.mcp.allow === true;
54
+ let persistedMcpAllow = mcpAllow;
40
55
  const store = new SQLiteStore(options.db ?? join(stateHome(), "sessions.sqlite"));
41
56
  const registry = new ToolRegistry(), skills = new Skills({ overrides: config.skillOverrides }), context = new ProjectContext(workspace, {
42
57
  globalDir: configHome(),
43
58
  cwd,
44
59
  claudeMdFallback: config.context.claudeMdFallback,
45
60
  maxBytes: config.context.maxBytes,
46
- }), 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 ? "allowed" : "disabled", config.mcpSources);
61
+ }), 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);
47
62
  let mcpRuntimePermission = options.readOnly
48
63
  ? "read-only"
49
- : options.allowMcp
64
+ : options.allowMcp || mcpAllow
50
65
  ? "granted"
51
66
  : "not-granted";
52
67
  const herdr = options.noHerdr
@@ -220,7 +235,9 @@ export async function createApplication(options = {}) {
220
235
  config: config.websearch,
221
236
  resolveExtension: () => plugins.extensions.resolve("websearch"),
222
237
  });
223
- if (options.allowMcp && !options.readOnly)
238
+ const { registerPluginInstallTool } = await import("./plugins/install.js");
239
+ registerPluginInstallTool(registry, { readOnly: !!options.readOnly });
240
+ if ((options.allowMcp || mcpAllow) && !options.readOnly)
224
241
  mcp.register();
225
242
  if (options.allowAgents && !options.readOnly)
226
243
  herdr.registerTools(registry);
@@ -297,9 +314,39 @@ export async function createApplication(options = {}) {
297
314
  throw new Error('websearch.provider is "native" but the active provider does not support native search');
298
315
  }
299
316
  const models = new Map();
300
- // Priority: explicit configuration for the configured model, then GET /models.
301
- const contextWindow = (model) => (model === config.provider.model ? config.provider.contextWindow : undefined) ??
302
- models.get(model)?.contextWindow;
317
+ // Lazily (re)loads the active provider's catalog exactly once, so the ACTIVE model's
318
+ // contextWindow becomes known for the context bar without waiting for an explicit
319
+ // /model picker or autocomplete call. Failed or absent discovery stays retryable.
320
+ let modelsLoading;
321
+ const ensureModels = () => {
322
+ if (!provider.listModels || modelsLoading)
323
+ return modelsLoading;
324
+ modelsLoading = loadModels(AbortSignal.timeout(10_000));
325
+ modelsLoading.catch(() => {
326
+ if (modelsLoading)
327
+ modelsLoading = undefined;
328
+ });
329
+ return modelsLoading;
330
+ };
331
+ // Priority: explicit configuration for the configured model, then the active saved profile's
332
+ // per-model override (user intent for local servers that omit context_window), then GET /models.
333
+ const contextWindow = (model) => {
334
+ const explicit = model === config.provider.model ? config.provider.contextWindow : undefined;
335
+ if (explicit !== undefined)
336
+ return explicit;
337
+ const override = providerInfo?.persisted && model === provider.model
338
+ ? parseProfileContextWindow(providerInfo.profile.contextWindow)
339
+ : undefined;
340
+ if (override !== undefined)
341
+ return override;
342
+ const known = models.get(model)?.contextWindow;
343
+ if (known !== undefined)
344
+ return known;
345
+ // Background prime: this call stays synchronous (the runner and the bar are sync), and
346
+ // once the catalog lands the map is populated for every later lookup.
347
+ void ensureModels();
348
+ return undefined;
349
+ };
303
350
  const loadModels = async (signal) => {
304
351
  if (!provider.listModels)
305
352
  return [];
@@ -431,6 +478,7 @@ export async function createApplication(options = {}) {
431
478
  external: !options.readOnly &&
432
479
  (!!options.allowExternal ||
433
480
  !!options.allowMcp ||
481
+ mcpAllow ||
434
482
  !!options.allowAgents ||
435
483
  plugins.externalCount > 0 ||
436
484
  registry.list().some((t) => t.name.startsWith("p_"))),
@@ -508,6 +556,24 @@ export async function createApplication(options = {}) {
508
556
  }
509
557
  return expanded;
510
558
  };
559
+ // Startup auto-connect: when runtime permission is granted (--allow-mcp or global mcp.allow),
560
+ // connect every enabled server exactly like pressing Connect for each. Failures are per-server,
561
+ // sanitized and never fatal to startup.
562
+ const mcpStartupFailures = [];
563
+ if (mcpRuntimePermission === "granted") {
564
+ for (const [name, server] of Object.entries(config.mcp.servers)) {
565
+ if (!server.enabled)
566
+ continue;
567
+ try {
568
+ await mcp.connect(name, AbortSignal.timeout(15_000));
569
+ }
570
+ catch {
571
+ const diagnostic = mcp.info(name).diagnostic ??
572
+ "Connection failed; inspect the server separately for details";
573
+ mcpStartupFailures.push(`MCP server "${name}" did not auto-connect: ${diagnostic}`);
574
+ }
575
+ }
576
+ }
511
577
  return {
512
578
  workspace,
513
579
  config,
@@ -595,6 +661,17 @@ export async function createApplication(options = {}) {
595
661
  mcpRuntimePermission() {
596
662
  return mcpRuntimePermission;
597
663
  },
664
+ /** Whether global MCP consent (`mcp.allow`) is persisted for this user right now. */
665
+ mcpAllowPersisted() {
666
+ return persistedMcpAllow;
667
+ },
668
+ /**
669
+ * Sanitized per-server failures collected while auto-connecting enabled servers at startup
670
+ * (only when runtime permission was granted). Never includes credentials or command paths.
671
+ */
672
+ mcpStartupFailures() {
673
+ return [...mcpStartupFailures];
674
+ },
598
675
  /** Host-owned interactive consent boundary. Headless callers remain blocked unless flagged. */
599
676
  grantMcpRuntimePermission(request) {
600
677
  if (!request.confirmed)
@@ -610,6 +687,35 @@ export async function createApplication(options = {}) {
610
687
  mcpRuntimePermission = "granted";
611
688
  return mcpRuntimePermission;
612
689
  },
690
+ /** Persist global consent atomically, then grant runtime permission for this process. */
691
+ async rememberGlobalMcpConsent() {
692
+ if (options.readOnly)
693
+ throw new Error("MCP is unavailable under --read-only");
694
+ await setGlobalMcpAllow({ allow: true });
695
+ persistedMcpAllow = true;
696
+ this.grantMcpRuntimePermission({ source: "interactive-tui", confirmed: true });
697
+ },
698
+ /**
699
+ * Clear the persisted global consent atomically and drop the runtime permission (disconnecting
700
+ * servers as cleanup does). An explicit this-run `--allow-mcp` flag keeps the grant alive.
701
+ */
702
+ async revokeGlobalMcpConsent() {
703
+ if (options.readOnly)
704
+ throw new Error("MCP is unavailable under --read-only");
705
+ await setGlobalMcpAllow({ allow: false });
706
+ persistedMcpAllow = false;
707
+ if (options.allowMcp)
708
+ return;
709
+ if (mcpRuntimePermission !== "granted")
710
+ return;
711
+ await mcp.revokeRuntimePermission();
712
+ mcpRuntimePermission = "not-granted";
713
+ runtimePolicy.external =
714
+ !!options.allowExternal ||
715
+ !!options.allowAgents ||
716
+ plugins.externalCount > 0 ||
717
+ registry.list().some((t) => t.name.startsWith("p_"));
718
+ },
613
719
  /** Persist an MCP toggle in its defining layer, then safely update this process. */
614
720
  async setMcpEnabled(id, enabled, connect = false) {
615
721
  if (options.readOnly)
@@ -688,6 +794,7 @@ export async function createApplication(options = {}) {
688
794
  providerError = undefined;
689
795
  providerInfo = { id, profile, persisted: true, profileName };
690
796
  models.clear();
797
+ modelsLoading = undefined;
691
798
  catalogCache = undefined;
692
799
  }
693
800
  catch (error) {
package/dist/config.d.ts CHANGED
@@ -85,6 +85,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
85
85
  enabled: boolean;
86
86
  bearerTokenEnv?: string | undefined;
87
87
  }>>]>>>;
88
+ allow: z.ZodOptional<z.ZodBoolean>;
88
89
  }, z.core.$strict>>;
89
90
  mcpServers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodDiscriminatedUnion<[z.ZodObject<{
90
91
  transport: z.ZodLiteral<"stdio">;
@@ -223,6 +224,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
223
224
  maxTokens?: number | undefined;
224
225
  };
225
226
  mcp: {
227
+ allow?: boolean | undefined;
226
228
  servers: {
227
229
  [x: string]: {
228
230
  transport: "stdio";
@@ -294,6 +296,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
294
296
  bearerTokenEnv?: string | undefined;
295
297
  transport: "http";
296
298
  }>;
299
+ allow?: boolean | undefined;
297
300
  };
298
301
  mcpServers?: Record<string, {
299
302
  transport: "stdio";
@@ -376,7 +379,11 @@ export interface ConfigLoadResult {
376
379
  config: LoadedConfig;
377
380
  provenance: ConfigProvenance;
378
381
  }
379
- /** Whether legacy provider selection should take priority over a saved plugin profile for this run. */
382
+ /**
383
+ * Whether legacy provider selection should take priority over a saved plugin profile for this
384
+ * run: an explicit endpoint override, or a trusted project/explicit layer whose parsed root
385
+ * `provider.model` is actually usable (non-empty and not the `alisio setup` placeholder).
386
+ */
380
387
  export declare function overridesSavedProviderProfile(provenance: ConfigProvenance, hasEndpointOverride: boolean): boolean;
381
388
  export declare const configHome: () => string;
382
389
  export declare const stateHome: () => string;
@@ -405,6 +412,10 @@ export declare function setMcpServerEnabled(input: {
405
412
  name: string;
406
413
  enabled: boolean;
407
414
  }): Promise<string>;
415
+ /** Atomically sets or clears the global `mcp.allow` consent preference in the user config file. */
416
+ export declare function setGlobalMcpAllow(input: {
417
+ allow: boolean;
418
+ }): Promise<string>;
408
419
  /** Atomically updates one project plugin override while preserving every unrelated JSON field. */
409
420
  export declare function setProjectPluginEnabled(input: {
410
421
  workspace: string;
package/dist/config.js CHANGED
@@ -84,7 +84,14 @@ const configObjectSchema = z
84
84
  /** Project-local enable/disable overrides for effective non-plugin skills. */
85
85
  skillOverrides: z.record(z.string(), z.object({ enabled: z.boolean() }).strict()).default({}),
86
86
  mcp: z
87
- .object({ servers: serversSchema(compatibleServerSchema).default({}) })
87
+ .object({
88
+ servers: serversSchema(compatibleServerSchema).default({}),
89
+ /**
90
+ * Global-only user preference: grants MCP process/network consent across sessions.
91
+ * Only the user (global) configuration layer is consulted; a project value is ignored.
92
+ */
93
+ allow: z.boolean().optional(),
94
+ })
88
95
  .strict()
89
96
  .default({ servers: {} }),
90
97
  /** Compatibility with the common MCP client configuration shape. */
@@ -172,9 +179,24 @@ const configObjectSchema = z
172
179
  });
173
180
  export const configSchema = configObjectSchema.transform(({ mcpServers, ...config }) => ({
174
181
  ...config,
175
- mcp: { servers: { ...config.mcp.servers, ...mcpServers } },
182
+ mcp: { ...config.mcp, servers: { ...config.mcp.servers, ...mcpServers } },
176
183
  }));
177
- /** Whether legacy provider selection should take priority over a saved plugin profile for this run. */
184
+ /** The `alisio setup` scaffold writes this placeholder; a model matching it is not usable. */
185
+ const LEGACY_PROVIDER_MODEL_PLACEHOLDER = "YOUR_MODEL_ID";
186
+ /**
187
+ * A legacy root `provider` is usable only when its parsed model is non-empty and not the
188
+ * `alisio setup` placeholder, so a scaffolded or empty project config never defeats a saved
189
+ * `/connect` profile.
190
+ */
191
+ function isUsableLegacyModel(model) {
192
+ const normalized = model.trim();
193
+ return normalized !== "" && normalized !== LEGACY_PROVIDER_MODEL_PLACEHOLDER;
194
+ }
195
+ /**
196
+ * Whether legacy provider selection should take priority over a saved plugin profile for this
197
+ * run: an explicit endpoint override, or a trusted project/explicit layer whose parsed root
198
+ * `provider.model` is actually usable (non-empty and not the `alisio setup` placeholder).
199
+ */
178
200
  export function overridesSavedProviderProfile(provenance, hasEndpointOverride) {
179
201
  return hasEndpointOverride || !!provenance.selectedLayer?.hasLegacyProvider;
180
202
  }
@@ -233,10 +255,12 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
233
255
  global.config.skillOverrides = {};
234
256
  let config = global.config;
235
257
  let mcpSources = global.sources;
236
- let selectedKeys;
258
+ // Parsed `provider.model` of the selected layer itself (before env/CLI overrides), used to
259
+ // decide whether its legacy root `provider` is usable against a saved `/connect` profile.
260
+ let selectedLegacyModel = "";
237
261
  if (selectedFile && selectedFile !== globalFile) {
238
262
  const selected = await parseLayer(selectedFile, options.file ? "explicit" : "project");
239
- selectedKeys = selected.keys;
263
+ selectedLegacyModel = selected.config.provider.model;
240
264
  const overlaid = { ...config };
241
265
  for (const key of selected.keys) {
242
266
  if (key === "mcp" || key === "mcpServers")
@@ -245,14 +269,17 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
245
269
  overlaid[key] = selected.config[key];
246
270
  }
247
271
  if (selected.keys.has("mcp") || selected.keys.has("mcpServers"))
272
+ // Only servers merge upward; `mcp.allow` is a global/user preference and is deliberately
273
+ // dropped from the selected layer so a project can never grant itself network consent.
248
274
  overlaid.mcp = {
275
+ ...config.mcp,
249
276
  servers: { ...config.mcp.servers, ...selected.config.mcp.servers },
250
277
  };
251
278
  mcpSources = { ...mcpSources, ...selected.sources };
252
279
  config = overlaid;
253
280
  }
254
281
  else if (selectedFile) {
255
- selectedKeys = global.keys;
282
+ selectedLegacyModel = global.config.provider.model;
256
283
  }
257
284
  config.provider = providerSchema.parse({
258
285
  ...config.provider,
@@ -273,7 +300,7 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
273
300
  ? {
274
301
  selectedLayer: {
275
302
  kind: options.file ? "explicit" : "project",
276
- hasLegacyProvider: selectedKeys?.has("provider") ?? false,
303
+ hasLegacyProvider: isUsableLegacyModel(selectedLegacyModel),
277
304
  },
278
305
  }
279
306
  : {}),
@@ -326,6 +353,51 @@ export async function setMcpServerEnabled(input) {
326
353
  }
327
354
  return file;
328
355
  }
356
+ /** Atomically sets or clears the global `mcp.allow` consent preference in the user config file. */
357
+ export async function setGlobalMcpAllow(input) {
358
+ const file = join(configHome(), "config.json");
359
+ let raw = {};
360
+ if (await exists(file)) {
361
+ const parsed = JSON.parse(await readFile(file, "utf8"));
362
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
363
+ throw new Error("Global Alisio configuration must be a JSON object");
364
+ raw = parsed;
365
+ }
366
+ if (input.allow) {
367
+ const current = raw.mcp && typeof raw.mcp === "object" && !Array.isArray(raw.mcp)
368
+ ? raw.mcp
369
+ : {};
370
+ raw.mcp = { ...current, allow: true };
371
+ }
372
+ else if (raw.mcp && typeof raw.mcp === "object" && !Array.isArray(raw.mcp)) {
373
+ const mcp = { ...raw.mcp };
374
+ delete mcp.allow;
375
+ if (Object.keys(mcp).length)
376
+ raw.mcp = mcp;
377
+ else
378
+ delete raw.mcp;
379
+ }
380
+ raw.schemaVersion ??= 1;
381
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
382
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
383
+ const handle = await open(temporary, "wx", 0o600);
384
+ try {
385
+ await handle.writeFile(`${JSON.stringify(raw, null, 2)}\n`, "utf8");
386
+ await handle.sync();
387
+ }
388
+ finally {
389
+ await handle.close();
390
+ }
391
+ try {
392
+ await rename(temporary, file);
393
+ await chmod(file, 0o600);
394
+ }
395
+ catch (error) {
396
+ await unlink(temporary).catch(() => { });
397
+ throw error;
398
+ }
399
+ return file;
400
+ }
329
401
  /** Atomically updates one project plugin override while preserving every unrelated JSON field. */
330
402
  export async function setProjectPluginEnabled(input) {
331
403
  const file = join(input.workspace, ".alisio", "config.json");
@@ -108,14 +108,16 @@ export declare class AgentRunner {
108
108
  /** Estimated tokens the next request of a session would send. */
109
109
  estimateContext(sessionId: string): Promise<number>;
110
110
  /**
111
- * The effective context budget a session's auto-compaction and the TUI context bar both use:
112
- * the model window when known (and not beyond `MAX_TRUSTED_WINDOW`), otherwise the char budget
113
- * converted to estimated tokens. `compactionAt` is the percentage of `total` where auto
114
- * compaction triggers, so the bar turns red exactly where the engine compacts.
111
+ * The effective context budget the TUI context bar displays. The model window is the total
112
+ * when known (and not beyond `MAX_TRUSTED_WINDOW`); otherwise the bar reports an honest
113
+ * unknown (`basis: "unknown"`, no fabricated total). `compactionAt` is the percentage of
114
+ * `total` where auto-compaction triggers, so the bar turns red exactly where the engine
115
+ * compacts. The engine's own auto-compaction guardrail still falls back to the char budget
116
+ * internally (see `shouldCompactContext`) — that fallback is never shown as a fake total.
115
117
  */
116
118
  contextBudget(model: string): {
117
- total: number;
118
- basis: "window" | "chars";
119
+ total?: number;
120
+ basis: "window" | "unknown";
119
121
  compactionAt: number;
120
122
  };
121
123
  /** Change the model used by subsequent turns of a session; recorded in the store. */
@@ -1,5 +1,5 @@
1
1
  import { textResult, } from "@alisio/sdk";
2
- import { checkpointInstructions, effectiveContextBudget, estimateTokens, parseCheckpointOutput, planCompaction, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
2
+ import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
3
3
  /**
4
4
  * Default cumulative token budget for one run: several context windows (each turn re-sends the
5
5
  * context), clamped; 1M tokens when the window is unknown.
@@ -112,19 +112,22 @@ export class AgentRunner {
112
112
  estimateTokens(this.toolsText(this.availableTools())));
113
113
  }
114
114
  /**
115
- * The effective context budget a session's auto-compaction and the TUI context bar both use:
116
- * the model window when known (and not beyond `MAX_TRUSTED_WINDOW`), otherwise the char budget
117
- * converted to estimated tokens. `compactionAt` is the percentage of `total` where auto
118
- * compaction triggers, so the bar turns red exactly where the engine compacts.
115
+ * The effective context budget the TUI context bar displays. The model window is the total
116
+ * when known (and not beyond `MAX_TRUSTED_WINDOW`); otherwise the bar reports an honest
117
+ * unknown (`basis: "unknown"`, no fabricated total). `compactionAt` is the percentage of
118
+ * `total` where auto-compaction triggers, so the bar turns red exactly where the engine
119
+ * compacts. The engine's own auto-compaction guardrail still falls back to the char budget
120
+ * internally (see `shouldCompactContext`) — that fallback is never shown as a fake total.
119
121
  */
120
122
  contextBudget(model) {
121
- const budget = effectiveContextBudget(this.options.contextWindow?.(model), this.options.maxContextChars ?? 160_000);
122
- return {
123
- ...budget,
124
- compactionAt: budget.basis === "window"
125
- ? Math.round((this.options.compaction?.threshold ?? 0.85) * 100)
126
- : 100,
127
- };
123
+ const window = this.options.contextWindow?.(model);
124
+ if (window !== undefined && window > 0 && window <= MAX_TRUSTED_WINDOW)
125
+ return {
126
+ total: window,
127
+ basis: "window",
128
+ compactionAt: Math.round((this.options.compaction?.threshold ?? 0.85) * 100),
129
+ };
130
+ return { total: undefined, basis: "unknown", compactionAt: 100 };
128
131
  }
129
132
  /** Change the model used by subsequent turns of a session; recorded in the store. */
130
133
  setModel(sessionId, model) {
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** @alisio/core public API: embed the Alisio agent programmatically. */
2
2
  export * from "@alisio/sdk";
3
3
  export { type AppOptions, type BuiltinContext, type BuiltinPlugin, createApplication, enabledBuiltins, type PluginCatalogEntry, type PluginCatalogStatus, } from "./application.ts";
4
- export { type Config, type ConfigLoadResult, type ConfigProvenance, configFile, configHome, configSchema, type LoadedConfig, loadConfig, loadConfigWithProvenance, type McpServerSource, overridesSavedProviderProfile, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.ts";
4
+ export { type Config, type ConfigLoadResult, type ConfigProvenance, configFile, configHome, configSchema, type LoadedConfig, loadConfig, loadConfigWithProvenance, type McpServerSource, overridesSavedProviderProfile, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.ts";
5
5
  export { type ContextBudget, checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.ts";
6
6
  export type { ApprovalDecision, ApprovalHandler, ApprovalRequest, ContextSource, HookFailure, Policy, RunnerExtensions, Session, SessionStore, } from "./core/contracts.ts";
7
7
  export { ToolRegistry } from "./core/registry.ts";
@@ -10,6 +10,7 @@ export { type ExtensionConflict, ExtensionRegistry } from "./extensions/registry
10
10
  export { HerdrBridge } from "./integrations/herdr.ts";
11
11
  export { McpConnector, type McpServerInfo, type McpStatus, type McpToolInfo, } from "./mcp/connector.ts";
12
12
  export { discoverPlugins, PluginHost, type PluginHostOptions, pluginPrefix, } from "./plugins/host.ts";
13
+ export { type CliInstallOptions, cliInstall, INSTALL_TIMEOUT_MS, type InstallPluginInput, type InstallPluginResult, type InstallRunner, installedNpmPlugins, installPlugin, npmInstallTarget, type PluginSpec, parsePluginSpec, pluginTrustRemark, printInstallResult, registerPluginInstallTool, sanitizeNpmError, } from "./plugins/install.ts";
13
14
  export { ActiveProvider, ProviderRegistry, UnconfiguredProvider } from "./providers/registry.ts";
14
15
  export { availableProviderModels, type ProviderModelCatalog, resolveProviderModel, } from "./providers/routing.ts";
15
16
  export { type ProviderProfile, type ProviderSettings, ProviderSettingsStore, } from "./providers/settings.ts";
@@ -19,7 +20,7 @@ export { type Skill, type SkillCatalogEntry, type SkillRoot, type SkillScope, Sk
19
20
  export { exists, fileSize, readHead, readJson, readText, which } from "./runtime/fs.ts";
20
21
  export { defaultGlobalRoots, isPathSpec, PLUGIN_KEYWORD, resolvePluginSpec, } from "./runtime/modules.ts";
21
22
  export { findWorkspace, safePath } from "./runtime/paths.ts";
22
- export { type ProcessResult, runProcess } from "./runtime/process.ts";
23
+ export { isMissingCommand, type ProcessResult, RIPGREP_INSTALL_HINT, runProcess, } from "./runtime/process.ts";
23
24
  export { isSqliteExperimentalWarning, openDatabase } from "./runtime/sqlite.ts";
24
25
  export { SQLiteStore } from "./runtime/store.ts";
25
26
  export { ChildSessions, type ChildSessionsOptions } from "./sessions/children.ts";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** @alisio/core public API: embed the Alisio agent programmatically. */
2
2
  export * from "@alisio/sdk";
3
3
  export { createApplication, enabledBuiltins, } from "./application.js";
4
- export { configFile, configHome, configSchema, loadConfig, loadConfigWithProvenance, overridesSavedProviderProfile, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
4
+ export { configFile, configHome, configSchema, loadConfig, loadConfigWithProvenance, overridesSavedProviderProfile, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
5
5
  export { checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.js";
6
6
  export { ToolRegistry } from "./core/registry.js";
7
7
  export { AgentRunner, defaultTokenBudget, } from "./core/runner.js";
@@ -9,6 +9,7 @@ export { ExtensionRegistry } from "./extensions/registry.js";
9
9
  export { HerdrBridge } from "./integrations/herdr.js";
10
10
  export { McpConnector, } from "./mcp/connector.js";
11
11
  export { discoverPlugins, PluginHost, pluginPrefix, } from "./plugins/host.js";
12
+ export { cliInstall, INSTALL_TIMEOUT_MS, installedNpmPlugins, installPlugin, npmInstallTarget, parsePluginSpec, pluginTrustRemark, printInstallResult, registerPluginInstallTool, sanitizeNpmError, } from "./plugins/install.js";
12
13
  export { ActiveProvider, ProviderRegistry, UnconfiguredProvider } from "./providers/registry.js";
13
14
  export { availableProviderModels, resolveProviderModel, } from "./providers/routing.js";
14
15
  export { ProviderSettingsStore, } from "./providers/settings.js";
@@ -18,7 +19,7 @@ export { Skills, skillRoots, } from "./resources/skills.js";
18
19
  export { exists, fileSize, readHead, readJson, readText, which } from "./runtime/fs.js";
19
20
  export { defaultGlobalRoots, isPathSpec, PLUGIN_KEYWORD, resolvePluginSpec, } from "./runtime/modules.js";
20
21
  export { findWorkspace, safePath } from "./runtime/paths.js";
21
- export { runProcess } from "./runtime/process.js";
22
+ export { isMissingCommand, RIPGREP_INSTALL_HINT, runProcess, } from "./runtime/process.js";
22
23
  export { isSqliteExperimentalWarning, openDatabase } from "./runtime/sqlite.js";
23
24
  export { SQLiteStore } from "./runtime/store.js";
24
25
  export { ChildSessions } from "./sessions/children.js";
@@ -48,6 +48,8 @@ export declare class McpConnector {
48
48
  constructor(servers: Record<string, ServerConfig>, registry: ToolRegistry, workspace: string, availability?: "allowed" | "disabled" | "read-only", sources?: Record<string, McpServerSource>);
49
49
  register(): void;
50
50
  grantRuntimePermission(): void;
51
+ /** Drop the runtime grant: disconnect every server and unregister the bridge (used on revoke). */
52
+ revokeRuntimePermission(): Promise<void>;
51
53
  runtimePermission(): McpServerInfo["runtimePermission"];
52
54
  private assertAvailable;
53
55
  private client;
@@ -3,6 +3,7 @@ import { textResult } from "@alisio/sdk";
3
3
  import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
4
4
  import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
5
5
  import { objectSchema } from "../tools/standard.js";
6
+ import { loadVersion } from "../version.js";
6
7
  const TOOL_NAME_LIMIT = 64;
7
8
  const semanticSegment = (value, fallback) => value
8
9
  .normalize("NFKD")
@@ -138,6 +139,13 @@ export class McpConnector {
138
139
  this.availability = "allowed";
139
140
  this.register();
140
141
  }
142
+ /** Drop the runtime grant: disconnect every server and unregister the bridge (used on revoke). */
143
+ async revokeRuntimePermission() {
144
+ await Promise.all([...this.clients.keys()].map((name) => this.disconnect(name)));
145
+ for (const undo of this.bridgeUndo.splice(0).reverse())
146
+ undo();
147
+ this.availability = "disabled";
148
+ }
141
149
  runtimePermission() {
142
150
  return this.availability === "allowed"
143
151
  ? "granted"
@@ -212,7 +220,7 @@ export class McpConnector {
212
220
  }
213
221
  this.statuses.set(name, "connecting");
214
222
  this.diagnostics.delete(name);
215
- const client = new Client({ name: "alisio", version: "0.1.0-alpha.1" }, { capabilities: {} });
223
+ const client = new Client({ name: "alisio", version: loadVersion(import.meta.url) }, { capabilities: {} });
216
224
  const undo = [];
217
225
  const generation = Symbol(name);
218
226
  const transport = config.transport === "stdio"
@@ -566,7 +566,13 @@ export async function discoverPlugins(root) {
566
566
  throw e;
567
567
  }
568
568
  return entries
569
- .filter((e) => (e.isFile() && /\.[cm]?[jt]s$/.test(e.name)) || e.isDirectory())
569
+ .filter(
570
+ // A root may hold an `npm install --prefix` layout (`node_modules`, package.json/lock);
571
+ // npm packages there are resolved through the configured `plugins` array instead.
572
+ (e) => e.name !== "node_modules" &&
573
+ e.name !== "package.json" &&
574
+ e.name !== "package-lock.json" &&
575
+ ((e.isFile() && /\.[cm]?[jt]s$/.test(e.name)) || e.isDirectory()))
570
576
  .map((e) => join(root, e.name))
571
577
  .sort();
572
578
  }
@@ -0,0 +1,100 @@
1
+ import type { ToolRegistry } from "../core/registry.ts";
2
+ import { type ProcessResult } from "../runtime/process.ts";
3
+ export interface PluginSpec {
4
+ name: string;
5
+ version?: string;
6
+ }
7
+ /**
8
+ * Validates an install spec without touching the network: `npm:<package>[@<version>]` (canonical),
9
+ * a bare package name (same resolution), and nothing else. Rejects shell metacharacters, `..` /
10
+ * absolute paths, and unknown prefixes (`registry:`, `git:`, `file:`, URLs) with a clear error.
11
+ */
12
+ export declare function parsePluginSpec(spec: string): PluginSpec;
13
+ /** The npm argument for a spec: `name`, `name@<version>` or `name@latest` for `--update`. */
14
+ export declare function npmInstallTarget(name: string, version?: string, update?: boolean): string;
15
+ /**
16
+ * Injectable process runner so tests can substitute a fake `npm` without a network or a PATH shim.
17
+ * The default runs the real npm with a generous timeout and bounded output.
18
+ */
19
+ export type InstallRunner = (command: string, args: string[], options: {
20
+ cwd: string;
21
+ signal: AbortSignal;
22
+ env?: Record<string, string>;
23
+ }) => Promise<ProcessResult>;
24
+ export declare const INSTALL_TIMEOUT_MS = 300000;
25
+ export interface InstallPluginInput {
26
+ /** `npm:<package>[@<version>]` or a bare package name. */
27
+ spec: string;
28
+ /** Defaults to `configHome()` (ALISIO_CONFIG_HOME, else ~/.config/alisio). */
29
+ configHome?: string;
30
+ /** Refresh an already-installed package to `@latest`, keeping the npm name in the config. */
31
+ update?: boolean;
32
+ /** Refuse immediately; loading and installing executable plugins is disabled under --read-only. */
33
+ readOnly?: boolean;
34
+ signal?: AbortSignal;
35
+ /** Injected installer for tests (fake npm); defaults to the real npm. */
36
+ runner?: InstallRunner;
37
+ }
38
+ export interface InstallPluginResult {
39
+ packageName: string;
40
+ /** Version read back from the installed package.json, or "unknown". */
41
+ installedVersion: string;
42
+ /** `<configHome>/plugins/node_modules/<name>` (a directory). */
43
+ path: string;
44
+ /** The npm name persisted in the global configuration `plugins` array. */
45
+ configEntry: string;
46
+ /** The global configuration file that received the entry. */
47
+ configFile: string;
48
+ pluginsDir: string;
49
+ /** Already present in the global plugins directory before this call (no npm run performed). */
50
+ alreadyInstalled: boolean;
51
+ /** Refreshed to `@latest` by this call. */
52
+ updated: boolean;
53
+ /** Plain-text remark about where the plugin loads and how project trust applies. */
54
+ trustRemark: string;
55
+ }
56
+ /**
57
+ * Redacts probable secrets from npm output (auth tokens, JWTs, bearer credentials, userinfo in
58
+ * URLs) and caps the size, so a failing install never echoes credentials to the user.
59
+ */
60
+ export declare function sanitizeNpmError(text: string, maxLength?: number): string;
61
+ /** The shared install routine used by both the CLI command and the `plugin_install` tool. */
62
+ export declare function installPlugin(input: InstallPluginInput): Promise<InstallPluginResult>;
63
+ /** Final remark about loading and project trust, printed by the CLI and returned by the tool. */
64
+ export declare function pluginTrustRemark(name: string): string;
65
+ export interface CliInstallOptions {
66
+ spec: string;
67
+ configHome?: string;
68
+ /** `--yes`/`--trust-plugin`: skip the interactive pre-install confirmation. */
69
+ yes?: boolean;
70
+ update?: boolean;
71
+ readOnly?: boolean;
72
+ /** True when the CLI can prompt (interactive TTY, not --json). */
73
+ interactive?: boolean;
74
+ /** Print the result as JSON instead of human lines. */
75
+ json?: boolean;
76
+ signal?: AbortSignal;
77
+ runner?: InstallRunner;
78
+ /** Injectable confirmation for the interactive path (tests); defaults to a readline prompt. */
79
+ confirm?: () => Promise<boolean>;
80
+ }
81
+ export declare function cliInstall(options: CliInstallOptions): Promise<InstallPluginResult>;
82
+ export declare function printInstallResult(result: InstallPluginResult, options?: {
83
+ json?: boolean;
84
+ }): void;
85
+ /**
86
+ * Registers the host-owned `plugin_install` tool (agent capability). It has process effect so it
87
+ * goes through the existing write/process/external permission gate (TUI approval or
88
+ * `--allow-process`; never available under `--read-only`, where it is not registered at all).
89
+ */
90
+ export declare function registerPluginInstallTool(registry: ToolRegistry, options?: {
91
+ readOnly?: boolean;
92
+ configHome?: string;
93
+ runner?: InstallRunner;
94
+ }): void;
95
+ /**
96
+ * Lists npm packages installed under `<root>/node_modules` that declare the `alisio-plugin`
97
+ * keyword — the same packages `plugins`-array entries resolve to. Used by `alisio plugins list`
98
+ * so an installed package shows up alongside file/directory plugins.
99
+ */
100
+ export declare function installedNpmPlugins(root: string): Promise<string[]>;
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Installer for npm plugin packages: `alisio install npm:<package>[@<version>]`.
3
+ *
4
+ * One shared routine (`installPlugin`) backs both the CLI command (`cliInstall`) and the
5
+ * host-owned `plugin_install` tool for the agent, so the two can never drift apart. The install
6
+ * target is the GLOBAL plugins directory `<configHome>/plugins` (default `~/.config/alisio/plugins`),
7
+ * installed with `npm install --prefix`; the package NAME is persisted in the global configuration
8
+ * `plugins` array (never the resolved filesystem path), keeping entries portable. Loading the plugin
9
+ * still follows the existing executable-plugin policy: it runs in-process wherever it is loaded and
10
+ * is disabled entirely under `--read-only`.
11
+ */
12
+ import { chmod, mkdir, open, readdir, rename, unlink } from "node:fs/promises";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { textResult } from "@alisio/sdk";
15
+ import { configHome } from "../config.js";
16
+ import { readJson } from "../runtime/fs.js";
17
+ import { PLUGIN_KEYWORD } from "../runtime/modules.js";
18
+ import { runProcess } from "../runtime/process.js";
19
+ /** npm package name: scope (optional) followed by the name; both start with an alphanumeric. */
20
+ const PACKAGE_PART = "[A-Za-z0-9][A-Za-z0-9._-]*";
21
+ const NAME_RE = new RegExp(`^(?:@${PACKAGE_PART}\\/)?${PACKAGE_PART}$`);
22
+ /** Version specifier: npm's `@<version>` part, restricted to the same safe character set. */
23
+ const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
24
+ /**
25
+ * Validates an install spec without touching the network: `npm:<package>[@<version>]` (canonical),
26
+ * a bare package name (same resolution), and nothing else. Rejects shell metacharacters, `..` /
27
+ * absolute paths, and unknown prefixes (`registry:`, `git:`, `file:`, URLs) with a clear error.
28
+ */
29
+ export function parsePluginSpec(spec) {
30
+ if (!spec || !spec.trim())
31
+ throw new Error("Plugin spec is empty: use npm:<package>[@<version>] or a bare package name, for example npm:plugin-openrouter");
32
+ const trimmed = spec.trim();
33
+ let rest = trimmed;
34
+ if (trimmed.startsWith("npm:"))
35
+ rest = trimmed.slice(4);
36
+ else {
37
+ const colon = trimmed.indexOf(":");
38
+ if (colon >= 0)
39
+ throw new Error(`Unsupported plugin spec prefix "${trimmed.slice(0, colon)}:": only npm:<package>[@<version>] ` +
40
+ `or a bare package name is supported (registry:/git:/file:/http: specs are not)` +
41
+ (trimmed.startsWith("registry:")
42
+ ? "; pass the package name directly instead of a registry URL"
43
+ : ""));
44
+ }
45
+ if (!rest)
46
+ throw new Error(`Plugin spec "${spec}" has no package name`);
47
+ const at = rest.lastIndexOf("@");
48
+ let name = rest;
49
+ let version;
50
+ if (at > 0) {
51
+ name = rest.slice(0, at);
52
+ version = rest.slice(at + 1);
53
+ }
54
+ if (name.length > 214)
55
+ throw new Error(`Invalid plugin spec "${spec}": package name is too long`);
56
+ if (!NAME_RE.test(name) || name.includes(".."))
57
+ throw new Error(`Invalid plugin package name "${name}" in spec "${spec}": allowed characters are letters, ` +
58
+ `digits, dot, underscore and dash, with "/" only for a scoped name and "@" only for a version; ` +
59
+ `names may not start with "." or "_" and may not contain ".."`);
60
+ if (version !== undefined && !VERSION_RE.test(version))
61
+ throw new Error(`Invalid plugin version "${version}" in spec "${spec}": versions may contain only letters, ` +
62
+ `digits, dot, underscore and dash (for example 1.2.3, 1.2.3-beta.1 or latest)`);
63
+ return { name, version };
64
+ }
65
+ /** The npm argument for a spec: `name`, `name@<version>` or `name@latest` for `--update`. */
66
+ export function npmInstallTarget(name, version, update = false) {
67
+ return update ? `${name}@latest` : version ? `${name}@${version}` : name;
68
+ }
69
+ export const INSTALL_TIMEOUT_MS = 300_000;
70
+ const defaultRunner = (command, args, options) => runProcess(command, args, { ...options, timeoutMs: INSTALL_TIMEOUT_MS, maxBytes: 64_000 });
71
+ /**
72
+ * Redacts probable secrets from npm output (auth tokens, JWTs, bearer credentials, userinfo in
73
+ * URLs) and caps the size, so a failing install never echoes credentials to the user.
74
+ */
75
+ export function sanitizeNpmError(text, maxLength = 4000) {
76
+ const MAX = Math.max(1, maxLength);
77
+ let cleaned = text.replace(/(eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]+|\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*\b|\b(?:npm_?authToken|_authToken|NODE_AUTH_TOKEN|NPM_TOKEN|authToken|password|secret)\b\s*(?:=|:)\s*\S+|\b(?:npm_?authToken|_authToken|NODE_AUTH_TOKEN|NPM_TOKEN|authToken|password|secret)\b\s+\S+)/gi, "[redacted]");
78
+ cleaned = cleaned.replace(/([a-z][a-z0-9+.-]*):\/\/[^@\s/]+@/gi, "$1://[redacted]@");
79
+ if (cleaned.length > MAX)
80
+ cleaned = `${cleaned.slice(0, MAX)}\n…[npm output truncated]`;
81
+ return cleaned;
82
+ }
83
+ function npmInstallError(name, version, update, pluginsDir, result) {
84
+ const detail = sanitizeNpmError([result.stdout, result.stderr].filter(Boolean).join("\n"));
85
+ const retry = `npm install --prefix ${pluginsDir} ${npmInstallTarget(name, version, update)}`;
86
+ return new Error(`npm install failed for plugin "${name}" (exit ${result.exitCode}).\n${detail || "no output"}\n` +
87
+ `Retry with: ${retry}\n(or: alisio install npm:${npmInstallTarget(name, version)})`);
88
+ }
89
+ /** Atomically adds `packageName` to the global configuration `plugins` array, preserving all other fields. */
90
+ async function persistGlobalPluginEntry(configHomeDir, packageName) {
91
+ const file = join(configHomeDir, "config.json");
92
+ const existing = await readJson(file);
93
+ let raw;
94
+ if (existing === undefined)
95
+ raw = {};
96
+ else if (typeof existing === "object" && !Array.isArray(existing))
97
+ raw = existing;
98
+ else
99
+ throw new Error(`Global Alisio configuration must be a JSON object: ${file}`);
100
+ const current = Array.isArray(raw.plugins) ? raw.plugins : [];
101
+ if (!current.includes(packageName))
102
+ raw.plugins = [...current, packageName];
103
+ raw.schemaVersion ??= 1;
104
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
105
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
106
+ const handle = await open(temporary, "wx", 0o600);
107
+ try {
108
+ await handle.writeFile(`${JSON.stringify(raw, null, 2)}\n`, "utf8");
109
+ await handle.sync();
110
+ }
111
+ finally {
112
+ await handle.close();
113
+ }
114
+ try {
115
+ await rename(temporary, file);
116
+ await chmod(file, 0o600);
117
+ }
118
+ catch (error) {
119
+ await unlink(temporary).catch(() => { });
120
+ throw error;
121
+ }
122
+ return file;
123
+ }
124
+ /** The shared install routine used by both the CLI command and the `plugin_install` tool. */
125
+ export async function installPlugin(input) {
126
+ const parsed = parsePluginSpec(input.spec);
127
+ if (input.readOnly)
128
+ throw new Error(`Installing npm plugin "${parsed.name}" is unavailable under --read-only: installation runs npm and writes your global configuration. Run without --read-only.`);
129
+ const home = resolve(input.configHome ?? configHome());
130
+ const pluginsDir = join(home, "plugins");
131
+ // npm creates the prefix directory itself, but the subprocess cwd must already exist for spawn.
132
+ await mkdir(pluginsDir, { recursive: true, mode: 0o700 });
133
+ const packageDir = join(pluginsDir, "node_modules", parsed.name);
134
+ const installedManifest = (await readJson(join(packageDir, "package.json")));
135
+ const alreadyInstalled = !!installedManifest?.version;
136
+ let updated = false;
137
+ if (!alreadyInstalled || input.update) {
138
+ const signal = input.signal ?? AbortSignal.timeout(INSTALL_TIMEOUT_MS);
139
+ const run = input.runner ?? defaultRunner;
140
+ const npmResult = await run("npm", [
141
+ "install",
142
+ "--prefix",
143
+ pluginsDir,
144
+ npmInstallTarget(parsed.name, parsed.version, input.update),
145
+ ], { cwd: pluginsDir, signal });
146
+ if (npmResult.exitCode !== 0)
147
+ throw npmInstallError(parsed.name, parsed.version, input.update, pluginsDir, npmResult);
148
+ updated = !!input.update && alreadyInstalled;
149
+ }
150
+ const manifest = (await readJson(join(packageDir, "package.json")));
151
+ const installedVersion = manifest?.version ?? "unknown";
152
+ const configFile = await persistGlobalPluginEntry(home, parsed.name);
153
+ return {
154
+ packageName: parsed.name,
155
+ installedVersion,
156
+ path: packageDir,
157
+ configEntry: parsed.name,
158
+ configFile,
159
+ pluginsDir,
160
+ alreadyInstalled,
161
+ updated,
162
+ trustRemark: pluginTrustRemark(parsed.name),
163
+ };
164
+ }
165
+ /** Final remark about loading and project trust, printed by the CLI and returned by the tool. */
166
+ export function pluginTrustRemark(name) {
167
+ return (`The plugin "${name}" is installed globally for your user; it loads as trusted personal code ` +
168
+ `wherever global plugins load — use \`alisio --trust-project\` or accept the one-time trust prompt ` +
169
+ `in a project so its configuration and project plugins load too. \`--read-only\` keeps the plugin ` +
170
+ `from loading. Run \`alisio plugins list\` to inspect.`);
171
+ }
172
+ const PREINSTALL_WARNING = `Warning: "npm install" may run the package's lifecycle scripts (preinstall, install, postinstall) ` +
173
+ `with your user privileges. Alisio does not sandbox plugin installation or plugin code; the plugin ` +
174
+ `runs in-process if it is loaded. Only install packages you trust.`;
175
+ export async function cliInstall(options) {
176
+ // Validate the spec before any network operation or prompt.
177
+ const parsed = parsePluginSpec(options.spec);
178
+ if (options.readOnly)
179
+ throw new Error(`Refusing to install npm plugin "${parsed.name}" under --read-only: installing runs npm and writes your global configuration. Run the command without --read-only.`);
180
+ if (options.yes) {
181
+ // Explicit opt-in: no warning, no prompt.
182
+ }
183
+ else if (options.interactive) {
184
+ process.stderr.write(`\n${PREINSTALL_WARNING}\n`);
185
+ const confirmed = await (options.confirm ?? defaultConfirm)();
186
+ if (!confirmed)
187
+ throw new Error(`Install of plugin "${parsed.name}" cancelled; nothing was installed or configured.`);
188
+ }
189
+ else {
190
+ // Headless (including --json): never prompt, fail with an actionable error.
191
+ throw new Error(`Refusing to install npm plugin "${parsed.name}" without confirmation: npm install may run ` +
192
+ `lifecycle scripts from the package. Re-run with --yes (or --trust-plugin) to confirm, for ` +
193
+ `example: alisio install npm:${npmInstallTarget(parsed.name, parsed.version)} --yes`);
194
+ }
195
+ const result = await installPlugin({
196
+ spec: options.spec,
197
+ configHome: options.configHome,
198
+ update: options.update,
199
+ signal: options.signal,
200
+ runner: options.runner,
201
+ });
202
+ printInstallResult(result, { json: !!options.json });
203
+ return result;
204
+ }
205
+ async function defaultConfirm() {
206
+ const { createInterface } = await import("node:readline/promises");
207
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
208
+ try {
209
+ const answer = (await rl.question("Install this plugin package globally? [y/N] "))
210
+ .trim()
211
+ .toLowerCase();
212
+ return answer === "y" || answer === "yes";
213
+ }
214
+ finally {
215
+ rl.close();
216
+ }
217
+ }
218
+ export function printInstallResult(result, options = {}) {
219
+ if (options.json) {
220
+ process.stdout.write(`${JSON.stringify({
221
+ packageName: result.packageName,
222
+ installedVersion: result.installedVersion,
223
+ path: result.path,
224
+ configEntry: result.configEntry,
225
+ configFile: result.configFile,
226
+ alreadyInstalled: result.alreadyInstalled,
227
+ updated: result.updated,
228
+ trustRemark: result.trustRemark,
229
+ }, null, 2)}\n`);
230
+ return;
231
+ }
232
+ if (result.alreadyInstalled && !result.updated)
233
+ console.log(`Plugin "${result.packageName}" is already installed (v${result.installedVersion}). Use --update to refresh it to the latest version and keep its name.`);
234
+ else if (result.updated)
235
+ console.log(`Updated plugin "${result.packageName}" to v${result.installedVersion}.`);
236
+ else
237
+ console.log(`Installed plugin "${result.packageName}" v${result.installedVersion}.`);
238
+ console.log(` path: ${result.path}`);
239
+ console.log(` config: "plugins" entry "${result.configEntry}" in ${result.configFile}`);
240
+ console.log(result.trustRemark);
241
+ }
242
+ /**
243
+ * Registers the host-owned `plugin_install` tool (agent capability). It has process effect so it
244
+ * goes through the existing write/process/external permission gate (TUI approval or
245
+ * `--allow-process`; never available under `--read-only`, where it is not registered at all).
246
+ */
247
+ export function registerPluginInstallTool(registry, options = {}) {
248
+ if (options.readOnly)
249
+ return;
250
+ const tool = {
251
+ name: "plugin_install",
252
+ effect: "process",
253
+ description: "Install an npm plugin package into Alisio's global plugins directory " +
254
+ "(<configHome>/plugins, for example ~/.config/alisio/plugins) and add its npm name to the " +
255
+ 'global configuration "plugins" array so it can load across projects. Accepts "npm:<package>' +
256
+ "[@<version>]\" or a bare package name. npm may run the package's lifecycle scripts with your " +
257
+ "privileges, so this tool goes through the process permission gate like any subprocess; the " +
258
+ "installed plugin runs in-process wherever it is loaded and is disabled under --read-only.",
259
+ inputSchema: {
260
+ type: "object",
261
+ properties: { spec: { type: "string", minLength: 1 } },
262
+ required: ["spec"],
263
+ additionalProperties: false,
264
+ },
265
+ async execute(input, context) {
266
+ const result = await installPlugin({
267
+ spec: String(input.spec),
268
+ configHome: options.configHome,
269
+ runner: options.runner,
270
+ signal: context.signal,
271
+ });
272
+ return textResult(JSON.stringify({
273
+ packageName: result.packageName,
274
+ installedVersion: result.installedVersion,
275
+ path: result.path,
276
+ configEntry: result.configEntry,
277
+ configFile: result.configFile,
278
+ alreadyInstalled: result.alreadyInstalled,
279
+ updated: result.updated,
280
+ trustRemark: result.trustRemark,
281
+ }));
282
+ },
283
+ };
284
+ registry.register(tool);
285
+ }
286
+ /**
287
+ * Lists npm packages installed under `<root>/node_modules` that declare the `alisio-plugin`
288
+ * keyword — the same packages `plugins`-array entries resolve to. Used by `alisio plugins list`
289
+ * so an installed package shows up alongside file/directory plugins.
290
+ */
291
+ export async function installedNpmPlugins(root) {
292
+ const packagesDir = join(root, "node_modules");
293
+ let entries;
294
+ try {
295
+ entries = await readdir(packagesDir, { withFileTypes: true });
296
+ }
297
+ catch (e) {
298
+ if (e.code === "ENOENT")
299
+ return [];
300
+ throw e;
301
+ }
302
+ const result = [];
303
+ const consider = async (dir) => {
304
+ const manifest = (await readJson(join(dir, "package.json")));
305
+ if (manifest && Array.isArray(manifest.keywords) && manifest.keywords.includes(PLUGIN_KEYWORD))
306
+ result.push(dir);
307
+ };
308
+ for (const entry of entries) {
309
+ if (!entry.isDirectory() || entry.name.startsWith("."))
310
+ continue;
311
+ if (entry.name.startsWith("@")) {
312
+ const scoped = await readdir(join(packagesDir, entry.name), { withFileTypes: true }).catch(() => []);
313
+ for (const scope of scoped)
314
+ if (scope.isDirectory())
315
+ await consider(join(packagesDir, entry.name, scope.name));
316
+ }
317
+ else {
318
+ await consider(join(packagesDir, entry.name));
319
+ }
320
+ }
321
+ return result.sort();
322
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * - Global: `<config home>/AGENTS.override.md` or `<config home>/AGENTS.md`.
5
5
  * - Project: one file per directory from the workspace root down to cwd, choosing
6
- * AGENTS.override.md > AGENTS.md > AGENT.md (legacy alias kept from 0.1.0-alpha.1) >
6
+ * AGENTS.override.md > AGENTS.md > AGENT.md (legacy alias from the earliest alphas) >
7
7
  * CLAUDE.md (only with `claudeMdFallback`). Concatenated root-first so the closest is last.
8
8
  * - Nested files below other directories are attached lazily when tools touch paths under them,
9
9
  * once per session per file (re-attached if the file changes).
@@ -1,7 +1,9 @@
1
1
  export declare const PLUGIN_KEYWORD = "alisio-plugin";
2
2
  /** Paths start with `.`, are absolute, use `\`, end in a JS/TS extension or contain `/` unscoped. */
3
3
  export declare function isPathSpec(spec: string): boolean;
4
- /** Global package roots: NODE_PATH entries and the prefix of the running Node/npm. */
4
+ /** Global package roots: NODE_PATH entries, the prefix of the running Node/npm, and the
5
+ * Alisio global plugins directory (`<configHome>/plugins`), where `alisio install` puts npm
6
+ * plugin packages. */
5
7
  export declare function defaultGlobalRoots(): string[];
6
8
  /**
7
9
  * Returns the file to import (or the directory holding an alisio-plugin.json manifest).
@@ -1,5 +1,6 @@
1
1
  /** Resolution of explicitly trusted plugins given as a path or an npm package name. */
2
2
  import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { configHome } from "../config.js";
3
4
  import { readJson } from "./fs.js";
4
5
  export const PLUGIN_KEYWORD = "alisio-plugin";
5
6
  /** Paths start with `.`, are absolute, use `\`, end in a JS/TS extension or contain `/` unscoped. */
@@ -11,7 +12,9 @@ export function isPathSpec(spec) {
11
12
  /\.(?:[cm]?[jt]s|tsx|jsx)$/.test(spec) ||
12
13
  (!spec.startsWith("@") && spec.includes("/")));
13
14
  }
14
- /** Global package roots: NODE_PATH entries and the prefix of the running Node/npm. */
15
+ /** Global package roots: NODE_PATH entries, the prefix of the running Node/npm, and the
16
+ * Alisio global plugins directory (`<configHome>/plugins`), where `alisio install` puts npm
17
+ * plugin packages. */
15
18
  export function defaultGlobalRoots() {
16
19
  const prefix = process.env.npm_config_prefix ?? process.env.PREFIX ?? dirname(dirname(process.execPath));
17
20
  return [
@@ -21,6 +24,7 @@ export function defaultGlobalRoots() {
21
24
  process.platform === "win32"
22
25
  ? join(prefix, "node_modules")
23
26
  : join(prefix, "lib", "node_modules"),
27
+ join(configHome(), "plugins"),
24
28
  ];
25
29
  }
26
30
  function entryOf(manifest) {
@@ -58,8 +62,11 @@ export async function resolvePluginSpec(spec, options) {
58
62
  if (dirname(dir) === dir)
59
63
  break;
60
64
  }
61
- for (const rootDir of options.globalRoots ?? defaultGlobalRoots())
65
+ for (const rootDir of options.globalRoots ?? defaultGlobalRoots()) {
62
66
  candidates.push(join(rootDir, spec));
67
+ // `alisio install` writes npm packages under <root>/node_modules/<spec>.
68
+ candidates.push(join(rootDir, "node_modules", spec));
69
+ }
63
70
  for (const dir of candidates) {
64
71
  searched.push(dir);
65
72
  const manifest = (await readJson(join(dir, "package.json")));
@@ -4,6 +4,15 @@ export interface ProcessResult {
4
4
  exitCode: number;
5
5
  truncated: boolean;
6
6
  }
7
+ /**
8
+ * Detect a spawn failure caused by a missing executable (`ENOENT`), so tools can explain how to
9
+ * install the dependency instead of surfacing the raw OS error.
10
+ */
11
+ export declare function isMissingCommand(error: unknown): boolean;
12
+ /** Per-platform install commands for the ripgrep dependency. */
13
+ export declare const RIPGREP_INSTALL_HINT: string;
14
+ /** Human-readable detection status for `alisio doctor`. */
15
+ export declare function ripgrepDiagnostic(found: boolean): string | undefined;
7
16
  export declare function runProcess(command: string, args: string[], options: {
8
17
  cwd: string;
9
18
  signal: AbortSignal;
@@ -1,4 +1,22 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
+ /**
3
+ * Detect a spawn failure caused by a missing executable (`ENOENT`), so tools can explain how to
4
+ * install the dependency instead of surfacing the raw OS error.
5
+ */
6
+ export function isMissingCommand(error) {
7
+ return (!!error &&
8
+ typeof error === "object" &&
9
+ "code" in error &&
10
+ error.code === "ENOENT");
11
+ }
12
+ /** Per-platform install commands for the ripgrep dependency. */
13
+ export const RIPGREP_INSTALL_HINT = "Install ripgrep (rg): `sudo apt install ripgrep` (Debian/Ubuntu), " +
14
+ "`brew install ripgrep` (macOS), `winget install BurntSushi.ripgrep.MSVC` or " +
15
+ "`scoop install ripgrep` (Windows). It powers the search_text and list_files tools.";
16
+ /** Human-readable detection status for `alisio doctor`. */
17
+ export function ripgrepDiagnostic(found) {
18
+ return found ? undefined : RIPGREP_INSTALL_HINT;
19
+ }
2
20
  export async function runProcess(command, args, options) {
3
21
  options.signal.throwIfAborted();
4
22
  const signal = AbortSignal.any([
@@ -4,13 +4,24 @@ import { dirname, relative } from "node:path";
4
4
  import { textResult, } from "@alisio/sdk";
5
5
  import { fileSize, readHead, readText } from "../runtime/fs.js";
6
6
  import { safePath } from "../runtime/paths.js";
7
- import { runProcess } from "../runtime/process.js";
7
+ import { isMissingCommand, RIPGREP_INSTALL_HINT, runProcess } from "../runtime/process.js";
8
8
  import { EXECUTE_TIMEOUT_MS, MAX_NESTED_CALLS, runExecute } from "./execute.js";
9
9
  import { searchWithFallback } from "./search.js";
10
10
  import { DEFAULT_TIMEOUT_SECONDS, MAX_RESPONSE_BYTES, MAX_TIMEOUT_SECONDS, webfetch, } from "./webfetch.js";
11
11
  const str = { type: "string" }, integer = { type: "integer", minimum: 0 };
12
12
  export const objectSchema = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
13
13
  export const hash = (text) => createHash("sha256").update(text).digest("hex");
14
+ /** Runs ripgrep and turns a missing binary into an actionable install message. */
15
+ async function runRipgrep(args, options) {
16
+ try {
17
+ return await runProcess("rg", args, options);
18
+ }
19
+ catch (error) {
20
+ if (isMissingCommand(error))
21
+ throw new Error(RIPGREP_INSTALL_HINT);
22
+ throw error;
23
+ }
24
+ }
14
25
  async function existing(path) {
15
26
  const size = await fileSize(path);
16
27
  if (size === undefined)
@@ -120,7 +131,7 @@ execCtx, websearchCtx) {
120
131
  paths: filePaths,
121
132
  async execute(i, c) {
122
133
  const path = await safePath(c.workspace, String(i.path ?? "."));
123
- const result = await runProcess("rg", ["--files", "--glob", "!.git", "--glob", "!node_modules", "--", path], { cwd: c.workspace, signal: c.signal, maxBytes: 200_000 });
134
+ const result = await runRipgrep(["--files", "--glob", "!.git", "--glob", "!node_modules", "--", path], { cwd: c.workspace, signal: c.signal, maxBytes: 200_000 });
124
135
  if (result.exitCode > 1 && !result.truncated)
125
136
  throw new Error(result.stderr);
126
137
  const all = result.stdout
@@ -146,7 +157,7 @@ execCtx, websearchCtx) {
146
157
  paths: filePaths,
147
158
  async execute(i, c) {
148
159
  const path = await safePath(c.workspace, String(i.path ?? "."));
149
- const r = await runProcess("rg", [
160
+ const r = await runRipgrep([
150
161
  "--json",
151
162
  "--max-count",
152
163
  "100",
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Reads the @alisio/core package version at runtime so the MCP client metadata stays in sync
3
+ * with the published package. Resolves the manifest relative to the module (`src/version.ts` →
4
+ * `../package.json` in the source tree; `dist/version.js` → `../package.json` when installed).
5
+ * Standalone binaries inject ALISIO_PACKAGE_VERSION at build time (scripts/binary-build.ts).
6
+ */
7
+ export declare function loadVersion(fromHere: string): string;
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /**
5
+ * Fallback used when neither the build-time injection nor the package manifest is readable.
6
+ * A development marker, never a publish literal: it cannot silently desync from the package.
7
+ */
8
+ const FALLBACK = "dev";
9
+ /**
10
+ * Reads the @alisio/core package version at runtime so the MCP client metadata stays in sync
11
+ * with the published package. Resolves the manifest relative to the module (`src/version.ts` →
12
+ * `../package.json` in the source tree; `dist/version.js` → `../package.json` when installed).
13
+ * Standalone binaries inject ALISIO_PACKAGE_VERSION at build time (scripts/binary-build.ts).
14
+ */
15
+ export function loadVersion(fromHere) {
16
+ if (process.env.ALISIO_PACKAGE_VERSION)
17
+ return process.env.ALISIO_PACKAGE_VERSION;
18
+ try {
19
+ const manifest = join(dirname(fileURLToPath(fromHere)), "..", "package.json");
20
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
21
+ return typeof parsed.version === "string" && parsed.version ? parsed.version : FALLBACK;
22
+ }
23
+ catch {
24
+ return FALLBACK;
25
+ }
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/core",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.6",
4
4
  "description": "Embeddable provider-agnostic Alisio agent core: tool loop, context compaction, local tools, MCP, plugin host and SQLite session store. Runs on Node >=22.16 and Bun.",
5
5
  "author": "Gustavo Gutiérrez",
6
6
  "license": "MIT",