kritya 0.8.10-beta → 0.8.11-beta

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/README.md CHANGED
@@ -236,6 +236,19 @@ full tool output. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
236
236
  anywhere, including while a permission prompt is on screen. The statusline
237
237
  shows `⛔ KILLED`, and both the stop and the release are written to the audit
238
238
  log. It's session-only: restarting kritya comes up in the normal state.
239
+ Some terminals steal `Ctrl+K` for their own shortcuts before kritya ever
240
+ sees it — VS Code's integrated terminal is the common case, where it's
241
+ bound as a chord prefix. `/kill [reason]` always works there instead; to
242
+ get `Ctrl+K` itself working, add a `terminalFocus`-scoped override to VS
243
+ Code's `keybindings.json`:
244
+ ```json
245
+ {
246
+ "key": "ctrl+k",
247
+ "command": "workbench.action.terminal.sendSequence",
248
+ "args": { "text": "\u000b" },
249
+ "when": "terminalFocus"
250
+ }
251
+ ```
239
252
  - **Subagents** — the agent can dispatch one or more focused investigations to
240
253
  fresh contexts at once (`spawn_agent`), each returning only its findings —
241
254
  keeps the main conversation lean on big searches. It can also dispatch
package/dist/headless.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { Agent } from "./agent/loop.js";
3
+ import { KillSwitchError } from "./agent/killSwitch.js";
3
4
  import { CONFIG_DIR, legacyGlobalModel, listProviders, loadConfig, loadDotEnv, resolveProvider, } from "./config/config.js";
4
5
  import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
5
6
  import { PermissionManager } from "./permissions/permissions.js";
