@oddessentials/agent-guild 1.21.1 → 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.
Files changed (34) hide show
  1. package/bin/agent-guild-report.mjs +5 -106
  2. package/bin/agent-guild.mjs +2 -0
  3. package/bin/native/darwin-arm64/agent-guild-report +0 -0
  4. package/bin/native/darwin-x64/agent-guild-report +0 -0
  5. package/bin/native/linux-arm64/agent-guild-report +0 -0
  6. package/bin/native/linux-x64/agent-guild-report +0 -0
  7. package/bin/native/win32-arm64/agent-guild-report.exe +0 -0
  8. package/bin/native/win32-x64/agent-guild-report.exe +0 -0
  9. package/bin/windows/arm64/agent-guild-start.exe +0 -0
  10. package/bin/windows/x64/agent-guild-start.exe +0 -0
  11. package/config/providers.default.json +4 -0
  12. package/docs/api.md +27 -5
  13. package/examples/terminal-ai-chat.json +27 -0
  14. package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  15. package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  16. package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  17. package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  18. package/package.json +2 -1
  19. package/src/manager/install-channels.mjs +3 -0
  20. package/src/manager/main.mjs +6 -2
  21. package/src/manager/mcp-connect.mjs +249 -0
  22. package/src/manager/providers.mjs +23 -4
  23. package/src/manager/report-shims.mjs +47 -3
  24. package/src/manager/server.mjs +31 -3
  25. package/src/manager/session-manager.mjs +36 -2
  26. package/src/manager/session.mjs +2 -1
  27. package/src/report/hook-event.mjs +40 -0
  28. package/src/report/transcripts.mjs +95 -0
  29. package/web/app.js +52 -3
  30. package/web/index.html +7 -1
  31. package/web/styles.css +2 -0
  32. package/web/yard/art.json +1 -1
  33. package/web/yard/renderer.js +110 -36
  34. package/web/yard/view.js +13 -3
@@ -17,9 +17,9 @@
17
17
  // (reads Claude Code status line JSON on stdin; prints a
18
18
  // status line, or the JSON itself with --passthrough)
19
19
 
20
- import fs from 'node:fs';
21
20
  import { createRequire } from 'node:module';
22
- import { hookToReports, hookRoute, dockerHookEvent, claudeStatuslineToReport, formatStatusLine, antigravityUserConversation, antigravityReply, claudeCompactBoundary } from '../src/report/hooks.mjs';
21
+ import { claudeStatuslineToReport, formatStatusLine } from '../src/report/hooks.mjs';
22
+ import { handleHookEvent, reportKind } from '../src/report/hook-event.mjs';
23
23
 
24
24
  // Importing node:http as an ES module costs a hook about 50 ms more than requiring it.
25
25
  const http = createRequire(import.meta.url)('node:http');
@@ -66,12 +66,7 @@ function readStdin() {
66
66
  });
67
67
  }
68
68
 
