@oddessentials/agent-guild 1.21.2 → 1.22.0

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.
@@ -360,6 +360,8 @@ async function main() {
360
360
  }
361
361
  case 'mcp': {
362
362
  // stdout carries only protocol messages from here on.
363
+ const home = args.find((a) => a.startsWith('--home='))?.slice('--home='.length);
364
+ if (home) process.env.AGENT_GUILD_HOME = home;
363
365
  const { serveMcp } = await import('../src/mcp/server.mjs');
364
366
  await serveMcp({ allowInput: flags.has('--allow-input') });
365
367
  return undefined;
@@ -12,6 +12,7 @@
12
12
  "memory": "claude",
13
13
  "instructions": "claude",
14
14
  "reporting": "claude",
15
+ "mcp": "claude",
15
16
  "homeVar": "CLAUDE_CONFIG_DIR",
16
17
  "accountEnv": { "CLAUDE_SECURESTORAGE_CONFIG_DIR": "{dir}" },
17
18
  "hooks": { "path": "settings.json", "example": "claude-code-settings.json" },
@@ -55,6 +56,7 @@
55
56
  "memory": "codex",
56
57
  "instructions": "codex",
57
58
  "reporting": "codex",
59
+ "mcp": "codex",
58
60
  "homeVar": "CODEX_HOME",
59
61
  "hooks": { "path": "hooks.json", "example": "codex-hooks.json" },
60
62
  "modelPattern": "\\bgpt-\\d[a-z0-9.-]*",
@@ -93,6 +95,7 @@
93
95
  "history": "antigravity",
94
96
  "instructions": "google",
95
97
  "reporting": "antigravity",
98
+ "mcp": "antigravity",
96
99
  "modelPattern": "\\bgemini-\\d[a-z0-9.-]*",
97
100
  "args": [],
98
101
  "resumeArgs": ["--conversation", "{id}"],
@@ -126,6 +129,7 @@
126
129
  "memory": "grok",
127
130
  "instructions": "grok",
128
131
  "reporting": "grok",
132
+ "mcp": "grok",
129
133
  "homeVar": "GROK_HOME",
130
134
  "hooks": { "path": "hooks/agent-guild.json", "example": "grok-hooks.json" },
131
135
  "modelPattern": "\\bgrok-(?:build|\\d)[a-z0-9.-]*",
package/docs/api.md CHANGED
@@ -123,8 +123,10 @@ whether its folder is on PATH. `uninstall` is null when Agent Guild cannot
123
123
  remove that copy, and `uninstallGuidance` then says how to remove it instead.
124
124
  `POST /providers/:id/uninstall` removes one copy.
125
125
 
126
- For a tool that is a Docker CLI plugin (Docker Agent), `installCommand` is the
127
- command **Install** and **Update** run. `pluginError` says why docker could not
126
+ `installCommand` is the command **Install** and **Update** run for a tool that
127
+ is a Docker CLI plugin (Docker Agent), or that a configured install command
128
+ updates by running again, and null otherwise. `plugin` names the Docker CLI
129
+ plugin the tool is, or is null. `pluginError` says why docker could not
128
130
  list the plugin's copies, or is null; `pluginPending` is true until docker has
129
131
  listed them once.
130
132
 
@@ -151,7 +153,24 @@ leaves the multiplexer's own session running, and
151
153
 
152
154
  For a tool whose agent or model reporting has to be turned on,
153
155
  `reportingEnabled` says whether it is, and `reportingNote` says why that switch
154
- cannot be used, or is null. `usageUrl`, `billingUrl` and `cloudUrl` are
156
+ cannot be used, or is null.
157
+
158
+ `mcp` is null unless the tool is installed and Agent Guild can add its own MCP
159
+ server (`agent-guild mcp`, see [mcp.md](mcp.md)) to the tool's settings:
160
+ Claude Code, Codex CLI, Antigravity CLI and Grok Build. Otherwise it is
161
+ `{ state, on, total, accounts }`, read from each account's settings and kept
162
+ for up to a minute. `accounts` lists `{ id, label, state, detail }` per
163
+ account, where `state` is `on`, `off`, `disabled` (Agent Guild's server, turned
164
+ off in the tool), `other` (a server of that name that runs something else,
165
+ which Agent Guild never changes) or `unknown` (the settings could not be
166
+ read), and `detail` says why for `other` and `unknown`. An account whose home folder does not exist yet
167
+ reads `off`, and turning the server on makes the folder. `on` counts the
168
+ accounts that are on, of `total`. The provider's `state` is `on` or `off` when
169
+ every account is, `partial` when some but not all are on, else `unknown`,
170
+ `other` or `disabled` for the first of those any account has; `checking`
171
+ before the first read, with `accounts` empty.
172
+
173
+ `usageUrl`, `billingUrl` and `cloudUrl` are
155
174
  `https://` links to the vendor's usage and billing pages and web app, or null.
156
175
 
157
176
  ### Manager environment
@@ -622,6 +641,7 @@ All paths are under `/api/v1`.
622
641
  | PATCH | `/providers/:id/accounts/:account` | `{ label, revision }` | `{ account, dir, providers, revision }`: renames an account, including `default`. |
623
642
  | DELETE | `/providers/:id/accounts/:account?revision=` | | `{ account, dir, providers, revision }`: removes an account from `providers.json`. Its home folder, `dir`, stays on disk. 409 `default_account` for `default`; 409 `account_in_use` while a running session uses it. |
624
643
  | POST | `/providers/:id/reporting` | `{ enabled }` | `{ provider }`: turns agent or model reporting on or off for a tool that has `reportingEnabled`. |
644
+ | POST | `/providers/:id/mcp` | `{ enabled }` | `{ provider, results }`: adds Agent Guild's MCP server to, or removes it from, each account of a tool that has `mcp`. Each account is read again first; one whose state is then `other` or `unknown` is left as it is. `results` has one `{ id, label, outcome, state, detail? }` per account, `outcome` `added`, `removed`, `unchanged`, `skipped` or `failed`; a failed account does not undo the others. 400 `not_applicable` for a tool without `mcp`; 409 `provider_unavailable` when it is not installed; 409 `busy` while a change for that tool is running. |
625
645
  | POST | `/providers/:id/install` | `{ force? }` | `201 { session }`: a session that installs or updates the tool. 409 `provider_in_use` (with `running`, the session count) while the provider's sessions are running; retry with `force: true` after the user confirms. |
626
646
  | POST | `/providers/:id/uninstall` | `{ path, force? }` | `201 { session }`: a session that removes the copy at `path`, one of the provider's `installs`. `force` as for `install`. |
627
647
  | GET | `/environment?scope=&cwd=&id=` | | An environment snapshot; see [Manager environment](#manager-environment). |
@@ -0,0 +1,27 @@
1
+ {
2
+ "providers": [
3
+ {
4
+ "id": "terminal-ai-chat",
5
+ "vendor": "Odd Essentials",
6
+ "tool": "Terminal AI Chat",
7
+ "command": "terminal-ai-chat",
8
+ "versionArgs": ["--version"],
9
+ "color": "#E11D48",
10
+ "monogram": "T",
11
+ "releases": "https://github.com/oddessentials/terminal-ai-chat-releases/releases/latest",
12
+ "install": "curl -fsSL https://github.com/oddessentials/terminal-ai-chat-releases/releases/latest/download/install.sh | sh",
13
+ "docs": "https://github.com/oddessentials/terminal-ai-chat-releases",
14
+ "channels": {
15
+ "native": { "paths": ["~/.local/bin/terminal-ai-chat"], "remove": ["~/.local/bin/terminal-ai-chat"], "reinstall": true, "versionEnv": "TAC_VERSION" },
16
+ "brew": { "names": ["terminal-ai-chat"] }
17
+ },
18
+ "win32": {
19
+ "install": "irm https://github.com/oddessentials/terminal-ai-chat-releases/releases/latest/download/install.ps1 | iex",
20
+ "channels": {
21
+ "native": { "paths": ["~/AppData/Local/Programs/terminal-ai-chat"], "remove": ["~/AppData/Local/Programs/terminal-ai-chat"], "reinstall": true, "versionEnv": "TAC_VERSION" },
22
+ "winget": { "id": "OddEssentials.TerminalAIChat" }
23
+ }
24
+ }
25
+ }
26
+ ]
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "1.21.2",
3
+ "version": "1.22.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "mcpName": "ai.oddessentials/agent-guild",
6
6
  "license": "AGPL-3.0-only",
@@ -227,6 +227,9 @@ export function classifyInstall({
227
227
  if (!npmOnPath) return result('native', { guidance: `${provider.tool} at ${resolvedPath} is updated through npm, but npm was not found.` });
228
228
  return result('native', { update: { file: npmOnPath, args: ['install', '-g'], package: provider.package } });
229
229
  }
230
+ if (native.reinstall && provider.install && !provider.package) {
231
+ return result('native', { update: { file: null, args: [], command: provider.install, reinstall: true } });
232
+ }
230
233
  if (native.update.length === 0) {
231
234
  return result('native', { guidance: `${provider.tool} at ${resolvedPath} has no update command configured. Update it the way you installed it.` });
232
235
  }
@@ -25,6 +25,7 @@ import { SelfUpdate } from './self-update.mjs';
25
25
  import { addRuntimePaths, resolveBaseEnv, pathReader } from './shell-env.mjs';
26
26
  import { nativeReporter, writeReportShims } from './report-shims.mjs';
27
27
  import { SessionHooks } from './session-hooks.mjs';
28
+ import { McpConnect, ownServer } from './mcp-connect.mjs';
28
29
  import { launcherPath, nextManager } from './launch.mjs';
29
30
  import { createAutostart } from './autostart.mjs';
30
31
  import { bootSupported, createBootService } from './systemd-service.mjs';
@@ -92,6 +93,8 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
92
93
  const manager = new SessionManager({
93
94
  registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate, github, sessionHooks, store: multiplexerStore, cardNames,
94
95
  });
96
+ const mcpConnect = new McpConnect({ registry, own: ownServer(), prepareAccount: (provider, account) => manager.prepareAccount(provider, account), dir: dataDir() });
97
+ registry.mcpState = (provider) => mcpConnect.describe(provider);
95
98
  environment.setSessionLookup((id) => {
96
99
  let session;
97
100
  try { session = manager.get(id); } catch { return null; }
@@ -184,6 +187,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
184
187
  notes: createNotesStore(paths.notes),
185
188
  launcher: launcherPath(),
186
189
  yardArt: new YardArt({ webDir, dir: paths.yardArt }),
190
+ mcpConnect,
187
191
  onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then((code) => process.exit(code)),
188
192
  });
189
193
  remoteAccess.attach({ apply: (access) => api.setAccessPolicy(access), getTarget: () => api.url });
@@ -0,0 +1,249 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { buildSpawnSpec, runSpec } from './command-resolver.mjs';
5
+ import { antigravityConfigFile } from './session-hooks.mjs';
6
+ import { stableExecPath } from './autostart.mjs';
7
+ import { PACKAGE_SCRIPT } from './launch.mjs';
8
+ import { dataDir } from './config.mjs';
9
+
10
+ export const SERVER_NAME = 'agent-guild';
11
+ export const MCP_MODES = ['claude', 'codex', 'antigravity', 'grok'];
12
+ const STATE_TTL_MS = 60 * 1000;
13
+ const RUN_TIMEOUT_MS = 30000;
14
+
15
+ export function ownServer(execPath = process.execPath, home = dataDir()) {
16
+ return { command: stableExecPath(execPath), args: [PACKAGE_SCRIPT, 'mcp', `--home=${path.resolve(home)}`] };
17
+ }
18
+
19
+ const homeOf = (env, platform) => (platform === 'win32' ? env.USERPROFILE : env.HOME) || os.homedir();
20
+
21
+ export function classify(entry, own, { enabled = true, command, args, remote = null } = {}) {
22
+ if (entry === undefined || entry === null) return { state: 'off' };
23
+ if (remote) return { state: 'other', detail: `agent-guild is already set to ${remote}` };
24
+ if (typeof command !== 'string' || !Array.isArray(args) || !args.every((a) => typeof a === 'string')) {
25
+ return { state: 'unknown', detail: 'its MCP settings name the agent-guild server in a form Agent Guild does not recognize' };
26
+ }
27
+ if (command !== own.command || args.length !== own.args.length || args.some((a, i) => a !== own.args[i])) {
28
+ return { state: 'other', detail: `agent-guild is already set to run ${[command, ...args].join(' ')}` };
29
+ }
30
+ return { state: enabled === false ? 'disabled' : 'on' };
31
+ }
32
+
33
+ function readJsonFile(file) {
34
+ let text;
35
+ try {
36
+ text = fs.readFileSync(file, 'utf8');
37
+ } catch (err) {
38
+ if (err.code === 'ENOENT') return { missing: true };
39
+ return { error: `${file} cannot be read: ${err.message}` };
40
+ }
41
+ try {
42
+ return { value: JSON.parse(text) };
43
+ } catch {
44
+ return { error: `${file} is not valid JSON` };
45
+ }
46
+ }
47
+
48
+ function fromServersFile(file, own, enabledOf) {
49
+ const read = readJsonFile(file);
50
+ if (read.missing) return { state: 'off' };
51
+ if (read.error) return { state: 'unknown', detail: read.error };
52
+ const servers = read.value?.mcpServers;
53
+ if (servers === undefined) return { state: 'off' };
54
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return { state: 'unknown', detail: `${file} has mcpServers in a form Agent Guild does not recognize` };
55
+ const entry = Object.hasOwn(servers, SERVER_NAME) ? servers[SERVER_NAME] : undefined;
56
+ if (entry === undefined) return { state: 'off' };
57
+ if (!entry || typeof entry !== 'object') return classify(entry, own);
58
+ const remote = entry.type !== undefined && entry.type !== 'stdio' ? String(entry.url ?? entry.type) : null;
59
+ return classify(entry, own, { enabled: enabledOf(entry), command: entry.command, args: entry.args ?? [], remote });
60
+ }
61
+
62
+ function fromList(stdout, own, pick) {
63
+ let list;
64
+ try { list = JSON.parse(stdout); } catch { list = null; }
65
+ if (!Array.isArray(list)) return { state: 'unknown', detail: 'its MCP server list was not in a form Agent Guild recognizes' };
66
+ const entry = list.find((item) => item?.name === SERVER_NAME && pick.match(item));
67
+ if (!entry) return { state: 'off' };
68
+ return classify(entry, own, pick.fields(entry));
69
+ }
70
+
71
+ const TOOLS = {
72
+ claude: {
73
+ file: (env, platform) => path.join(env.CLAUDE_CONFIG_DIR || homeOf(env, platform), '.claude.json'),
74
+ read(ctx) { return fromServersFile(this.file(ctx.env, ctx.platform), ctx.own, () => true); },
75
+ add: (own) => ['mcp', 'add', '-s', 'user', SERVER_NAME, '--', own.command, ...own.args],
76
+ remove: () => ['mcp', 'remove', '-s', 'user', SERVER_NAME],
77
+ },
78
+ codex: {
79
+ list: ['mcp', 'list', '--json'],
80
+ pick: {
81
+ match: () => true,
82
+ fields: (e) => ({ enabled: e.enabled, command: e.transport?.command, args: e.transport?.args ?? [], remote: e.transport?.type && e.transport.type !== 'stdio' ? String(e.transport.url ?? e.transport.type) : null }),
83
+ },
84
+ add: (own) => ['mcp', 'add', SERVER_NAME, '--', own.command, ...own.args],
85
+ // Adding again replaces the entry, and with it the `enabled = false` that turned it off.
86
+ enable: (own) => ['mcp', 'add', SERVER_NAME, '--', own.command, ...own.args],
87
+ remove: () => ['mcp', 'remove', SERVER_NAME],
88
+ },
89
+ antigravity: {
90
+ file: (env, platform) => path.join(path.dirname(antigravityConfigFile(env, platform)), 'mcp_config.json'),
91
+ read(ctx) { return fromServersFile(this.file(ctx.env, ctx.platform), ctx.own, (entry) => entry.disabled !== true); },
92
+ add: (own) => ['mcp', 'add', SERVER_NAME, '--', own.command, ...own.args],
93
+ enable: () => ['mcp', 'enable', SERVER_NAME],
94
+ remove: () => ['mcp', 'remove', SERVER_NAME],
95
+ },
96
+ grok: {
97
+ list: ['mcp', 'list', '--json'],
98
+ pick: { match: (e) => e.scope === undefined || e.scope === 'user', fields: (e) => ({ enabled: e.enabled, command: e.command, args: e.args ?? [], remote: e.url ? String(e.url) : null }) },
99
+ add: (own) => ['mcp', 'add', '-s', 'user', SERVER_NAME, '--', own.command, ...own.args],
100
+ enable: () => ['mcp', 'enable', SERVER_NAME],
101
+ remove: () => ['mcp', 'remove', '-s', 'user', SERVER_NAME],
102
+ },
103
+ };
104
+
105
+ function lastLine(err) {
106
+ return `${err.stderr || ''}\n${err.stdout || ''}`.trim().split('\n').map((l) => l.trim()).filter(Boolean).pop() || err.message;
107
+ }
108
+
109
+ function refusal(status, code, message) {
110
+ return Object.assign(new Error(message), { status, code });
111
+ }
112
+
113
+ export function summarize(accounts) {
114
+ const count = (state) => accounts.filter((a) => a.state === state).length;
115
+ const total = accounts.length;
116
+ const on = count('on');
117
+ const state = on === total ? 'on'
118
+ : count('off') === total ? 'off'
119
+ : on > 0 ? 'partial'
120
+ : count('unknown') ? 'unknown'
121
+ : count('other') ? 'other'
122
+ : count('disabled') ? 'disabled'
123
+ : 'off';
124
+ return { state, on, total };
125
+ }
126
+
127
+ export class McpConnect {
128
+ constructor({ registry, own, prepareAccount, dir = os.homedir(), ttlMs = STATE_TTL_MS }) {
129
+ this.registry = registry;
130
+ this.prepareAccount = prepareAccount;
131
+ this.own = own;
132
+ this.dir = dir;
133
+ this.ttlMs = ttlMs;
134
+ this.cache = new Map();
135
+ this.reading = new Map();
136
+ this.reads = 0;
137
+ this.busy = new Set();
138
+ }
139
+
140
+ _tool(provider) {
141
+ return MCP_MODES.includes(provider.mcp) ? TOOLS[provider.mcp] : null;
142
+ }
143
+
144
+ _accounts(provider) {
145
+ return this.registry.accountsFor(provider).map((account) => ({
146
+ id: account.id, label: account.label, dir: account.dir, account, env: { ...this.registry.env, ...provider.env, ...account.env },
147
+ }));
148
+ }
149
+
150
+ _signature(provider) {
151
+ return JSON.stringify(this._accounts(provider).map((a) => [a.id, a.label, a.dir, provider.homeVar ? a.env[provider.homeVar] ?? null : null]));
152
+ }
153
+
154
+ async _run(provider, resolved, env, args) {
155
+ return runSpec(buildSpawnSpec(resolved, args, env, this.registry.platform), { env, cwd: this.dir, timeoutMs: RUN_TIMEOUT_MS, killTree: true });
156
+ }
157
+
158
+ async readAccount(provider, resolved, account) {
159
+ const tool = this._tool(provider);
160
+ // A managed home that does not exist yet holds no settings, and some tools create it when asked.
161
+ if (account.dir && !fs.existsSync(account.dir)) return { state: 'off' };
162
+ const ctx = { env: account.env, platform: this.registry.platform, own: this.own };
163
+ if (tool.read) return tool.read(ctx);
164
+ try {
165
+ const { stdout } = await this._run(provider, resolved, account.env, tool.list);
166
+ return fromList(stdout, this.own, tool.pick);
167
+ } catch (err) {
168
+ return { state: 'unknown', detail: `${provider.command} ${tool.list.join(' ')} failed: ${lastLine(err)}` };
169
+ }
170
+ }
171
+
172
+ async _readAll(provider, resolved) {
173
+ return Promise.all(this._accounts(provider).map(async (account) => ({ id: account.id, label: account.label, ...await this.readAccount(provider, resolved, account) })));
174
+ }
175
+
176
+ refresh(provider, { force = false } = {}) {
177
+ const resolved = this.registry.resolve(provider);
178
+ if (!this._tool(provider) || !resolved) return Promise.resolve(null);
179
+ const sig = this._signature(provider);
180
+ const cached = this.cache.get(provider.id);
181
+ if (!force && cached && cached.resolved === resolved && cached.sig === sig && Date.now() - cached.at < this.ttlMs) return Promise.resolve(cached.accounts);
182
+ // A forced read follows a change, so a read that started before it must not be the one kept.
183
+ const pending = this.reading.get(provider.id);
184
+ if (!force && pending?.sig === sig) return pending.job;
185
+ const seq = ++this.reads;
186
+ const job = this._readAll(provider, resolved).then((accounts) => {
187
+ if (this.reading.get(provider.id)?.seq !== seq) return accounts;
188
+ this.reading.delete(provider.id);
189
+ // Accounts that changed during the read make its result obsolete.
190
+ const current = this.registry.get(provider.id);
191
+ if (!current || this._signature(current) !== sig) return accounts;
192
+ const before = this.cache.get(provider.id);
193
+ this.cache.set(provider.id, { resolved, sig, accounts, at: Date.now() });
194
+ // A page showing "checking" for a new account set needs this read even when every state is the same.
195
+ if (before?.sig !== sig || JSON.stringify(accounts) !== JSON.stringify(before.accounts)) this.registry.emit?.('updated');
196
+ return accounts;
197
+ }, (err) => {
198
+ if (this.reading.get(provider.id)?.seq === seq) this.reading.delete(provider.id);
199
+ throw err;
200
+ });
201
+ this.reading.set(provider.id, { seq, sig, job });
202
+ return job;
203
+ }
204
+
205
+ describe(provider) {
206
+ if (!this._tool(provider) || !this.registry.resolve(provider)) return null;
207
+ this.refresh(provider).catch(() => {});
208
+ const cached = this.cache.get(provider.id);
209
+ if (!cached || cached.sig !== this._signature(provider)) return { state: 'checking', on: 0, total: provider.accounts.length, accounts: [] };
210
+ return { ...summarize(cached.accounts), accounts: cached.accounts.map(({ id, label, state, detail }) => ({ id, label, state, detail: detail ?? null })) };
211
+ }
212
+
213
+ async setEnabled(provider, enabled) {
214
+ const tool = this._tool(provider);
215
+ if (!tool) throw refusal(400, 'not_applicable', `${provider.tool} has no MCP settings Agent Guild can change`);
216
+ const resolved = this.registry.resolve(provider);
217
+ if (!resolved) throw refusal(409, 'provider_unavailable', `${provider.tool} is not installed`);
218
+ if (this.busy.has(provider.id)) throw refusal(409, 'busy', `${provider.tool}'s MCP settings are already being changed`);
219
+ this.busy.add(provider.id);
220
+ try {
221
+ const results = [];
222
+ for (const account of this._accounts(provider)) {
223
+ results.push({ id: account.id, label: account.label, ...await this._change(provider, resolved, tool, account, enabled) });
224
+ }
225
+ await this.refresh(provider, { force: true });
226
+ return results;
227
+ } finally {
228
+ this.busy.delete(provider.id);
229
+ }
230
+ }
231
+
232
+ async _change(provider, resolved, tool, account, enabled) {
233
+ // The cache is for display only: every change acts on a fresh read of the account.
234
+ const now = await this.readAccount(provider, resolved, account);
235
+ const want = enabled ? 'on' : 'off';
236
+ if (now.state === want) return { outcome: 'unchanged', state: now.state };
237
+ if (now.state === 'unknown' || now.state === 'other') return { outcome: 'skipped', state: now.state, detail: now.detail };
238
+ const args = !enabled ? tool.remove() : now.state === 'disabled' ? tool.enable(this.own) : tool.add(this.own);
239
+ try {
240
+ if (enabled && account.dir) this.prepareAccount(provider, account.account);
241
+ await this._run(provider, resolved, account.env, args);
242
+ } catch (err) {
243
+ return { outcome: 'failed', state: now.state, detail: `${provider.command} ${args.slice(0, 2).join(' ')} failed: ${lastLine(err)}` };
244
+ }
245
+ const after = await this.readAccount(provider, resolved, account);
246
+ if (after.state !== want) return { outcome: 'failed', state: after.state, detail: after.detail ?? `${provider.tool} reported success, but the server is ${after.state}` };
247
+ return { outcome: enabled ? 'added' : 'removed', state: after.state };
248
+ }
249
+ }
@@ -22,6 +22,7 @@ import { MultiplexerRegistry } from './multiplexers.mjs';
22
22
  import { reconcileHerdrPath } from './multiplexer-paths.mjs';
23
23
  import { refreshLaunchers } from './launchers.mjs';
24
24
  import { LaunchFlags, normalizeLaunchFlags } from './launch-flags.mjs';
25
+ import { MCP_MODES } from './mcp-connect.mjs';
25
26
 
26
27
  const here = path.dirname(fileURLToPath(import.meta.url));
27
28
  export const DEFAULTS_FILE = path.resolve(here, '../../config/providers.default.json');
@@ -87,6 +88,8 @@ function normalizeChannels(raw) {
87
88
  remove: stringList(raw.native.remove),
88
89
  links: stringList(raw.native.links),
89
90
  sharedWithNpm: raw.native.sharedWithNpm === true,
91
+ reinstall: raw.native.reinstall === true,
92
+ versionEnv: typeof raw.native.versionEnv === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(raw.native.versionEnv) ? raw.native.versionEnv : null,
90
93
  };
91
94
  }
92
95
  if (raw.brew && typeof raw.brew === 'object') out.brew = { names: stringList(raw.brew.names), autoUpdates: raw.brew.autoUpdates === true };
@@ -171,6 +174,7 @@ function normalize(raw, platform, warnings) {
171
174
  memory: ['claude', 'codex', 'grok'].includes(merged.memory) ? merged.memory : null,
172
175
  instructions: ['claude', 'codex', 'grok', 'google'].includes(merged.instructions) ? merged.instructions : null,
173
176
  reporting: REPORTING_MODES.has(merged.reporting) ? merged.reporting : null,
177
+ mcp: MCP_MODES.includes(merged.mcp) ? merged.mcp : null,
174
178
  modelPattern: merged.modelPattern ? String(merged.modelPattern) : null,
175
179
  args: Array.isArray(merged.args) ? merged.args.map(String) : [],
176
180
  resumeArgs: Array.isArray(merged.resumeArgs) ? merged.resumeArgs.map(String) : [],
@@ -308,6 +312,7 @@ export class ProviderRegistry extends EventEmitter {
308
312
  this._npmRegistry = null;
309
313
  this.reportingEnabled = null;
310
314
  this.reportingNote = null;
315
+ this.mcpState = null;
311
316
  this.multiplexers = new MultiplexerRegistry(this, multiplexerOptions);
312
317
  this.multiplexerState = null;
313
318
  this.reload();
@@ -585,6 +590,16 @@ export class ProviderRegistry extends EventEmitter {
585
590
  return args;
586
591
  }
587
592
 
593
+ reinstalls(provider) {
594
+ return Boolean(!provider.plugin && !provider.package && provider.install && provider.channels.native?.reinstall);
595
+ }
596
+
597
+ installEnv(provider) {
598
+ const name = this.reinstalls(provider) ? provider.channels.native.versionEnv : null;
599
+ const latest = name ? this.versions.get(provider.id)?.latest : null;
600
+ return latest ? { [name]: latest } : null;
601
+ }
602
+
588
603
  /** Whether the tool is there to run: its command on PATH, and for a plugin a copy that `docker` runs. */
589
604
  installed(provider) {
590
605
  return Boolean(provider.plugin ? this._pluginPath(provider) : this.resolve(provider));
@@ -699,7 +714,7 @@ export class ProviderRegistry extends EventEmitter {
699
714
  guidance: `Homebrew moved ${provider.tool} from a formula to a cask, so brew upgrade no longer updates this copy. Run "brew uninstall ${channel.token}", then "brew install --cask ${channel.token}".`,
700
715
  };
701
716
  }
702
- if (provider.plugin) return { file: null, args: [], command: channel.update.command, guidance: null };
717
+ if (provider.plugin || channel.update.reinstall) return { file: null, args: [], command: channel.update.command, guidance: null };
703
718
  const args = this.npmArgs(channel.update, this.versions.get(provider.id)?.latest ?? 'latest');
704
719
  const command = formatCommand(channel.update.file, args);
705
720
  if (channel.probe) {
@@ -828,8 +843,10 @@ export class ProviderRegistry extends EventEmitter {
828
843
  resumable: provider.resumeArgs.length > 0,
829
844
  launchFlags: provider.launchFlags.map(({ flag, risky }) => ({ flag, risky })),
830
845
  launchFlagsEquals: provider.launchFlagsEquals,
831
- installable: provider.plugin ? Boolean(commandPath && !resolvedPath && !pluginError && !pluginPending && provider.install) : Boolean(provider.package && this.resolveNpm()),
832
- installCommand: provider.plugin && provider.install ? provider.install : null,
846
+ installable: provider.plugin ? Boolean(commandPath && !resolvedPath && !pluginError && !pluginPending && provider.install)
847
+ : this.reinstalls(provider) || Boolean(provider.package && this.resolveNpm()),
848
+ installCommand: (provider.plugin || this.reinstalls(provider)) && provider.install ? provider.install : null,
849
+ plugin: provider.plugin,
833
850
  pluginError,
834
851
  pluginPending,
835
852
  installedVersion: installed,
@@ -853,6 +870,7 @@ export class ProviderRegistry extends EventEmitter {
853
870
  reporting: provider.reporting,
854
871
  reportingEnabled: this.reportingEnabled?.(provider) ?? null,
855
872
  reportingNote: this.reportingNote?.(provider) ?? null,
873
+ mcp: this.mcpState?.(provider) ?? null,
856
874
  accounts: provider.accounts.map((account) => ({ id: account.id, label: account.label })),
857
875
  accountsEditable: Boolean(provider.homeVar),
858
876
  shells: shells?.shells.map((shell) => ({ id: shell.id, label: shell.label, path: shell.path, multiplexer: Boolean(shell.multiplexer) })) ?? null,
@@ -935,7 +953,7 @@ export class ProviderRegistry extends EventEmitter {
935
953
  if (!update.command) {
936
954
  throw refusal(400, 'not_updatable', update.guidance || `Still checking how ${provider.tool} was installed. Try again in a moment.`);
937
955
  }
938
- if (provider.plugin) return { spec: this._installCommandSpec(provider), channel: channel.channel };
956
+ if (provider.plugin || channel.update.reinstall) return { spec: this._installCommandSpec(provider), channel: channel.channel };
939
957
  if (!channel.update.package) return { spec: buildSpawnSpec(update.file, update.args, this.env, this.platform), channel: channel.channel };
940
958
  const args = this.npmArgs(channel.update, await this.resolveRelease(provider, update.file));
941
959
  return { spec: npmSpawnSpec(update.file, args, this.env, this.platform), channel: 'npm' };
@@ -963,6 +981,7 @@ export class ProviderRegistry extends EventEmitter {
963
981
  if (plugins.copies.some((c) => c.active)) throw refusal(409, 'already_installed', `${provider.tool} is already installed at ${this._pluginPath(provider)}.`);
964
982
  return this._installCommandSpec(provider);
965
983
  }
984
+ if (this.reinstalls(provider)) return this._installCommandSpec(provider);
966
985
  if (!provider.package) {
967
986
  const err = new Error(`${provider.tool} has no npm package configured; install it by hand: ${provider.install || provider.docs || 'see its documentation'}`);
968
987
  err.status = 400;
@@ -183,6 +183,8 @@ export function createManagerServer({
183
183
  nextManagerProblem = ptyRestartProblem,
184
184
  /** The Yard's art; without a data folder, only a checkout's. */
185
185
  yardArt = new YardArt({ webDir }),
186
+ mcpConnect = null,
187
+ heartbeatMs = 30000,
186
188
  }) {
187
189
  let listedProviders = null;
188
190
  const accounts = createAccountEditor({
@@ -517,6 +519,15 @@ export function createManagerServer({
517
519
  registry.emit('updated');
518
520
  return sendJson(res, 200, { provider: registry.describe(provider) });
519
521
  }
522
+ const mcpMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/mcp$/);
523
+ if (mcpMatch && method === 'POST' && mcpConnect) {
524
+ const provider = registry.get(mcpMatch[1]);
525
+ if (!provider) throw new HttpError(404, `unknown provider "${mcpMatch[1]}"`, 'unknown_provider');
526
+ const body = await readJsonBody(req);
527
+ if (typeof body.enabled !== 'boolean') throw new HttpError(400, 'enabled must be true or false', 'bad_request');
528
+ const results = await mcpConnect.setEnabled(provider, body.enabled);
529
+ return sendJson(res, 200, { provider: registry.describe(provider), results });
530
+ }
520
531
  const multiplexerMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/multiplexers\/([a-z0-9_-]+)\/(install|update|uninstall)$/);
521
532
  if (multiplexerMatch && method === 'POST') {
522
533
  const body = await readJsonBody(req);
@@ -818,7 +829,8 @@ export function createManagerServer({
818
829
  ws.isAlive = false;
819
830
  try { ws.ping(); } catch { /* closing */ }
820
831
  }
821
- }, 30000);
832
+ if (mcpConnect && wss.clients.size) for (const provider of registry.providers) mcpConnect.refresh(provider).catch(() => {});
833
+ }, heartbeatMs);
822
834
  heartbeat.unref();
823
835
 
824
836
  return {
@@ -505,11 +505,11 @@ export class SessionManager extends EventEmitter {
505
505
  const { spec, channel } = await this.registry.updateSpec(provider);
506
506
  guard();
507
507
  const name = `Update ${provider.tool} (${CHANNEL_LABELS[channel]})`;
508
- return this._spawn({ provider, spawnSpec: spec, cwd: os.homedir(), name, task: 'install', installKind: 'update' });
508
+ return this._spawn({ provider, spawnSpec: spec, cwd: os.homedir(), name, task: 'install', installKind: 'update', extraEnv: this.registry.installEnv(provider) });
509
509
  }
510
510
  const spawnSpec = await this.registry.installSpec(provider);
511
511
  guard();
512
- return this._spawn({ provider, spawnSpec, cwd: os.homedir(), name: `Install ${provider.tool}`, task: 'install', installKind: 'install' });
512
+ return this._spawn({ provider, spawnSpec, cwd: os.homedir(), name: `Install ${provider.tool}`, task: 'install', installKind: 'install', extraEnv: this.registry.installEnv(provider) });
513
513
  } finally {
514
514
  this.installing.delete(provider.id);
515
515
  }