@@ -184,6 +185,16 @@ export async function runHeadless(args) {
184
185
  const controller = new AbortController();
185
186
  const timer = setTimeout(() => controller.abort(), Math.max(1, args.timeoutSeconds) * 1000);
186
187
  timer.unref();
188
+ // Node's default SIGINT/SIGTERM handling kills the process outright —
189
+ // orphaning background shells/MCP children and leaving no audit trail that
190
+ // this was a deliberate stop rather than a crash. Route both through the
191
+ // same kill switch Ctrl+K uses interactively instead, so the turn tears
192
+ // down cleanly and the stop is logged.
193
+ const stopForSignal = (sig) => {
194
+ agent.kill.engage(`stopped by ${sig}`);
195
+ };
196
+ process.on("SIGINT", stopForSignal);
197
+ process.on("SIGTERM", stopForSignal);
187
198
  const handlers = {
188
199
  onTextDelta: () => { },
189
200
  onReasoningDelta: () => { },
@@ -237,12 +248,17 @@ export async function runHeadless(args) {
237
248
  : " No other provider has an API key configured to fall back to.";
238
249
  errorMessage = `${err.message}.${hint}`;
239
250
  }
251
+ else if (err instanceof KillSwitchError) {
252
+ errorMessage = `Stopped (${err.killReason ?? "kill switch engaged"})`;
253
+ }
240
254
  else {
241
255
  errorMessage = err instanceof Error ? err.message : String(err);
242
256
  }
243
257
  }
244
258
  finally {
245
259
  clearTimeout(timer);
260
+ process.off("SIGINT", stopForSignal);
261
+ process.off("SIGTERM", stopForSignal);
246
262
  backgroundManager.killAll();
247
263
  lspManager.disposeAll();
248
264
  shutdownMcp();
package/dist/index.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.10-beta",
3
+ "version": "0.8.11-beta",
4
4
  "description": "Kritya — a lean, provider-agnostic terminal coding agent (NVIDIA, OpenAI, OpenRouter, Groq, Ollama, and more)",
5
5
  "type": "module",
6
+ "publishConfig": {
7
+ "provenance": true
8
+ },
6
9
  "repository": {
7
10
  "type": "git",
8
11
  "url": "git+https://github.com/GenAICloudDevOps/Kritya.git"
@@ -1,408 +0,0 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- /**
5
- * Agent Plugins specification: a portable package format for reusable components
6
- * that extend AI agents. Version 1.0.0 defines a shared format for Agent Skills
7
- * and MCP servers that compatible clients can discover and load consistently.
8
- *
9
- * Plugin structure:
10
- * my-plugin/
11
- * ├── plugin.json # Required manifest
12
- * ├── skills/ # Agent Skills (in Agent Skills format)
13
- * ├── mcp.json # MCP server configuration
14
- * └── com.example.client/ # Optional client-specific extensions
15
- *
16
- * @see https://agent-plugins.org/
17
- * @see https://developers.googleblog.com/agent-plugins-package-your-skills-tools-and-more/
18
- */
19
- /** The agent-plugins manifest file name. */
20
- export const PLUGIN_MANIFEST_FILE = "plugin.json";
21
- /** The skills directory name within a plugin. */
22
- export const SKILLS_DIR_NAME = "skills";
23
- /** The MCP config file name within a plugin. */
24
- export const MCP_CONFIG_FILE = "mcp.json";
25
- /** User-global plugins root, available across all workspaces. */
26
- export function userPluginsDir() {
27
- return path.join(os.homedir(), ".kritya", "plugins");
28
- }
29
- /**
30
- * Get the workspace plugins directory.
31
- */
32
- export function pluginsDir(workspace) {
33
- return path.join(workspace, ".kritya", "plugins");
34
- }
35
- /**
36
- * Extract plugin ID from manifest.
37
- * Per spec, id should go in extensions under reverse-domain namespace.
38
- * For simplicity, we use the plugin directory basename if not in extensions.
39
- */
40
- export function getPluginId(manifest, dir) {
41
- const extensions = manifest.extensions;
42
- if (extensions?.["kritya.id"]) {
43
- return extensions["kritya.id"];
44
- }
45
- // Fallback to basename of plugin directory
46
- return path.basename(dir);
47
- }
48
- /**
49
- * Resolve a path relative to the plugin root.
50
- * Ensures the result stays within the plugin directory.
51
- * Throws if path escape is detected.
52
- */
53
- export function resolvePluginPath(pluginDir, relativePath) {
54
- const resolved = path.resolve(pluginDir, relativePath);
55
- const normalizedResolved = path.normalize(resolved);
56
- const normalizedPlugin = path.normalize(pluginDir);
57
- // Check that resolved path starts with plugin directory (plus separator or is same)
58
- if (normalizedResolved === normalizedPlugin) {
59
- return normalizedResolved;
60
- }
61
- if (!normalizedResolved.startsWith(normalizedPlugin + path.sep)) {
62
- throw new Error(`Path escape detected: ${relativePath} is outside plugin root`);
63
- }
64
- return normalizedResolved;
65
- }
66
- /**
67
- * Check if a path is contained within the plugin root.
68
- * Resolves symlinks to verify containment.
69
- */
70
- export function isPathContained(pluginDir, candidate) {
71
- try {
72
- const resolvedPlugin = path.resolve(pluginDir);
73
- const resolvedCandidate = path.resolve(candidate);
74
- // Resolve symlinks
75
- const realPlugin = fs.existsSync(resolvedPlugin) ? fs.realpathSync(resolvedPlugin) : resolvedPlugin;
76
- const realCandidate = fs.existsSync(resolvedCandidate) ? fs.realpathSync(resolvedCandidate) : resolvedCandidate;
77
- if (realCandidate === realPlugin)
78
- return true;
79
- if (!realCandidate.startsWith(realPlugin + path.sep))
80
- return false;
81
- return true;
82
- }
83
- catch {
84
- // If resolution fails, fall back to basic check
85
- const resolvedPlugin = path.resolve(pluginDir);
86
- const resolvedCandidate = path.resolve(candidate);
87
- if (resolvedCandidate === resolvedPlugin)
88
- return true;
89
- return resolvedCandidate.startsWith(resolvedPlugin + path.sep);
90
- }
91
- }
92
- /**
93
- * Validate plugin name per spec constraints:
94
- * - 1-64 characters
95
- * - lowercase ASCII letters, digits, hyphens, and periods
96
- * - begins and ends with alphanumeric
97
- * - cannot contain consecutive ".." or "--"
98
- * @returns true if name is valid
99
- */
100
- export function isValidPluginName(name) {
101
- if (name.length < 1 || name.length > 64)
102
- return false;
103
- if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(name) && name.length > 1) {
104
- return false;
105
- }
106
- // Forbid consecutive ".." or "--"
107
- if (name.includes("..") || name.includes("--"))
108
- return false;
109
- return true;
110
- }
111
- /**
112
- * Parse a plugin.json manifest file.
113
- * Validates required fields per spec: $schema (required), name (required).
114
- * Unknown fields are allowed but reported.
115
- * @returns null if the file doesn't exist, is invalid JSON, or missing required fields.
116
- */
117
- export function parsePluginManifest(raw) {
118
- try {
119
- const parsed = JSON.parse(raw);
120
- if (!parsed || typeof parsed !== "object") {
121
- return null;
122
- }
123
- // Per spec v1.0.0, $schema and name are REQUIRED
124
- if (typeof parsed.$schema !== "string" || !parsed.$schema.trim()) {
125
- return null;
126
- }
127
- if (typeof parsed.name !== "string" || !parsed.name.trim()) {
128
- return null;
129
- }
130
- // Validate name constraints
131
- const name = parsed.name;
132
- if (!isValidPluginName(name)) {
133
- return null;
134
- }
135
- return parsed;
136
- }
137
- catch {
138
- return null;
139
- }
140
- }
141
- /**
142
- * Load and parse a plugin manifest from a directory.
143
- * Returns null if no valid manifest exists or if path escape is detected.
144
- */
145
- export function loadPluginManifest(dir) {
146
- const manifestPath = path.join(dir, PLUGIN_MANIFEST_FILE);
147
- // Check path containment before reading
148
- if (!isPathContained(dir, manifestPath)) {
149
- return null;
150
- }
151
- try {
152
- const raw = fs.readFileSync(manifestPath, "utf8");
153
- return parsePluginManifest(raw);
154
- }
155
- catch {
156
- return null;
157
- }
158
- }
159
- /**
160
- * Discover plugins in a directory tree.
161
- * Scans immediate subdirectories for plugin.json files.
162
- * Validates path containment for each discovered plugin.
163
- */
164
- export function scanPlugins(root) {
165
- const loaded = [];
166
- const skipped = [];
167
- // Check if root exists and is contained
168
- if (!isPathContained(path.dirname(root) || "/", root)) {
169
- return { loaded, skipped };
170
- }
171
- let entries;
172
- try {
173
- entries = fs.readdirSync(root, { withFileTypes: true });
174
- }
175
- catch {
176
- return { loaded, skipped };
177
- }
178
- for (const entry of entries) {
179
- if (!entry.isDirectory())
180
- continue;
181
- const dir = path.join(root, entry.name);
182
- // Check path containment
183
- if (!isPathContained(root, dir)) {
184
- skipped.push({
185
- dir,
186
- reason: "path escape detected - directory outside plugin root",
187
- });
188
- continue;
189
- }
190
- const manifest = loadPluginManifest(dir);
191
- if (!manifest) {
192
- skipped.push({
193
- dir,
194
- reason: `missing or invalid ${PLUGIN_MANIFEST_FILE}`,
195
- });
196
- continue;
197
- }
198
- const skillsDirPath = path.join(dir, SKILLS_DIR_NAME);
199
- const mcpConfigPath = path.join(dir, MCP_CONFIG_FILE);
200
- // Verify skills directory is contained
201
- if (fs.existsSync(skillsDirPath) && !isPathContained(dir, skillsDirPath)) {
202
- skipped.push({
203
- dir,
204
- reason: "skills directory outside plugin root",
205
- });
206
- continue;
207
- }
208
- // Verify mcp.json is contained
209
- if (fs.existsSync(mcpConfigPath) && !isPathContained(dir, mcpConfigPath)) {
210
- skipped.push({
211
- dir,
212
- reason: "mcp.json outside plugin root",
213
- });
214
- continue;
215
- }
216
- const plugin = {
217
- id: getPluginId(manifest, dir),
218
- name: manifest.name,
219
- version: manifest.version,
220
- description: manifest.description,
221
- dir,
222
- manifest,
223
- skillsDir: fs.existsSync(skillsDirPath) ? skillsDirPath : undefined,
224
- mcpConfigFile: fs.existsSync(mcpConfigPath) ? mcpConfigPath : undefined,
225
- };
226
- loaded.push(plugin);
227
- }
228
- return { loaded, skipped };
229
- }
230
- /**
231
- * Scan multiple roots for plugins, merging results.
232
- * On ID collision, the first plugin found wins.
233
- * The roots are scanned in order, so earlier roots take precedence.
234
- */
235
- export function scanPluginsDetailed(roots) {
236
- const seen = new Map();
237
- const skipped = [];
238
- for (const root of roots) {
239
- const { loaded, skipped: rootSkipped } = scanPlugins(root);
240
- skipped.push(...rootSkipped);
241
- for (const plugin of loaded) {
242
- const existing = seen.get(plugin.id);
243
- if (existing) {
244
- skipped.push({
245
- dir: plugin.dir,
246
- reason: `duplicate plugin ID "${plugin.id}" (already loaded from ${existing.dir})`,
247
- });
248
- continue;
249
- }
250
- seen.set(plugin.id, plugin);
251
- }
252
- }
253
- return { loaded: [...seen.values()], skipped };
254
- }
255
- /**
256
- * Scan for plugins across user-global and workspace plugin directories.
257
- * Project plugins are checked first, then user plugins, so project plugins win on collision.
258
- */
259
- export function scanAllPlugins(workspace, userRoot) {
260
- const projectRoot = pluginsDir(workspace);
261
- const userPluginsRoot = userRoot ?? userPluginsDir();
262
- // Project plugins take precedence over user plugins
263
- const roots = [projectRoot, userPluginsRoot];
264
- return scanPluginsDetailed(roots);
265
- }
266
- /**
267
- * Type guard for MCP server config.
268
- */
269
- function isMcpServerConfig(v) {
270
- if (!v || typeof v !== "object")
271
- return false;
272
- const cfg = v;
273
- if (!cfg.type)
274
- return false;
275
- if (cfg.type === "stdio") {
276
- return typeof cfg.command === "string";
277
- }
278
- if (cfg.type === "streamable-http" || cfg.type === "legacy-http-sse") {
279
- return typeof cfg.url === "string";
280
- }
281
- return false;
282
- }
283
- /**
284
- * Expand ${VAR} from process.env; unknown variables are left as-is.
285
- * Per spec, only command args and env support expansion, not url.
286
- */
287
- function expandVars(value) {
288
- const VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
289
- return value.replace(VAR_RE, (whole, name) => {
290
- const v = process.env[name];
291
- return v !== undefined ? v : whole;
292
- });
293
- }
294
- /**
295
- * Expand server config with environment variables and resolve relative paths.
296
- * Per spec:
297
- * - command, args, env: expand ${VAR} and resolve relative paths against plugin root
298
- * - url: expand ${VAR} is NOT allowed per spec
299
- * - headers: expand ${VAR}
300
- * - cwd: defaults to plugin root if not specified
301
- * - PLUGIN_ROOT and PLUGIN_DATA are injected for stdio plugins
302
- */
303
- export function expandServerConfig(cfg, pluginDir) {
304
- function expandRecord(rec) {
305
- if (!rec)
306
- return undefined;
307
- const out = {};
308
- for (const [k, v] of Object.entries(rec))
309
- out[k] = expandVars(v);
310
- return out;
311
- }
312
- // Resolve cwd relative to plugin root, defaulting to plugin root if not specified
313
- // If path escape is detected, REJECT this entry (throw) - do not fallback
314
- let resolvedCwd = pluginDir;
315
- if (cfg.cwd) {
316
- try {
317
- resolvedCwd = resolvePluginPath(pluginDir, cfg.cwd);
318
- }
319
- catch (err) {
320
- // Re-throw to reject this MCP entry
321
- throw new Error(`Invalid cwd "${cfg.cwd}": ${err instanceof Error ? err.message : String(err)}`);
322
- }
323
- }
324
- // Resolve command relative to plugin root for stdio
325
- // If path escape is detected, REJECT this entry (throw) - do not fallback
326
- let resolvedCommand;
327
- if (cfg.type === "stdio" && cfg.command) {
328
- try {
329
- resolvedCommand = resolvePluginPath(pluginDir, cfg.command);
330
- }
331
- catch (err) {
332
- // Re-throw to reject this MCP entry
333
- throw new Error(`Invalid command "${cfg.command}": ${err instanceof Error ? err.message : String(err)}`);
334
- }
335
- }
336
- // Inject PLUGIN_ROOT and PLUGIN_DATA for stdio plugins
337
- const env = expandRecord(cfg.env) ?? {};
338
- env.PLUGIN_ROOT = pluginDir;
339
- env.PLUGIN_DATA = path.join(pluginDir, ".kritya", "plugin-data");
340
- return {
341
- type: cfg.type,
342
- command: resolvedCommand ?? cfg.command,
343
- args: cfg.args?.map(expandVars),
344
- env,
345
- cwd: resolvedCwd,
346
- url: cfg.url, // Per spec, ${VAR} expansion is NOT allowed for url
347
- headers: expandRecord(cfg.headers),
348
- tools: cfg.tools,
349
- };
350
- }
351
- /**
352
- * Load MCP servers from an Agent Plugins structure.
353
- * Reads from plugin/mcp.json if it exists.
354
- * Validates transport type and path containment.
355
- * Rejects entries with path escapes instead of falling back.
356
- */
357
- export function loadPluginMcpServers(plugin) {
358
- if (!plugin.mcpConfigFile)
359
- return undefined;
360
- // Check path containment
361
- if (!isPathContained(plugin.dir, plugin.mcpConfigFile)) {
362
- console.warn(`mcp.json is outside plugin root: ${plugin.mcpConfigFile}`);
363
- return undefined;
364
- }
365
- let parsed;
366
- try {
367
- parsed = JSON.parse(fs.readFileSync(plugin.mcpConfigFile, "utf8"));
368
- }
369
- catch (err) {
370
- console.warn(`Failed to read MCP config from ${plugin.mcpConfigFile}: ${err}`);
371
- return undefined;
372
- }
373
- // Per spec, mcpServers object is required
374
- if (!parsed?.mcpServers || typeof parsed.mcpServers !== "object") {
375
- return undefined;
376
- }
377
- const servers = {};
378
- for (const [name, cfg] of Object.entries(parsed.mcpServers)) {
379
- if (!isMcpServerConfig(cfg)) {
380
- console.warn(`Invalid MCP server config for "${name}": missing required fields`);
381
- continue;
382
- }
383
- // Resolve relative paths and expand env vars
384
- // expandServerConfig throws on path escape, which rejects this entry
385
- try {
386
- const expanded = expandServerConfig(cfg, plugin.dir);
387
- servers[name] = expanded;
388
- }
389
- catch (err) {
390
- console.warn(`Rejected MCP server "${name}" in plugin "${plugin.name}": ${err instanceof Error ? err.message : String(err)}`);
391
- continue;
392
- }
393
- }
394
- return Object.keys(servers).length ? servers : undefined;
395
- }
396
- /**
397
- * Get plugin directory path for a given plugin ID.
398
- * This allows looking up where a plugin is installed.
399
- */
400
- export function getPluginDirById(workspace, pluginId, userRoot) {
401
- const { loaded: plugins } = scanAllPlugins(workspace, userRoot);
402
- for (const plugin of plugins) {
403
- if (plugin.id === pluginId) {
404
- return plugin.dir;
405
- }
406
- }
407
- return undefined;
408
- }
@@ -1,204 +0,0 @@
1
- import { scanAllPlugins, loadPluginMcpServers } from "../agent/plugins.js";
2
- import { connectServer, mcpStatus, replaceStatus, disconnectServer, forgetStatus, isToolOf } from "../mcp/client.js";
3
- /**
4
- * The `/plugins` command family: list, show details, and manage plugins.
5
- *
6
- * Plugins are Agent Plugins packages that bundle skills and MCP servers
7
- * into a portable format following the agent-plugins specification.
8
- *
9
- * @see https://agent-plugins.org/
10
- */
11
- const USAGE = `Usage:
12
- /plugins list all discovered plugins
13
- /plugins <name> show details for a specific plugin
14
- /plugins <name> connect connect plugins MCP servers to kritya
15
- /plugins <name> disconnect disconnect plugins MCP servers from kritya`;
16
- export async function runPluginsCommand(ctx) {
17
- const parts = ctx.arg.trim().split(/\s+/).filter(Boolean);
18
- const sub = parts[0]?.toLowerCase();
19
- switch (sub) {
20
- case undefined:
21
- return listPlugins(ctx);
22
- case "connect":
23
- return connectPlugin(ctx, parts[1]);
24
- case "disconnect":
25
- return disconnectPlugin(ctx, parts[1]);
26
- default:
27
- // If it's not a subcommand, treat it as a plugin name to show details
28
- return showPlugin(ctx, sub);
29
- }
30
- }
31
- function listPlugins(ctx) {
32
- const { loaded: plugins } = scanAllPlugins(ctx.workspace);
33
- if (!plugins.length) {
34
- ctx.addItem({
35
- kind: "info",
36
- text: "No plugins found.\n\n" +
37
- "Plugins are loaded from ~/.kritya/plugins (user-global) and .kritya/plugins (project).\n" +
38
- "Create a plugin with a plugin.json manifest, skills/ directory, and optional mcp.json.",
39
- });
40
- return;
41
- }
42
- const lines = plugins.map((p) => {
43
- const versionInfo = p.version ? ` v${p.version}` : "";
44
- const desc = p.description ? ` - ${p.description}` : "";
45
- return ` ${p.name}${versionInfo}${desc} (${p.id})`;
46
- });
47
- ctx.addItem({
48
- kind: "info",
49
- text: `Plugins:\n${lines.join("\n")}\n\n${USAGE}`,
50
- });
51
- }
52
- async function showPlugin(ctx, pluginName) {
53
- const { loaded: plugins } = scanAllPlugins(ctx.workspace);
54
- const plugin = plugins.find((p) => p.name === pluginName || p.id === pluginName);
55
- if (!plugin) {
56
- ctx.addItem({
57
- kind: "info",
58
- text: `No plugin named "${pluginName}". Run /plugins to see available plugins.`,
59
- });
60
- return;
61
- }
62
- // Report skills - note that they are loaded from plugin.skillsDir
63
- const skillsText = plugin.skillsDir
64
- ? `Skills: Available from ${plugin.skillsDir}\n(Use /skills to list discovered skills including plugin skills)`
65
- : "No skills directory";
66
- // Load MCP servers from this plugin
67
- const mcpServers = loadPluginMcpServers(plugin);
68
- const mcpText = mcpServers
69
- ? `MCP Servers (${Object.keys(mcpServers).length}):\n${Object.entries(mcpServers).map(([name, cfg]) => {
70
- const target = typeof cfg.command === "string" ? `command: ${cfg.command}` : `url: ${cfg.url}`;
71
- return ` - ${name}: ${target}`;
72
- }).join("\n")}`
73
- : "No MCP servers";
74
- // Check if MCP servers are currently connected
75
- const status = mcpStatus();
76
- const connectedServers = status
77
- .filter((s) => s.ok && Object.keys(mcpServers || {}).includes(s.name))
78
- .map((s) => s.name);
79
- const connectionState = connectedServers.length
80
- ? `Connected servers: ${connectedServers.join(", ")}`
81
- : "MCP servers not connected";
82
- ctx.addItem({
83
- kind: "info",
84
- text: `Plugin: ${plugin.name} ${plugin.version ? `v${plugin.version}` : ""} (${plugin.id})
85
- ${plugin.description ? `Description: ${plugin.description}` : ""}
86
-
87
- ${skillsText}
88
-
89
- ${mcpText}
90
-
91
- ${connectionState}
92
-
93
- ${USAGE}`,
94
- });
95
- }
96
- async function connectPlugin(ctx, pluginName) {
97
- if (!pluginName) {
98
- ctx.addItem({ kind: "info", text: `Usage: /plugins <name> connect` });
99
- return;
100
- }
101
- const { loaded: plugins } = scanAllPlugins(ctx.workspace);
102
- const plugin = plugins.find((p) => p.name === pluginName || p.id === pluginName);
103
- if (!plugin) {
104
- ctx.addItem({
105
- kind: "info",
106
- text: `No plugin named "${pluginName}". Run /plugins to see available plugins.`,
107
- });
108
- return;
109
- }
110
- // Load MCP servers from this plugin
111
- const mcpServers = loadPluginMcpServers(plugin);
112
- if (!mcpServers || !Object.keys(mcpServers).length) {
113
- ctx.addItem({
114
- kind: "info",
115
- text: `Plugin "${pluginName}" has no MCP servers to connect.`,
116
- });
117
- return;
118
- }
119
- // Connect each server with plugin-specific naming
120
- const connected = [];
121
- const failed = [];
122
- for (const [name, cfg] of Object.entries(mcpServers)) {
123
- // Namespace server name with plugin ID to avoid conflicts
124
- const pluginNamespace = plugin.id.replace(/[^a-z0-9_-]/g, "_");
125
- const serverName = `${pluginNamespace}:${name}`;
126
- try {
127
- const { tools, status } = await connectServer(serverName, cfg, {
128
- tracer: ctx.agent.tracer,
129
- audit: ctx.agent.audit,
130
- workspace: ctx.workspace,
131
- });
132
- replaceStatus(status);
133
- if (status.ok) {
134
- ctx.agent.addTools(tools);
135
- connected.push(serverName);
136
- }
137
- else {
138
- failed.push({ name: serverName, error: status.error || "unknown error" });
139
- }
140
- }
141
- catch (err) {
142
- failed.push({ name: serverName, error: err instanceof Error ? err.message : String(err) });
143
- }
144
- }
145
- let response = `Plugin "${pluginName}":\n`;
146
- if (connected.length) {
147
- response += `Connected ${connected.length} server(s): ${connected.join(", ")}\n`;
148
- }
149
- if (failed.length) {
150
- response += `Failed to connect ${failed.length} server(s):\n`;
151
- response += failed.map((f) => ` - ${f.name}: ${f.error}`).join("\n");
152
- }
153
- ctx.addItem({ kind: "info", text: response });
154
- }
155
- async function disconnectPlugin(ctx, pluginName) {
156
- if (!pluginName) {
157
- ctx.addItem({ kind: "info", text: `Usage: /plugins <name> disconnect` });
158
- return;
159
- }
160
- const { loaded: plugins } = scanAllPlugins(ctx.workspace);
161
- const plugin = plugins.find((p) => p.name === pluginName || p.id === pluginName);
162
- if (!plugin) {
163
- ctx.addItem({
164
- kind: "info",
165
- text: `No plugin named "${pluginName}". Run /plugins to see available plugins.`,
166
- });
167
- return;
168
- }
169
- // Load MCP servers from this plugin
170
- const mcpServers = loadPluginMcpServers(plugin);
171
- if (!mcpServers || !Object.keys(mcpServers).length) {
172
- ctx.addItem({
173
- kind: "info",
174
- text: `Plugin "${pluginName}" has no MCP servers to disconnect.`,
175
- });
176
- return;
177
- }
178
- // Disconnect each server
179
- const disconnected = [];
180
- let removedTools = 0;
181
- for (const [name] of Object.entries(mcpServers)) {
182
- // Use the same namespace as connect
183
- const pluginNamespace = plugin.id.replace(/[^a-z0-9_-]/g, "_");
184
- const serverName = `${pluginNamespace}:${name}`;
185
- disconnected.push(serverName);
186
- // Get tools to remove
187
- const status = mcpStatus().find((s) => s.name === serverName && s.ok);
188
- if (status) {
189
- removedTools += status.tools.length;
190
- }
191
- // Actually disconnect the server
192
- disconnectServer(serverName);
193
- // Remove tools from agent
194
- ctx.agent.removeTools(isToolOf(serverName));
195
- // Forget status so it doesn't remain visible
196
- forgetStatus(serverName);
197
- }
198
- ctx.addItem({
199
- kind: "info",
200
- text: `Plugin "${pluginName}":\n` +
201
- `Disconnected ${disconnected.length} server(s): ${disconnected.join(", ")}\n` +
202
- `Removed ${removedTools} tool(s).`,
203
- });
204
- }