@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.
- package/bin/agent-guild.mjs +2 -0
- package/bin/native/win32-arm64/agent-guild-report.exe +0 -0
- package/bin/native/win32-x64/agent-guild-report.exe +0 -0
- package/bin/windows/arm64/agent-guild-start.exe +0 -0
- package/bin/windows/x64/agent-guild-start.exe +0 -0
- package/config/providers.default.json +4 -0
- package/docs/api.md +23 -3
- package/examples/terminal-ai-chat.json +27 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/package.json +1 -1
- package/src/manager/install-channels.mjs +3 -0
- package/src/manager/main.mjs +4 -0
- package/src/manager/mcp-connect.mjs +249 -0
- package/src/manager/providers.mjs +23 -4
- package/src/manager/server.mjs +13 -1
- package/src/manager/session-manager.mjs +2 -2
- package/web/app.js +51 -2
- package/web/index.html +7 -1
- package/web/styles.css +2 -0
- package/web/yard/art.json +1 -1
- package/web/yard/renderer.js +110 -36
- package/web/yard/view.js +13 -3
package/bin/agent-guild.mjs
CHANGED
|
@@ -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;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
127
|
-
|
|
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.
|
|
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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "1.
|
|
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
|
}
|
package/src/manager/main.mjs
CHANGED
|
@@ -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)
|
|
832
|
-
|
|
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;
|
package/src/manager/server.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|