69
- function send(report, kind = report.compaction !== undefined ? 'context'
70
- : report.reply !== undefined ? 'reply'
71
- : report.shell !== undefined ? 'shells'
72
- : report.agentId !== undefined || report.finishForeground === true ? 'agents'
73
- : report.hello === true ? 'reporting'
74
- : report.toolSessionId !== undefined ? 'tool-session' : 'model') {
69
+ function send(report, kind = reportKind(report)) {
75
70
  const body = JSON.stringify(report);
76
71
  return new Promise((resolve, reject) => {
77
72
  const req = http.request(`${env.AGENT_GUILD_URL}/api/v1/sessions/${env.AGENT_GUILD_SESSION_ID}/${kind}`, {
@@ -97,85 +92,6 @@ function send(report, kind = report.compaction !== undefined ? 'context'
97
92
  /** A hook must never break the coding tool, so report failures quietly. */
98
93
  const sendQuietly = (report, kind) => send(report, kind).catch((err) => console.error(`agent-guild-report: ${err.message}`));
99
94
 
100
- const MAX_FIRST_LINE = 1024 * 1024;
101
-
102
- /** The first JSON record of a JSONL file, or null. */
103
- function firstRecord(file) {
104
- if (typeof file !== 'string') return null;
105
- let fd;
106
- try {
107
- fd = fs.openSync(file, 'r');
108
- const chunks = [];
109
- const buffer = Buffer.alloc(64 * 1024);
110
- for (let total = 0; total < MAX_FIRST_LINE;) {
111
- const n = fs.readSync(fd, buffer, 0, buffer.length, null);
112
- if (n === 0) break;
113
- const end = buffer.subarray(0, n).indexOf(10);
114
- chunks.push(Buffer.from(buffer.subarray(0, end === -1 ? n : end)));
115
- if (end !== -1) break;
116
- total += n;
117
- }
118
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
119
- } catch {
120
- return null;
121
- } finally {
122
- if (fd !== undefined) fs.closeSync(fd);
123
- }
124
- }
125
-
126
- /** The last response of an Antigravity CLI turn, read back from the end of its transcript. */
127
- function antigravityTranscriptReply(file) {
128
- if (typeof file !== 'string') return null;
129
- let fd;
130
- try {
131
- fd = fs.openSync(file, 'r');
132
- const size = fs.fstatSync(fd).size;
133
- for (let bytes = 256 * 1024; ; bytes *= 4) {
134
- const start = Math.max(0, size - bytes);
135
- const buffer = Buffer.alloc(size - start);
136
- fs.readSync(fd, buffer, 0, buffer.length, start);
137
- const lines = buffer.toString('utf8').split('\n');
138
- // A window that starts mid-file starts mid-line.
139
- if (start > 0) lines.shift();
140
- const reply = antigravityReply(lines.map(parseJson));
141
- if (reply !== undefined || start === 0) return reply ?? null;
142
- }
143
- } catch {
144
- return null;
145
- } finally {
146
- if (fd !== undefined) fs.closeSync(fd);
147
- }
148
- }
149
-
150
- // Claude Code writes the compaction record a moment after its PostCompact hook starts.
151
- async function claudeCompaction(file, since) {
152
- if (typeof file !== 'string') return null;
153
- for (let waited = 0; waited <= 3000; waited += 250) {
154
- if (waited) await new Promise((resolve) => setTimeout(resolve, 250));
155
- const key = claudeCompactBoundary(tailRecords(file, 1024 * 1024), since);
156
- if (key) return key;
157
- }
158
- return null;
159
- }
160
-
161
- function tailRecords(file, bytes) {
162
- let fd;
163
- try {
164
- fd = fs.openSync(file, 'r');
165
- const size = fs.fstatSync(fd).size;
166
- const start = Math.max(0, size - bytes);
167
- const buffer = Buffer.alloc(size - start);
168
- fs.readSync(fd, buffer, 0, buffer.length, start);
169
- const lines = buffer.toString('utf8').split('\n');
170
- if (start > 0) lines.shift();
171
- return lines.map(parseJson);
172
- } catch {
173
- return [];
174
- } finally {
175
- if (fd !== undefined) fs.closeSync(fd);
176
- }
177
- }
178
-
179
95
  function parseJson(raw) {
180
96
  try { return JSON.parse(raw); } catch { return null; }
181
97
  }
@@ -189,25 +105,8 @@ async function main() {
189
105
  if (args.hook) {
190
106
  const input = parseJson(await readStdin());
191
107
  if (!inSession || !input || typeof input !== 'object') return;
192
- if (args.event && input.hook_event_name === undefined) input.hook_event_name = args.event;
193
- const route = hookRoute(input, env.AGENT_GUILD_REPORTING);
194
- if (!route) return;
195
- // Docker Agent's events go whole to the manager, which tells its sessions (tabs, sub-agents) apart.
196
- if (route === 'docker') {
197
- const event = dockerHookEvent(input);
198
- if (event) await sendQuietly(event, 'docker');
199
- return;
200
- }
201
- // Antigravity CLI runs the hook in its sub-agents too, each a conversation of its own.
202
- if (typeof input.conversationId === 'string' && !antigravityUserConversation(firstRecord(input.transcriptPath))) return;
203
- for (const report of hookToReports(input)) {
204
- if (report.transcript === undefined) await sendQuietly(report);
205
- else {
206
- const key = await claudeCompaction(report.transcript, performance.timeOrigin - 2000);
207
- if (key) await sendQuietly({ compaction: report.compaction, key });
208
- }
209
- }
210
- if (typeof input.conversationId === 'string' && input.hook_event_name === 'Stop') await sendQuietly({ reply: antigravityTranscriptReply(input.transcriptPath) });
108
+ const { later } = await handleHookEvent(input, { reporting: env.AGENT_GUILD_REPORTING, event: args.event, since: performance.timeOrigin - 2000, send: sendQuietly });
109
+ await later;
211
110
  return;
212
111
  }
213
112
  if (args.claudeStatusline) {
@@ -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). |
@@ -674,6 +694,7 @@ All paths are under `/api/v1`.
674
694
  | POST | `/sessions/:id/reporting` | | `{ reporting }`: the tool's hooks announce themselves. |
675
695
  | POST | `/sessions/:id/docker` | `{ event, sessionId, agentName?, toolName?, toolUseId?, command?, model?, reply? }` | `{ ok }`. One Docker Agent hook event. |
676
696
  | POST | `/sessions/:id/shells` | `{ shell, key \| task, match?, agentId?, persist?, endsWithAgent?, kind?, tasks? }` | `{ ok }`. A shell command the tool started, ended or put in the background. |
697
+ | POST | `/sessions/:id/hook?event=&reporting=` | A coding tool's hook event, as the tool gives it to its hooks | `{ ok }`, or `{ ok, errors }` when reports the event makes are refused, each `{ status, code?, message }` as its own route would answer. The manager turns the event into the session's reports as `agent-guild-report --hook` does. `event` names an event that does not name itself; `reporting` is the session's `AGENT_GUILD_REPORTING` where the hook ran, and defaults to the session's own. A transcript the event names is read only if it is a regular file. |
677
698
  | POST | `/shutdown` | `{ force?, restart? }` | `202 { ok, running, restart }`: stops the manager and every session, detaching tmux and herdr sessions. 409 `sessions_running` (with `running`) while other sessions run; retry with `force: true` after the user confirms. With `restart`, a new manager takes over on the same port and token, running the version on disk. |
678
699
 
679
700
  `cwd` defaults to the user's home folder, and a leading `~` is expanded.
@@ -686,14 +707,15 @@ session the card holds; renaming the card to its default name drops it for
686
707
  the tool session it holds then.
687
708
 
688
709
  The `POST /sessions/:id/…` report routes (`agents`, `model`, `tool-session`,
689
- `context`, `reply`, `reporting`, `docker` and `shells`) also accept the per-session
710
+ `context`, `reply`, `reporting`, `docker`, `shells` and `hook`) also accept the per-session
690
711
  report token instead of the API token, in an `X-Agent-Guild-Report-Token`
691
712
  header. The manager gives that token only to the processes inside that
692
713
  session.
693
714
 
694
715
  Request bodies are limited to 64 KiB, except `PUT /notes`,
695
716
  `POST /sessions/:id/reply` and `POST /sessions/:id/docker`, which accept
696
- 1 MiB. Messages a client sends on a WebSocket are limited to 1 MiB.
717
+ 1 MiB, and `POST /sessions/:id/hook`, which accepts 32 MiB and checks the token
718
+ before reading the body. Messages a client sends on a WebSocket are limited to 1 MiB.
697
719
 
698
720
  ## WebSockets
699
721
 
@@ -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.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",
@@ -38,6 +38,7 @@
38
38
  "manager": "node bin/agent-guild.mjs start",
39
39
  "stop": "node bin/agent-guild.mjs stop",
40
40
  "build:windows": "node scripts/build-windows-launcher.mjs",
41
+ "build:reporter": "node scripts/build-reporter.mjs",
41
42
  "status": "node bin/agent-guild.mjs status",
42
43
  "doctor": "node bin/agent-guild.mjs doctor",
43
44
  "demo:build": "node docs/demo/build.mjs",
@@ -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
  }
@@ -23,8 +23,9 @@ import { createCardNames } from './card-names.mjs';
23
23
  import { RemoteAccess, loadRemoteAccess } from './remote-access.mjs';
24
24
  import { SelfUpdate } from './self-update.mjs';
25
25
  import { addRuntimePaths, resolveBaseEnv, pathReader } from './shell-env.mjs';
26
- import { writeReportShims } from './report-shims.mjs';
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';
@@ -68,7 +69,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
68
69
  // because the Node.js path can change between runs.
69
70
  let shimDir = null;
70
71
  try {
71
- shimDir = writeReportShims({ dir: paths.shims, script: path.join(rootDir, 'bin', 'agent-guild-report.mjs') });
72
+ shimDir = writeReportShims({ dir: paths.shims, script: path.join(rootDir, 'bin', 'agent-guild-report.mjs'), native: nativeReporter(rootDir) });
72
73
  } catch (err) {
73
74
  console.warn(`[manager] could not write the agent-guild-report launchers to ${paths.shims}: ${err.message}; hooks need the command on PATH`);
74
75
  }
@@ -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
+ }