talon-agent 5.2.2 → 5.4.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/README.md +10 -6
- package/package.json +1 -1
- package/src/app.ts +78 -0
- package/src/backend/agy/auth.ts +128 -0
- package/src/backend/agy/constants.ts +88 -0
- package/src/backend/agy/doctor.ts +161 -0
- package/src/backend/agy/effort.ts +76 -0
- package/src/backend/agy/events.ts +402 -0
- package/src/backend/agy/factory.ts +137 -0
- package/src/backend/agy/handler/index.ts +9 -0
- package/src/backend/agy/handler/message.ts +444 -0
- package/src/backend/agy/init.ts +64 -0
- package/src/backend/agy/mcp/config.ts +336 -0
- package/src/backend/agy/mcp/register.ts +134 -0
- package/src/backend/agy/models.ts +335 -0
- package/src/backend/agy/one-shot.ts +301 -0
- package/src/backend/agy/process/child.ts +378 -0
- package/src/backend/agy/process/orphans.ts +106 -0
- package/src/backend/agy/sessions.ts +90 -0
- package/src/backend/agy/state.ts +77 -0
- package/src/backend/builtins.ts +1 -0
- package/src/backend/codex/mcp-config.ts +1 -1
- package/src/backend/openai-agents/mcp-pool.ts +1 -1
- package/src/backend/runtime/index.ts +1 -1
- package/src/cli/commands/backup.ts +396 -0
- package/src/cli/config-view.ts +5 -0
- package/src/cli/config.ts +4 -2
- package/src/cli/events.ts +14 -0
- package/src/cli/index.ts +64 -45
- package/src/cli/setup.ts +49 -0
- package/src/core/agent-runtime/model-ref.ts +1 -0
- package/src/core/backup/archive/digest.ts +77 -0
- package/src/core/backup/archive/tar.ts +567 -0
- package/src/core/backup/archive/zstd.ts +31 -0
- package/src/core/backup/index.ts +54 -0
- package/src/core/backup/plan.ts +273 -0
- package/src/core/backup/restore.ts +410 -0
- package/src/core/backup/scheduler.ts +357 -0
- package/src/core/backup/snapshot.ts +408 -0
- package/src/core/backup/status.ts +194 -0
- package/src/core/backup/store.ts +312 -0
- package/src/core/backup/targets.ts +281 -0
- package/src/core/backup/types.ts +96 -0
- package/src/core/backup/upload.ts +172 -0
- package/src/core/bus/events.ts +45 -1
- package/src/core/config/index.ts +53 -0
- package/src/core/engine/gateway-actions/backup/index.ts +129 -0
- package/src/core/engine/gateway-actions/index.ts +4 -0
- package/src/core/mcp-hub/talon-server.ts +1 -1
- package/src/core/plugin/actions.ts +34 -0
- package/src/core/plugin/index.ts +5 -1
- package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/ops/backup.ts +67 -0
- package/src/core/tools/types.ts +2 -1
- package/src/core/update/self-update.ts +3 -0
- package/src/frontend/discord/callbacks/components/index.ts +3 -0
- package/src/frontend/discord/commands/backup.ts +203 -0
- package/src/frontend/discord/commands/definitions.ts +35 -0
- package/src/frontend/discord/commands/router.ts +3 -0
- package/src/frontend/telegram/callbacks/backup.ts +55 -0
- package/src/frontend/telegram/callbacks/index.ts +8 -0
- package/src/frontend/telegram/commands/backup.ts +209 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +2 -0
- package/src/storage/backup/index.ts +82 -0
- package/src/storage/backup/repo.ts +164 -0
- package/src/storage/db.ts +20 -0
- package/src/storage/sql/backups.sql +46 -0
- package/src/storage/sql/db.sql +8 -0
- package/src/storage/sql/schema.sql +30 -0
- package/src/storage/sql/statements.generated.ts +60 -1
- package/src/util/log.ts +1 -0
- /package/src/core/tools/{ops/mcp-env.ts → mcp-env.ts} +0 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talon's MCP servers, as Antigravity's `mcp_config.json` wants them.
|
|
3
|
+
*
|
|
4
|
+
* agy has no per-process MCP flag and no runtime `mcp add` API a child
|
|
5
|
+
* can be handed: servers are read from one shared file,
|
|
6
|
+
* `~/.gemini/config/mcp_config.json`, when the process starts. That
|
|
7
|
+
* file is the user's too — it can hold servers they added with
|
|
8
|
+
* `agy mcp add` and know nothing about Talon.
|
|
9
|
+
*
|
|
10
|
+
* So the contract here is narrow and defensive:
|
|
11
|
+
*
|
|
12
|
+
* - Talon only ever owns keys prefixed `__talon__` (see
|
|
13
|
+
* [AGY_MCP_PREFIX]); every other key is read, kept, and written
|
|
14
|
+
* back untouched.
|
|
15
|
+
* - A write is read-modify-write through a temp file + rename, so a
|
|
16
|
+
* crash mid-write cannot truncate the user's config.
|
|
17
|
+
* - Entries are scoped — `__talon__<scope>__<server>` — so a chat's
|
|
18
|
+
* servers, a second chat's servers and a one-shot's servers can
|
|
19
|
+
* coexist and be removed independently.
|
|
20
|
+
* - On `mcp add` agy snapshots each server's tool schemas into
|
|
21
|
+
* `~/.gemini/antigravity-cli/mcp/<name>/`. Removing the config
|
|
22
|
+
* entry does not remove that directory, so [removeAgyMcpServers]
|
|
23
|
+
* deletes it explicitly. Left alone they accumulate forever (this
|
|
24
|
+
* host had 17 orphans from a backend deleted months earlier).
|
|
25
|
+
*
|
|
26
|
+
* Both paths are injectable (`TALON_AGY_MCP_CONFIG`,
|
|
27
|
+
* `TALON_AGY_MCP_SNAPSHOT_DIR`, or explicit options) so tests never
|
|
28
|
+
* touch the real files.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { join, dirname } from "node:path";
|
|
33
|
+
import { randomBytes } from "node:crypto";
|
|
34
|
+
import {
|
|
35
|
+
mkdirSync,
|
|
36
|
+
readFileSync,
|
|
37
|
+
renameSync,
|
|
38
|
+
rmSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
} from "node:fs";
|
|
41
|
+
import {
|
|
42
|
+
talonHubUrl,
|
|
43
|
+
pluginHubUrl,
|
|
44
|
+
hubPluginServerNames,
|
|
45
|
+
} from "../../../core/mcp-hub/index.js";
|
|
46
|
+
import { frontendsForChat } from "../../runtime/frontends.js";
|
|
47
|
+
import { logWarn } from "../../../util/log.js";
|
|
48
|
+
import { AGY_MCP_PREFIX } from "../constants.js";
|
|
49
|
+
|
|
50
|
+
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** Where agy reads its MCP servers from. Env override for tests. */
|
|
53
|
+
function agyMcpConfigPath(override?: string): string {
|
|
54
|
+
return (
|
|
55
|
+
override ||
|
|
56
|
+
process.env.TALON_AGY_MCP_CONFIG ||
|
|
57
|
+
join(homedir(), ".gemini", "config", "mcp_config.json")
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Where agy snapshots each registered server's tool schemas. */
|
|
62
|
+
function agyMcpSnapshotDir(override?: string): string {
|
|
63
|
+
return (
|
|
64
|
+
override ||
|
|
65
|
+
process.env.TALON_AGY_MCP_SNAPSHOT_DIR ||
|
|
66
|
+
join(homedir(), ".gemini", "antigravity-cli", "mcp")
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Shapes ──────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One http MCP entry, in the exact shape `agy mcp add --type http`
|
|
74
|
+
* writes. `disabled` is written explicitly: agy treats a missing key
|
|
75
|
+
* as enabled today, but the CLI's own writer always emits it and an
|
|
76
|
+
* entry that silently flips off would be invisible to debug.
|
|
77
|
+
*/
|
|
78
|
+
export interface AgyMcpServer {
|
|
79
|
+
disabled: boolean;
|
|
80
|
+
serverUrl: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface AgyMcpConfigFile {
|
|
84
|
+
mcpServers?: Record<string, unknown>;
|
|
85
|
+
[key: string]: unknown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Naming ──────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Chat ids contain characters (`-`, `@`, `:`) that also appear in
|
|
92
|
+
* server names, so a raw join would make `__talon__a__b__c` ambiguous.
|
|
93
|
+
* Normalise to a single safe token; the id only has to round-trip to
|
|
94
|
+
* itself, not back to the chat id.
|
|
95
|
+
*/
|
|
96
|
+
export function agyScopeSlug(chatId: string): string {
|
|
97
|
+
const slug = chatId.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
98
|
+
return slug || "chat";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Full config key for one Talon server inside one scope. */
|
|
102
|
+
export function agyServerKey(scope: string, server: string): string {
|
|
103
|
+
return `${AGY_MCP_PREFIX}${scope}__${server}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** True for a key Talon owns (and may therefore rewrite or delete). */
|
|
107
|
+
export function isTalonAgyKey(key: string): boolean {
|
|
108
|
+
return key.startsWith(AGY_MCP_PREFIX);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Building ────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build the scoped server set for a chat: one frontend-tools server
|
|
115
|
+
* per frontend that owns the chat, brave when configured, plus every
|
|
116
|
+
* hub-served plugin. Identical membership to
|
|
117
|
+
* `codex/mcp-config.ts: buildCodexMcpServers` — same hub URLs, same
|
|
118
|
+
* `frontendsForChat` scoping — differing only in the wire shape and
|
|
119
|
+
* the `__talon__<scope>__` key prefix agy needs.
|
|
120
|
+
*/
|
|
121
|
+
export function buildAgyMcpServers(args: {
|
|
122
|
+
chatId: string;
|
|
123
|
+
bridgeUrl: string;
|
|
124
|
+
frontends: readonly string[];
|
|
125
|
+
braveApiKey?: string;
|
|
126
|
+
/** Key scope; defaults to the chat's slug. One-shots pass their own. */
|
|
127
|
+
scope?: string;
|
|
128
|
+
}): Record<string, AgyMcpServer> {
|
|
129
|
+
const { chatId, bridgeUrl, frontends, braveApiKey } = args;
|
|
130
|
+
const scope = args.scope ?? agyScopeSlug(chatId);
|
|
131
|
+
const servers: Record<string, AgyMcpServer> = {};
|
|
132
|
+
const add = (name: string, url: string) => {
|
|
133
|
+
servers[agyServerKey(scope, name)] = { disabled: false, serverUrl: url };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
for (const frontend of frontendsForChat(chatId, frontends)) {
|
|
137
|
+
add(`${frontend}-tools`, talonHubUrl(bridgeUrl, frontend, chatId));
|
|
138
|
+
}
|
|
139
|
+
if (braveApiKey) {
|
|
140
|
+
add("brave-search", pluginHubUrl(bridgeUrl, "brave-search", chatId));
|
|
141
|
+
}
|
|
142
|
+
for (const name of hubPluginServerNames()) {
|
|
143
|
+
add(name, pluginHubUrl(bridgeUrl, name, chatId));
|
|
144
|
+
}
|
|
145
|
+
return servers;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Reading / writing ───────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function readConfigFile(path: string): AgyMcpConfigFile {
|
|
151
|
+
let raw: string;
|
|
152
|
+
try {
|
|
153
|
+
raw = readFileSync(path, "utf-8");
|
|
154
|
+
} catch {
|
|
155
|
+
return {};
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const parsed: unknown = JSON.parse(raw);
|
|
159
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
160
|
+
return {};
|
|
161
|
+
}
|
|
162
|
+
return parsed as AgyMcpConfigFile;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// A corrupt file is the user's, not ours to silently replace.
|
|
165
|
+
logWarn(
|
|
166
|
+
"agent",
|
|
167
|
+
`agy mcp_config.json at ${path} is not valid JSON (${
|
|
168
|
+
err instanceof Error ? err.message : String(err)
|
|
169
|
+
}); starting from an empty server map — existing content will be replaced`,
|
|
170
|
+
);
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Read the file's current `mcpServers` map (empty when absent). */
|
|
176
|
+
export function readAgyMcpServers(
|
|
177
|
+
configPath?: string,
|
|
178
|
+
): Record<string, unknown> {
|
|
179
|
+
const file = readConfigFile(agyMcpConfigPath(configPath));
|
|
180
|
+
const servers = file.mcpServers;
|
|
181
|
+
return servers && typeof servers === "object" && !Array.isArray(servers)
|
|
182
|
+
? (servers as Record<string, unknown>)
|
|
183
|
+
: {};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Atomically persist a new `mcpServers` map, preserving every other
|
|
188
|
+
* top-level key of the file. Temp file in the same directory (so
|
|
189
|
+
* `rename` stays on one filesystem and is therefore atomic) then
|
|
190
|
+
* rename over the target.
|
|
191
|
+
*/
|
|
192
|
+
function writeConfigAtomic(
|
|
193
|
+
path: string,
|
|
194
|
+
servers: Record<string, unknown>,
|
|
195
|
+
): void {
|
|
196
|
+
const file = readConfigFile(path);
|
|
197
|
+
file.mcpServers = servers;
|
|
198
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
199
|
+
const tmp = `${path}.talon-${randomBytes(6).toString("hex")}.tmp`;
|
|
200
|
+
try {
|
|
201
|
+
writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, "utf-8");
|
|
202
|
+
renameSync(tmp, path);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
try {
|
|
205
|
+
rmSync(tmp, { force: true });
|
|
206
|
+
} catch {
|
|
207
|
+
/* best effort */
|
|
208
|
+
}
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface AgyMcpWriteOptions {
|
|
214
|
+
configPath?: string;
|
|
215
|
+
snapshotDir?: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Install `servers` as the complete set of Talon entries for `scope`.
|
|
220
|
+
*
|
|
221
|
+
* Entries under other scopes and every non-Talon entry survive
|
|
222
|
+
* untouched; stale entries of THIS scope that are no longer in
|
|
223
|
+
* `servers` are dropped (and their schema snapshots with them).
|
|
224
|
+
* Returns the added / removed key names so `refreshTools` can report
|
|
225
|
+
* a diff.
|
|
226
|
+
*/
|
|
227
|
+
export function writeAgyMcpServers(
|
|
228
|
+
scope: string,
|
|
229
|
+
servers: Record<string, AgyMcpServer>,
|
|
230
|
+
options: AgyMcpWriteOptions = {},
|
|
231
|
+
): { added: string[]; removed: string[] } {
|
|
232
|
+
const path = agyMcpConfigPath(options.configPath);
|
|
233
|
+
const current = readAgyMcpServers(path);
|
|
234
|
+
const scopePrefix = `${AGY_MCP_PREFIX}${scope}__`;
|
|
235
|
+
|
|
236
|
+
const next: Record<string, unknown> = {};
|
|
237
|
+
const removed: string[] = [];
|
|
238
|
+
for (const [key, value] of Object.entries(current)) {
|
|
239
|
+
if (key.startsWith(scopePrefix) && !(key in servers)) {
|
|
240
|
+
removed.push(key);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
next[key] = value;
|
|
244
|
+
}
|
|
245
|
+
const added = Object.keys(servers).filter((key) => !(key in current));
|
|
246
|
+
Object.assign(next, servers);
|
|
247
|
+
|
|
248
|
+
writeConfigAtomic(path, next);
|
|
249
|
+
removeSnapshotDirs(removed, options.snapshotDir);
|
|
250
|
+
return { added, removed };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Drop every Talon entry belonging to `scope`, plus the schema
|
|
255
|
+
* snapshot directories agy left behind for them. Returns the removed
|
|
256
|
+
* key names.
|
|
257
|
+
*/
|
|
258
|
+
export function removeAgyMcpServers(
|
|
259
|
+
scope: string,
|
|
260
|
+
options: AgyMcpWriteOptions = {},
|
|
261
|
+
): string[] {
|
|
262
|
+
const path = agyMcpConfigPath(options.configPath);
|
|
263
|
+
const current = readAgyMcpServers(path);
|
|
264
|
+
const scopePrefix = `${AGY_MCP_PREFIX}${scope}__`;
|
|
265
|
+
const removed = Object.keys(current).filter((key) =>
|
|
266
|
+
key.startsWith(scopePrefix),
|
|
267
|
+
);
|
|
268
|
+
if (removed.length === 0) return [];
|
|
269
|
+
|
|
270
|
+
const next: Record<string, unknown> = {};
|
|
271
|
+
for (const [key, value] of Object.entries(current)) {
|
|
272
|
+
if (!removed.includes(key)) next[key] = value;
|
|
273
|
+
}
|
|
274
|
+
writeConfigAtomic(path, next);
|
|
275
|
+
removeSnapshotDirs(removed, options.snapshotDir);
|
|
276
|
+
return removed;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Drop every `__talon__*` entry this process did not write.
|
|
281
|
+
*
|
|
282
|
+
* Run once at init. Talon's entries are per-boot state — a daemon that
|
|
283
|
+
* died without cleaning up, or (as on this host) a backend deleted
|
|
284
|
+
* months ago, leaves keys pointing at hub URLs on ports nothing is
|
|
285
|
+
* listening on, and agy fails every tool call against them. Keys in
|
|
286
|
+
* `keep` are ours and survive; nothing outside the Talon prefix is
|
|
287
|
+
* ever touched.
|
|
288
|
+
*/
|
|
289
|
+
export function pruneStaleTalonEntries(
|
|
290
|
+
keep: readonly string[] = [],
|
|
291
|
+
options: AgyMcpWriteOptions = {},
|
|
292
|
+
): string[] {
|
|
293
|
+
const path = agyMcpConfigPath(options.configPath);
|
|
294
|
+
const current = readAgyMcpServers(path);
|
|
295
|
+
const kept = new Set(keep);
|
|
296
|
+
const stale = Object.keys(current).filter(
|
|
297
|
+
(key) => isTalonAgyKey(key) && !kept.has(key),
|
|
298
|
+
);
|
|
299
|
+
if (stale.length === 0) return [];
|
|
300
|
+
|
|
301
|
+
const next: Record<string, unknown> = {};
|
|
302
|
+
for (const [key, value] of Object.entries(current)) {
|
|
303
|
+
if (!stale.includes(key)) next[key] = value;
|
|
304
|
+
}
|
|
305
|
+
writeConfigAtomic(path, next);
|
|
306
|
+
removeSnapshotDirs(stale, options.snapshotDir);
|
|
307
|
+
return stale;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Delete the tool-schema snapshot directories agy wrote for the given
|
|
312
|
+
* server names. Best-effort: a missing directory is the normal case
|
|
313
|
+
* for a server that never successfully registered.
|
|
314
|
+
*/
|
|
315
|
+
export function removeSnapshotDirs(
|
|
316
|
+
names: readonly string[],
|
|
317
|
+
snapshotDir?: string,
|
|
318
|
+
): void {
|
|
319
|
+
if (names.length === 0) return;
|
|
320
|
+
const root = agyMcpSnapshotDir(snapshotDir);
|
|
321
|
+
for (const name of names) {
|
|
322
|
+
// Defence in depth: only ever recurse into a directory whose name
|
|
323
|
+
// is a Talon key with no path separators in it.
|
|
324
|
+
if (!isTalonAgyKey(name) || /[\\/]/.test(name)) continue;
|
|
325
|
+
try {
|
|
326
|
+
rmSync(join(root, name), { recursive: true, force: true });
|
|
327
|
+
} catch (err) {
|
|
328
|
+
logWarn(
|
|
329
|
+
"agent",
|
|
330
|
+
`agy: could not remove MCP snapshot dir ${name}: ${
|
|
331
|
+
err instanceof Error ? err.message : String(err)
|
|
332
|
+
}`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration side of agy's MCP wiring: which scopes Talon currently
|
|
3
|
+
* owns in the shared `mcp_config.json`, and the register / unregister
|
|
4
|
+
* / prune operations the handler, the one-shot runner and the factory
|
|
5
|
+
* drive.
|
|
6
|
+
*
|
|
7
|
+
* `mcp-config.ts` is the pure file layer (build, atomic write, remove,
|
|
8
|
+
* snapshot cleanup). This module is the stateful half — it remembers
|
|
9
|
+
* what this boot wrote so `pruneStaleTalonEntries` can tell our
|
|
10
|
+
* entries from a dead daemon's leftovers.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { log, logWarn } from "../../../util/log.js";
|
|
14
|
+
import { nonTerminalFrontends } from "../../runtime/frontends.js";
|
|
15
|
+
import { getState, bridgeUrl } from "../state.js";
|
|
16
|
+
import {
|
|
17
|
+
agyScopeSlug,
|
|
18
|
+
buildAgyMcpServers,
|
|
19
|
+
pruneStaleTalonEntries,
|
|
20
|
+
removeAgyMcpServers,
|
|
21
|
+
writeAgyMcpServers,
|
|
22
|
+
type AgyMcpWriteOptions,
|
|
23
|
+
} from "./config.js";
|
|
24
|
+
|
|
25
|
+
/** Config keys this process wrote, by scope. */
|
|
26
|
+
const ownedKeys = new Map<string, string[]>();
|
|
27
|
+
|
|
28
|
+
/** Every key Talon currently owns, across all scopes. */
|
|
29
|
+
function ownedTalonKeys(): string[] {
|
|
30
|
+
return [...ownedKeys.values()].flat();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Forget all ownership — test isolation. Does not touch the file. */
|
|
34
|
+
export function resetOwnership(): void {
|
|
35
|
+
ownedKeys.clear();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Write (or rewrite) the MCP entries for one scope.
|
|
40
|
+
*
|
|
41
|
+
* `chatId` drives which frontend tool servers are included; `scope`
|
|
42
|
+
* defaults to that chat's slug but a one-shot passes its own so a
|
|
43
|
+
* heartbeat's servers can be removed without disturbing a chat's.
|
|
44
|
+
*/
|
|
45
|
+
export function registerMcpForChat(
|
|
46
|
+
chatId: string,
|
|
47
|
+
options: AgyMcpWriteOptions & { scope?: string } = {},
|
|
48
|
+
): { scope: string; added: string[]; removed: string[]; keys: string[] } {
|
|
49
|
+
const state = getState();
|
|
50
|
+
const scope = options.scope ?? agyScopeSlug(chatId);
|
|
51
|
+
const servers = buildAgyMcpServers({
|
|
52
|
+
chatId,
|
|
53
|
+
bridgeUrl: bridgeUrl(),
|
|
54
|
+
frontends: nonTerminalFrontends(state.config?.frontend),
|
|
55
|
+
braveApiKey: state.config?.braveApiKey,
|
|
56
|
+
scope,
|
|
57
|
+
});
|
|
58
|
+
const keys = Object.keys(servers);
|
|
59
|
+
const { added, removed } = writeAgyMcpServers(scope, servers, options);
|
|
60
|
+
ownedKeys.set(scope, keys);
|
|
61
|
+
if (added.length > 0 || removed.length > 0) {
|
|
62
|
+
log(
|
|
63
|
+
"agent",
|
|
64
|
+
`[${chatId}] agy MCP entries: +${added.length} -${removed.length} (${keys.length} active)`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return { scope, added, removed, keys };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Drop one scope's entries and its schema snapshots. */
|
|
71
|
+
export function unregisterMcpScope(
|
|
72
|
+
scope: string,
|
|
73
|
+
options: AgyMcpWriteOptions = {},
|
|
74
|
+
): string[] {
|
|
75
|
+
let removed: string[] = [];
|
|
76
|
+
try {
|
|
77
|
+
removed = removeAgyMcpServers(scope, options);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
logWarn(
|
|
80
|
+
"agent",
|
|
81
|
+
`agy: failed to remove MCP scope ${scope}: ${
|
|
82
|
+
err instanceof Error ? err.message : String(err)
|
|
83
|
+
}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
ownedKeys.delete(scope);
|
|
87
|
+
return removed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Drop the entries belonging to one chat. */
|
|
91
|
+
export function unregisterMcpForChat(
|
|
92
|
+
chatId: string,
|
|
93
|
+
options: AgyMcpWriteOptions = {},
|
|
94
|
+
): string[] {
|
|
95
|
+
return unregisterMcpScope(agyScopeSlug(chatId), options);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Remove every `__talon__*` entry this boot did not write.
|
|
100
|
+
*
|
|
101
|
+
* Called once from `init`. agy's config file is shared and persistent,
|
|
102
|
+
* so a daemon that died without cleaning up — or, on this host, a
|
|
103
|
+
* Talon backend deleted months earlier — leaves entries pointing at
|
|
104
|
+
* hub URLs that nothing serves any more. agy then fails every tool
|
|
105
|
+
* call against them with a transport error, which the model dutifully
|
|
106
|
+
* reports to the user. Non-Talon entries are never touched.
|
|
107
|
+
*/
|
|
108
|
+
export function pruneForeignTalonEntries(
|
|
109
|
+
options: AgyMcpWriteOptions = {},
|
|
110
|
+
): string[] {
|
|
111
|
+
let stale: string[] = [];
|
|
112
|
+
try {
|
|
113
|
+
stale = pruneStaleTalonEntries(ownedTalonKeys(), options);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
logWarn(
|
|
116
|
+
"agent",
|
|
117
|
+
`agy: MCP prune failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
118
|
+
);
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
if (stale.length > 0) {
|
|
122
|
+
log("agent", `agy: pruned ${stale.length} stale Talon MCP entries`);
|
|
123
|
+
}
|
|
124
|
+
return stale;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Drop every scope this process owns — cleanup / shutdown. */
|
|
128
|
+
export function unregisterAllMcp(options: AgyMcpWriteOptions = {}): void {
|
|
129
|
+
// Snapshot the scopes: unregisterMcpScope mutates the map as we go.
|
|
130
|
+
const scopes = Object.keys(Object.fromEntries(ownedKeys));
|
|
131
|
+
for (const scope of scopes) {
|
|
132
|
+
unregisterMcpScope(scope, options);
|
|
133
|
+
}
|
|
134
|
+
}
|