@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.
- package/bin/agent-guild-report.mjs +5 -106
- package/bin/agent-guild.mjs +2 -0
- package/bin/native/darwin-arm64/agent-guild-report +0 -0
- package/bin/native/darwin-x64/agent-guild-report +0 -0
- package/bin/native/linux-arm64/agent-guild-report +0 -0
- package/bin/native/linux-x64/agent-guild-report +0 -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 +27 -5
- 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 +2 -1
- package/src/manager/install-channels.mjs +3 -0
- package/src/manager/main.mjs +6 -2
- package/src/manager/mcp-connect.mjs +249 -0
- package/src/manager/providers.mjs +23 -4
- package/src/manager/report-shims.mjs +47 -3
- package/src/manager/server.mjs +31 -3
- package/src/manager/session-manager.mjs +36 -2
- package/src/manager/session.mjs +2 -1
- package/src/report/hook-event.mjs +40 -0
- package/src/report/transcripts.mjs +95 -0
- package/web/app.js +52 -3
- 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
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
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) {
|
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
|
|
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). |
|
|
@@ -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 `
|
|
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
|
|
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
|
+
}
|
|
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",
|
|
@@ -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
|
}
|
package/src/manager/main.mjs
CHANGED
|
@@ -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
|
+
}
|