talon-agent 3.14.0 → 3.15.1
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/package.json +1 -1
- package/src/app.ts +24 -10
- package/src/backend/kilo/factory.ts +26 -0
- package/src/backend/kilo/handler/message.ts +12 -6
- package/src/backend/kilo/handler/turn.ts +43 -40
- package/src/backend/kilo/index.ts +7 -1
- package/src/backend/kilo/server.ts +28 -32
- package/src/backend/kilo/sessions.ts +17 -0
- package/src/backend/opencode/factory.ts +26 -0
- package/src/backend/opencode/handler/message.ts +13 -5
- package/src/backend/opencode/handler/turn.ts +41 -32
- package/src/backend/opencode/index.ts +7 -1
- package/src/backend/opencode/server.ts +27 -16
- package/src/backend/opencode/sessions.ts +17 -0
- package/src/backend/remote-server/client.ts +1 -1
- package/src/backend/remote-server/events.ts +19 -6
- package/src/backend/remote-server/index.ts +16 -1
- package/src/backend/remote-server/lifecycle.ts +5 -1
- package/src/backend/remote-server/mcp.ts +196 -110
- package/src/backend/remote-server/one-shot.ts +53 -6
- package/src/backend/remote-server/providers.ts +12 -0
- package/src/backend/remote-server/session-helpers.ts +69 -0
- package/src/backend/remote-server/sessions.ts +67 -7
- package/src/backend/remote-server/sse-stream.ts +17 -10
- package/src/backend/remote-server/state.ts +6 -0
- package/src/backend/remote-server/turn-timeout.ts +71 -0
- package/src/core/mcp-hub/index.ts +22 -0
- package/src/frontend/shared/status-context.ts +161 -0
- package/src/frontend/telegram/callbacks/model.ts +48 -0
- package/src/frontend/telegram/commands/settings.ts +8 -2
- package/src/frontend/telegram/helpers/menu.ts +5 -2
- package/src/frontend/terminal/commands.ts +152 -0
- package/src/util/respawn.ts +66 -38
|
@@ -4,21 +4,17 @@
|
|
|
4
4
|
* Both OpenCode and Kilo expose the same MCP HTTP API surface
|
|
5
5
|
* (`POST /mcp` to add, `DELETE /mcp/:name` to disconnect) and share the
|
|
6
6
|
* same visibility model upstream: every registered MCP server's tools
|
|
7
|
-
* are visible to every session by default.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* current one, instead of relying on permission rules alone.
|
|
7
|
+
* are visible to every session by default. Talon keeps namespaced servers
|
|
8
|
+
* registered concurrently, then scopes visibility per prompt with a tool
|
|
9
|
+
* override map and execution with per-session permission rules.
|
|
11
10
|
*
|
|
12
11
|
* Functions exported here:
|
|
13
12
|
*
|
|
14
13
|
* - {@link ensureChatMcpServer} — register the current chat's
|
|
15
|
-
* `talon-tools-<chatId>` MCP server
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* MCP servers (`mempalace-tools`, `brave-search`, `github-tools`, …).
|
|
20
|
-
* These are shared globally (not chat-scoped) and only registered
|
|
21
|
-
* once per Talon process.
|
|
14
|
+
* `talon-tools-<chatId>` MCP server. Returns the registered name so
|
|
15
|
+
* callers can scope tool overrides to this chat alone.
|
|
16
|
+
* - {@link ensurePluginMcpServers} — register chat-namespaced plugin MCP
|
|
17
|
+
* servers (`mempalace-tools`, `brave-search`, `github-tools`, …).
|
|
22
18
|
* - {@link buildToolOverrides} — produce a `tools` map that whitelists
|
|
23
19
|
* this chat's Talon tools and blacklists every other chat's. Used
|
|
24
20
|
* as the `tools` field on the prompt payload to constrain the model's
|
|
@@ -29,9 +25,8 @@
|
|
|
29
25
|
* Performance: registrations are cached locally in
|
|
30
26
|
* `state.registeredMcpServers`. Both OpenCode's and Kilo's `GET /mcp` is
|
|
31
27
|
* known to return `{}` regardless of actual state, so we can't rely on
|
|
32
|
-
* the server to tell us what's already registered.
|
|
33
|
-
*
|
|
34
|
-
* plugin servers, which spawn subprocesses on each `add`.
|
|
28
|
+
* the server to tell us what's already registered. A chat's initial plugin
|
|
29
|
+
* registrations run concurrently through the hub; later turns skip them.
|
|
35
30
|
*/
|
|
36
31
|
|
|
37
32
|
import { log, logWarn } from "../../util/log.js";
|
|
@@ -39,12 +34,14 @@ import {
|
|
|
39
34
|
talonHubUrl,
|
|
40
35
|
pluginHubUrl,
|
|
41
36
|
hubPluginServerNames,
|
|
37
|
+
listHubPluginToolNames,
|
|
42
38
|
} from "../../core/mcp-hub/index.js";
|
|
43
39
|
import type { RemoteAgentClient } from "./client.js";
|
|
44
40
|
import type { RemoteServerState } from "./state.js";
|
|
45
41
|
import { errMsg } from "./state.js";
|
|
46
42
|
import type { TalonConfig } from "../../util/config.js";
|
|
47
43
|
import type { FrontendName } from "../../core/agent-runtime/backend-registry.js";
|
|
44
|
+
import { ALL_TOOLS, nativeTools } from "../../core/tools/index.js";
|
|
48
45
|
import {
|
|
49
46
|
frontendForChatId,
|
|
50
47
|
nonTerminalFrontends,
|
|
@@ -54,6 +51,8 @@ import {
|
|
|
54
51
|
|
|
55
52
|
/** Stable name prefix for Talon's per-chat MCP servers. */
|
|
56
53
|
export const TALON_MCP_SERVER_NAME = "talon-tools";
|
|
54
|
+
/** Stable prefix for chat-scoped plugin MCP registrations. */
|
|
55
|
+
export const TALON_PLUGIN_MCP_SERVER_NAME = "talon-plugin";
|
|
57
56
|
|
|
58
57
|
/**
|
|
59
58
|
* MCP add() calls slower than this get a `[slow]` annotation in the log
|
|
@@ -63,6 +62,15 @@ export const TALON_MCP_SERVER_NAME = "talon-tools";
|
|
|
63
62
|
*/
|
|
64
63
|
const SLOW_MCP_REGISTRATION_MS = 1000;
|
|
65
64
|
|
|
65
|
+
/**
|
|
66
|
+
* OpenCode's `/experimental/tool/ids` endpoint omits dynamically registered
|
|
67
|
+
* MCP tools despite its API description. Synthesize Talon's known MCP ids so
|
|
68
|
+
* prompt-level overrides can still isolate concurrently registered chats.
|
|
69
|
+
*/
|
|
70
|
+
const TALON_TOOL_NAMES = [...ALL_TOOLS, ...nativeTools].map(
|
|
71
|
+
(tool) => tool.name,
|
|
72
|
+
);
|
|
73
|
+
|
|
66
74
|
// ── Internal helpers ────────────────────────────────────────────────────────
|
|
67
75
|
|
|
68
76
|
/**
|
|
@@ -71,10 +79,23 @@ const SLOW_MCP_REGISTRATION_MS = 1000;
|
|
|
71
79
|
* sanitisation step is defense-in-depth for any future frontend.
|
|
72
80
|
*/
|
|
73
81
|
export function getChatMcpServerName(chatId: string): string {
|
|
74
|
-
const safeChatId = chatId
|
|
82
|
+
const safeChatId = safeMcpNamePart(chatId, "chat");
|
|
75
83
|
return `${TALON_MCP_SERVER_NAME}-${safeChatId}`;
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
/** Sanitize a component embedded in an upstream MCP registration name. */
|
|
87
|
+
export function safeMcpNamePart(value: string, fallback: string): string {
|
|
88
|
+
return value.replace(/[^a-zA-Z0-9_-]+/g, "_") || fallback;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Per-chat registration name for a plugin-provided MCP server. */
|
|
92
|
+
export function getPluginMcpServerName(
|
|
93
|
+
pluginName: string,
|
|
94
|
+
chatId: string,
|
|
95
|
+
): string {
|
|
96
|
+
return `${TALON_PLUGIN_MCP_SERVER_NAME}-${safeMcpNamePart(chatId, "chat")}-${safeMcpNamePart(pluginName, "plugin")}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
78
99
|
/** Whether a tool id belongs to one of Talon's MCP servers. */
|
|
79
100
|
export function isTalonToolID(toolID: string): boolean {
|
|
80
101
|
return (
|
|
@@ -110,15 +131,10 @@ function resolveChatToolFrontend(
|
|
|
110
131
|
* Ensure the per-chat Talon MCP server is registered with the upstream
|
|
111
132
|
* agent server.
|
|
112
133
|
*
|
|
113
|
-
* Each chat gets its own namespaced MCP server (`talon-tools-<chatId>`)
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* cross-chat tools from the model's visible catalog. The model's
|
|
118
|
-
* permission rules deny EXECUTION of cross-chat tools but don't hide
|
|
119
|
-
* them from visibility, so without the disconnect step the model in
|
|
120
|
-
* chat A would see `talon-tools-<chatB>_send` in its tool catalog and
|
|
121
|
-
* try to call it.
|
|
134
|
+
* Each chat gets its own namespaced MCP server (`talon-tools-<chatId>`).
|
|
135
|
+
* Servers stay registered across concurrent turns; callers pass the result
|
|
136
|
+
* of `buildToolOverrides` with each prompt so chat A cannot see chat B's
|
|
137
|
+
* tools. Session permission rules independently deny cross-chat execution.
|
|
122
138
|
*
|
|
123
139
|
* Best-effort: a registration failure logs a warning but doesn't throw —
|
|
124
140
|
* the conversation can still proceed without Talon-tool access.
|
|
@@ -130,30 +146,6 @@ export async function ensureChatMcpServer<TClient extends RemoteAgentClient>(
|
|
|
130
146
|
): Promise<string> {
|
|
131
147
|
const serverName = getChatMcpServerName(chatId);
|
|
132
148
|
|
|
133
|
-
// Rotate out other chat servers BEFORE registering this one. The
|
|
134
|
-
// heartbeat sentinel server is exempt (it has no real chat to leak
|
|
135
|
-
// into and stays connected for cron / trigger paths).
|
|
136
|
-
// Snapshot: rotation removes entries from the live set mid-iteration.
|
|
137
|
-
for (const other of Array.from(state.registeredMcpServers)) {
|
|
138
|
-
if (
|
|
139
|
-
!other.startsWith(`${TALON_MCP_SERVER_NAME}-`) ||
|
|
140
|
-
other === serverName ||
|
|
141
|
-
other === `${TALON_MCP_SERVER_NAME}-heartbeat`
|
|
142
|
-
) {
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
try {
|
|
146
|
-
await client.mcp.disconnect({ name: other });
|
|
147
|
-
state.registeredMcpServers.delete(other);
|
|
148
|
-
log("agent", `Disconnected ${other} MCP server (chat switch)`);
|
|
149
|
-
} catch (err) {
|
|
150
|
-
logWarn(
|
|
151
|
-
"agent",
|
|
152
|
-
`Failed to disconnect ${other} during chat switch: ${errMsg(err)}`,
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
149
|
// Local cache short-circuit. The upstream's GET /mcp returns {} regardless
|
|
158
150
|
// of state, so we trust our own record of what we registered earlier in
|
|
159
151
|
// this process. The upstream's POST /mcp is idempotent — a second `add`
|
|
@@ -186,6 +178,7 @@ export async function ensureChatMcpServer<TClient extends RemoteAgentClient>(
|
|
|
186
178
|
},
|
|
187
179
|
});
|
|
188
180
|
state.registeredMcpServers.add(serverName);
|
|
181
|
+
state.registeredMcpTools.set(serverName, TALON_TOOL_NAMES);
|
|
189
182
|
const ms = Date.now() - startedAt;
|
|
190
183
|
log(
|
|
191
184
|
"agent",
|
|
@@ -205,11 +198,11 @@ export async function ensureChatMcpServer<TClient extends RemoteAgentClient>(
|
|
|
205
198
|
/**
|
|
206
199
|
* Register all plugin-provided MCP servers with the upstream agent server.
|
|
207
200
|
*
|
|
208
|
-
* Plugins
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
201
|
+
* Plugins are chat-scoped because most receive `TALON_CHAT_ID` at process
|
|
202
|
+
* start. Each registration therefore gets a chat-qualified name and URL.
|
|
203
|
+
* Registering a global `mempalace-tools` name during prewarm used to bind all
|
|
204
|
+
* later chats to the sentinel `prewarm` context (and concurrent adds raced to
|
|
205
|
+
* replace that URL). Namespacing removes both the routing bug and the race.
|
|
213
206
|
*
|
|
214
207
|
* Returns the list of server names that ended up registered (either
|
|
215
208
|
* freshly added or already connected).
|
|
@@ -221,41 +214,63 @@ export async function ensurePluginMcpServers<TClient extends RemoteAgentClient>(
|
|
|
221
214
|
): Promise<string[]> {
|
|
222
215
|
const bridgeUrl = `http://127.0.0.1:${state.gatewayPortFn()}`;
|
|
223
216
|
const names = hubPluginServerNames();
|
|
224
|
-
const
|
|
217
|
+
const byPlugin =
|
|
218
|
+
state.pluginMcpServersByChat.get(chatId) ?? new Map<string, string>();
|
|
219
|
+
state.pluginMcpServersByChat.set(chatId, byPlugin);
|
|
225
220
|
|
|
226
|
-
for
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
config: {
|
|
236
|
-
type: "remote",
|
|
237
|
-
// Hub-managed child, shared across sessions and idle-reaped —
|
|
238
|
-
// the agent server holds an HTTP connection, not a subprocess.
|
|
239
|
-
url: pluginHubUrl(bridgeUrl, name, chatId),
|
|
240
|
-
},
|
|
241
|
-
});
|
|
242
|
-
registered.push(name);
|
|
243
|
-
state.registeredMcpServers.add(name);
|
|
244
|
-
const ms = Date.now() - startedAt;
|
|
245
|
-
log(
|
|
246
|
-
"agent",
|
|
247
|
-
`Registered plugin MCP server: ${name} (${ms}ms)` +
|
|
248
|
-
(ms > SLOW_MCP_REGISTRATION_MS ? " [slow]" : ""),
|
|
249
|
-
);
|
|
250
|
-
} catch (err) {
|
|
251
|
-
logWarn(
|
|
252
|
-
"agent",
|
|
253
|
-
`Plugin MCP registration failed for ${name}: ${errMsg(err)}`,
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
221
|
+
// Drop registrations for plugins removed since this chat last ran.
|
|
222
|
+
const desired = new Set(names);
|
|
223
|
+
await Promise.all(
|
|
224
|
+
[...byPlugin].map(async ([pluginName, serverName]) => {
|
|
225
|
+
if (desired.has(pluginName)) return;
|
|
226
|
+
await disconnectChatMcpServer(client, state, serverName);
|
|
227
|
+
byPlugin.delete(pluginName);
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
257
230
|
|
|
258
|
-
|
|
231
|
+
const results = await Promise.all(
|
|
232
|
+
names.map(async (name): Promise<string | null> => {
|
|
233
|
+
const serverName = getPluginMcpServerName(name, chatId);
|
|
234
|
+
if (state.registeredMcpServers.has(serverName)) {
|
|
235
|
+
byPlugin.set(name, serverName);
|
|
236
|
+
return serverName;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
// Enumerate before adding. The upstream tool-id endpoint omits MCP
|
|
240
|
+
// tools, so this list is required to disable sibling chats' plugin
|
|
241
|
+
// tools in the prompt override map.
|
|
242
|
+
const toolNames = await listHubPluginToolNames(name, chatId, bridgeUrl);
|
|
243
|
+
const startedAt = Date.now();
|
|
244
|
+
await client.mcp.add({
|
|
245
|
+
name: serverName,
|
|
246
|
+
config: {
|
|
247
|
+
type: "remote",
|
|
248
|
+
// Hub-managed child, shared across sessions and idle-reaped —
|
|
249
|
+
// the agent server holds an HTTP connection, not a subprocess.
|
|
250
|
+
url: pluginHubUrl(bridgeUrl, name, chatId),
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
state.registeredMcpServers.add(serverName);
|
|
254
|
+
state.registeredMcpTools.set(serverName, toolNames);
|
|
255
|
+
byPlugin.set(name, serverName);
|
|
256
|
+
const ms = Date.now() - startedAt;
|
|
257
|
+
log(
|
|
258
|
+
"agent",
|
|
259
|
+
`Registered plugin MCP server: ${serverName} (${ms}ms)` +
|
|
260
|
+
(ms > SLOW_MCP_REGISTRATION_MS ? " [slow]" : ""),
|
|
261
|
+
);
|
|
262
|
+
return serverName;
|
|
263
|
+
} catch (err) {
|
|
264
|
+
logWarn(
|
|
265
|
+
"agent",
|
|
266
|
+
`Plugin MCP registration failed for ${serverName}: ${errMsg(err)}`,
|
|
267
|
+
);
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
return results.filter((name): name is string => Boolean(name));
|
|
259
274
|
}
|
|
260
275
|
|
|
261
276
|
/**
|
|
@@ -267,55 +282,77 @@ export async function ensurePluginMcpServers<TClient extends RemoteAgentClient>(
|
|
|
267
282
|
* access at the visibility layer (not just the execution layer that
|
|
268
283
|
* permission rules cover).
|
|
269
284
|
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
285
|
+
* When the current chat's tools have not appeared in `tool.ids()` yet, still
|
|
286
|
+
* returns a map that disables any sibling chat tools already visible. MCP
|
|
287
|
+
* registration completes asynchronously upstream; returning `undefined` in
|
|
288
|
+
* that window exposed a concurrent heartbeat/dream toolset to the chat.
|
|
289
|
+
* Returns `undefined` only when the catalog contains no Talon tools at all.
|
|
273
290
|
*/
|
|
274
291
|
export async function buildToolOverrides<TClient extends RemoteAgentClient>(
|
|
275
292
|
client: TClient,
|
|
276
293
|
state: RemoteServerState<TClient>,
|
|
277
294
|
chatServerName: string,
|
|
295
|
+
pluginServerNames: readonly string[] = [],
|
|
278
296
|
): Promise<Record<string, boolean> | undefined> {
|
|
297
|
+
const overrides: Record<string, boolean> = {};
|
|
298
|
+
const enabledServers = new Set([chatServerName, ...pluginServerNames]);
|
|
299
|
+
let matchedManagedTool = false;
|
|
300
|
+
|
|
301
|
+
// Build from Talon's own registration ledger first. This is the reliable
|
|
302
|
+
// source for dynamic MCP tools; the upstream ids endpoint omits them and can
|
|
303
|
+
// itself fail during a server wobble. A failed discovery call must never
|
|
304
|
+
// discard the isolation map we can synthesize locally.
|
|
305
|
+
for (const serverName of state.registeredMcpServers) {
|
|
306
|
+
const toolNames =
|
|
307
|
+
state.registeredMcpTools.get(serverName) ??
|
|
308
|
+
(serverName.startsWith(`${TALON_MCP_SERVER_NAME}-`)
|
|
309
|
+
? TALON_TOOL_NAMES
|
|
310
|
+
: []);
|
|
311
|
+
const enabled = enabledServers.has(serverName);
|
|
312
|
+
for (const toolName of toolNames) {
|
|
313
|
+
overrides[`${serverName}_${toolName}`] = enabled;
|
|
314
|
+
}
|
|
315
|
+
if (toolNames.length > 0) matchedManagedTool = true;
|
|
316
|
+
}
|
|
317
|
+
|
|
279
318
|
try {
|
|
280
319
|
const toolIdsResp = await client.tool.ids();
|
|
281
320
|
const toolIds = Array.isArray(toolIdsResp.data) ? toolIdsResp.data : [];
|
|
282
|
-
const overrides: Record<string, boolean> = {};
|
|
283
|
-
const chatToolPrefix = `${chatServerName}_`;
|
|
284
|
-
let matchedChatTool = false;
|
|
285
321
|
|
|
286
322
|
for (const toolId of toolIds) {
|
|
287
|
-
if (typeof toolId !== "string"
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
323
|
+
if (typeof toolId !== "string") continue;
|
|
324
|
+
const owner = [...state.registeredMcpServers].find((serverName) =>
|
|
325
|
+
toolId.startsWith(`${serverName}_`),
|
|
326
|
+
);
|
|
327
|
+
if (owner) {
|
|
328
|
+
overrides[toolId] = enabledServers.has(owner);
|
|
329
|
+
} else if (isTalonToolID(toolId)) {
|
|
330
|
+
overrides[toolId] = toolId.startsWith(`${chatServerName}_`);
|
|
331
|
+
} else {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
matchedManagedTool = true;
|
|
292
335
|
}
|
|
293
|
-
|
|
294
|
-
return matchedChatTool ? overrides : undefined;
|
|
295
336
|
} catch (err) {
|
|
296
337
|
logWarn(
|
|
297
338
|
"agent",
|
|
298
339
|
`Failed to build ${state.label} tool overrides for ${chatServerName}: ${errMsg(err)}`,
|
|
299
340
|
);
|
|
300
|
-
return undefined;
|
|
301
341
|
}
|
|
342
|
+
return matchedManagedTool ? overrides : undefined;
|
|
302
343
|
}
|
|
303
344
|
|
|
304
345
|
/**
|
|
305
|
-
* Disconnect a per-chat MCP server.
|
|
306
|
-
*
|
|
346
|
+
* Disconnect a per-chat MCP server. One-shot paths use this to release
|
|
347
|
+
* their ephemeral context registration once the run ends.
|
|
307
348
|
*
|
|
308
349
|
* Errors are swallowed (the server may already be gone if MCP itself
|
|
309
350
|
* crashed). The local cache entry is removed unconditionally so a
|
|
310
351
|
* future `ensureChatMcpServer` re-registers — otherwise the cache
|
|
311
352
|
* short-circuit would skip a server the upstream no longer has.
|
|
312
353
|
*
|
|
313
|
-
*
|
|
314
|
-
* per-
|
|
315
|
-
* `ensureChatMcpServer` handles the rotation, and re-registering the
|
|
316
|
-
* same chat's server every turn costs ~800ms of subprocess spawn).
|
|
317
|
-
* OpenCode's handler still calls it for now; the next refactor pass
|
|
318
|
-
* will align both backends on the keep-across-turns policy.
|
|
354
|
+
* Chat handlers deliberately keep registrations cached across turns;
|
|
355
|
+
* per-prompt tool overrides provide cross-chat visibility isolation.
|
|
319
356
|
*/
|
|
320
357
|
export async function disconnectChatMcpServer<
|
|
321
358
|
TClient extends RemoteAgentClient,
|
|
@@ -326,16 +363,65 @@ export async function disconnectChatMcpServer<
|
|
|
326
363
|
): Promise<void> {
|
|
327
364
|
try {
|
|
328
365
|
await client.mcp.disconnect({ name: serverName });
|
|
329
|
-
state.registeredMcpServers.delete(serverName);
|
|
330
366
|
} catch (err) {
|
|
331
367
|
logWarn("agent", `Failed to disconnect ${serverName}: ${errMsg(err)}`);
|
|
368
|
+
} finally {
|
|
369
|
+
state.registeredMcpServers.delete(serverName);
|
|
370
|
+
state.registeredMcpTools.delete(serverName);
|
|
371
|
+
for (const byPlugin of state.pluginMcpServersByChat.values()) {
|
|
372
|
+
for (const [pluginName, registeredName] of byPlugin) {
|
|
373
|
+
if (registeredName === serverName) byPlugin.delete(pluginName);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
332
376
|
}
|
|
333
377
|
}
|
|
334
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Reconnect one chat's plugin registrations after a hot reload so the remote
|
|
381
|
+
* server refreshes cached tool schemas. Hub children are already retired by
|
|
382
|
+
* the plugin registry; this refresh handles added/removed/renamed tools.
|
|
383
|
+
*/
|
|
384
|
+
export async function refreshPluginMcpServers<
|
|
385
|
+
TClient extends RemoteAgentClient,
|
|
386
|
+
>(
|
|
387
|
+
client: TClient,
|
|
388
|
+
state: RemoteServerState<TClient>,
|
|
389
|
+
chatId: string,
|
|
390
|
+
): Promise<{
|
|
391
|
+
added: string[];
|
|
392
|
+
removed: string[];
|
|
393
|
+
errors: Record<string, string>;
|
|
394
|
+
}> {
|
|
395
|
+
const previous = new Set(
|
|
396
|
+
state.pluginMcpServersByChat.get(chatId)?.keys() ?? [],
|
|
397
|
+
);
|
|
398
|
+
const existingNames = [
|
|
399
|
+
...(state.pluginMcpServersByChat.get(chatId)?.values() ?? []),
|
|
400
|
+
];
|
|
401
|
+
await Promise.all(
|
|
402
|
+
existingNames.map((name) => disconnectChatMcpServer(client, state, name)),
|
|
403
|
+
);
|
|
404
|
+
state.pluginMcpServersByChat.delete(chatId);
|
|
405
|
+
|
|
406
|
+
const registered = await ensurePluginMcpServers(client, state, chatId);
|
|
407
|
+
const current = new Set(
|
|
408
|
+
state.pluginMcpServersByChat.get(chatId)?.keys() ?? [],
|
|
409
|
+
);
|
|
410
|
+
const desiredCount = hubPluginServerNames().length;
|
|
411
|
+
return {
|
|
412
|
+
added: [...current].filter((name) => !previous.has(name)),
|
|
413
|
+
removed: [...previous].filter((name) => !current.has(name)),
|
|
414
|
+
errors:
|
|
415
|
+
registered.length === desiredCount && current.size === desiredCount
|
|
416
|
+
? {}
|
|
417
|
+
: { registration: "one or more plugin MCP servers failed to register" },
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
335
421
|
/**
|
|
336
422
|
* Snapshot of the locally-cached MCP server registrations. Test-only:
|
|
337
423
|
* `registeredMcpServers` is module-private state that integration tests
|
|
338
|
-
* need to inspect to assert
|
|
424
|
+
* need to inspect to assert concurrent registrations are retained (the
|
|
339
425
|
* upstream's GET /mcp returns {} regardless of state, so we can't query
|
|
340
426
|
* the server itself).
|
|
341
427
|
*/
|
|
@@ -35,10 +35,14 @@
|
|
|
35
35
|
import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
|
|
36
36
|
import { logWarn } from "../../util/log.js";
|
|
37
37
|
import {
|
|
38
|
+
approvePendingPermissions,
|
|
38
39
|
extractAssistantUsage,
|
|
40
|
+
rejectPendingQuestions,
|
|
41
|
+
type RemoteSessionClient,
|
|
39
42
|
type RemoteAssistantInfo,
|
|
40
43
|
} from "./session-helpers.js";
|
|
41
|
-
import { appendBackendSuffix } from "../shared/index.js";
|
|
44
|
+
import { appendBackendSuffix, sleep } from "../shared/index.js";
|
|
45
|
+
import { buildPermissionRuleset } from "./sessions.js";
|
|
42
46
|
|
|
43
47
|
// ── Client surface ──────────────────────────────────────────────────────────
|
|
44
48
|
|
|
@@ -49,9 +53,8 @@ import { appendBackendSuffix } from "../shared/index.js";
|
|
|
49
53
|
* parameter checking bivariant so both concrete SDK clients satisfy
|
|
50
54
|
* it structurally.
|
|
51
55
|
*/
|
|
52
|
-
export interface RemoteOneShotClient {
|
|
53
|
-
session: {
|
|
54
|
-
create(args: { title: string }): Promise<{ data?: unknown }>;
|
|
56
|
+
export interface RemoteOneShotClient extends RemoteSessionClient {
|
|
57
|
+
session: RemoteSessionClient["session"] & {
|
|
55
58
|
prompt(args: {
|
|
56
59
|
sessionID: string;
|
|
57
60
|
parts: Array<{ type: "text"; text: string }>;
|
|
@@ -84,6 +87,7 @@ export type RemoteOneShotBindings<TClient extends RemoteOneShotClient> = {
|
|
|
84
87
|
buildToolOverrides(
|
|
85
88
|
client: TClient,
|
|
86
89
|
chatServerName: string,
|
|
90
|
+
pluginServerNames?: readonly string[],
|
|
87
91
|
): Promise<Record<string, boolean> | undefined>;
|
|
88
92
|
disconnectChatMcpServer(client: TClient, serverName: string): Promise<void>;
|
|
89
93
|
errMsg(e: unknown): string;
|
|
@@ -117,14 +121,19 @@ export async function runRemoteOneShotAgent<
|
|
|
117
121
|
oc,
|
|
118
122
|
contextLabel,
|
|
119
123
|
);
|
|
120
|
-
await bindings.ensurePluginMcpServers(
|
|
124
|
+
const pluginMcpServerNames = await bindings.ensurePluginMcpServers(
|
|
125
|
+
oc,
|
|
126
|
+
contextLabel,
|
|
127
|
+
);
|
|
121
128
|
const toolOverrides = await bindings.buildToolOverrides(
|
|
122
129
|
oc,
|
|
123
130
|
chatMcpServerName,
|
|
131
|
+
pluginMcpServerNames,
|
|
124
132
|
);
|
|
125
133
|
|
|
126
134
|
const sessionResp = await oc.session.create({
|
|
127
135
|
title: `One-shot ${contextLabel} ${new Date().toISOString()}`,
|
|
136
|
+
permission: buildPermissionRuleset(contextLabel),
|
|
128
137
|
});
|
|
129
138
|
const sessionData = sessionResp.data as Record<string, unknown> | undefined;
|
|
130
139
|
const sessionID =
|
|
@@ -145,6 +154,37 @@ export async function runRemoteOneShotAgent<
|
|
|
145
154
|
);
|
|
146
155
|
};
|
|
147
156
|
abortController.signal.addEventListener("abort", onAbort, { once: true });
|
|
157
|
+
const watchdogAbort = new AbortController();
|
|
158
|
+
const seenQuestionIds = new Set<string>();
|
|
159
|
+
const seenPermissionIds = new Set<string>();
|
|
160
|
+
const interactionWatchdog = (async () => {
|
|
161
|
+
while (!watchdogAbort.signal.aborted) {
|
|
162
|
+
try {
|
|
163
|
+
await Promise.all([
|
|
164
|
+
rejectPendingQuestions(
|
|
165
|
+
oc,
|
|
166
|
+
sessionID,
|
|
167
|
+
contextLabel,
|
|
168
|
+
seenQuestionIds,
|
|
169
|
+
label,
|
|
170
|
+
),
|
|
171
|
+
approvePendingPermissions(
|
|
172
|
+
oc,
|
|
173
|
+
sessionID,
|
|
174
|
+
contextLabel,
|
|
175
|
+
seenPermissionIds,
|
|
176
|
+
label,
|
|
177
|
+
),
|
|
178
|
+
]);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
logWarn(
|
|
181
|
+
"agent",
|
|
182
|
+
`${label} one-shot interaction watchdog failed: ${errMsg(err)}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
await sleep(350, watchdogAbort.signal);
|
|
186
|
+
}
|
|
187
|
+
})();
|
|
148
188
|
|
|
149
189
|
try {
|
|
150
190
|
if (abortController.signal.aborted) {
|
|
@@ -189,7 +229,14 @@ export async function runRemoteOneShotAgent<
|
|
|
189
229
|
}
|
|
190
230
|
} finally {
|
|
191
231
|
abortController.signal.removeEventListener("abort", onAbort);
|
|
192
|
-
|
|
232
|
+
watchdogAbort.abort();
|
|
233
|
+
await interactionWatchdog.catch(() => {});
|
|
234
|
+
await Promise.all([
|
|
235
|
+
bindings.disconnectChatMcpServer(oc, chatMcpServerName),
|
|
236
|
+
...pluginMcpServerNames.map((name) =>
|
|
237
|
+
bindings.disconnectChatMcpServer(oc, name),
|
|
238
|
+
),
|
|
239
|
+
]);
|
|
193
240
|
try {
|
|
194
241
|
await oc.session.delete({ sessionID });
|
|
195
242
|
} catch (err) {
|
|
@@ -72,6 +72,17 @@ export async function resolveProviderID<TClient extends RemoteAgentClient>(
|
|
|
72
72
|
const providerResp = await client.provider.list();
|
|
73
73
|
const providerBuckets =
|
|
74
74
|
(providerResp.data as Record<string, unknown> | undefined) ?? {};
|
|
75
|
+
// Current OpenCode/Kilo shape: `all` contains the provider objects while
|
|
76
|
+
// `connected` is an array of provider ids. Older releases exposed provider
|
|
77
|
+
// objects in named buckets such as `connected` / `configured`. Support both
|
|
78
|
+
// shapes, but always prefer a provider the server says is authenticated.
|
|
79
|
+
const connectedProviderIDs = new Set(
|
|
80
|
+
Array.isArray(providerBuckets.connected)
|
|
81
|
+
? providerBuckets.connected.filter(
|
|
82
|
+
(value): value is string => typeof value === "string",
|
|
83
|
+
)
|
|
84
|
+
: [],
|
|
85
|
+
);
|
|
75
86
|
const guessedProviderID = hooks.guessProviderID(modelID);
|
|
76
87
|
const matches: Array<{ providerID: string; bucketName: string }> = [];
|
|
77
88
|
|
|
@@ -98,6 +109,7 @@ export async function resolveProviderID<TClient extends RemoteAgentClient>(
|
|
|
98
109
|
|
|
99
110
|
if (matches.length > 0) {
|
|
100
111
|
const score = (m: (typeof matches)[0]): number =>
|
|
112
|
+
(connectedProviderIDs.has(m.providerID) ? 0 : 100) +
|
|
101
113
|
(m.providerID === guessedProviderID ? 0 : 2) +
|
|
102
114
|
(m.providerID === "opencode" ? 0 : 1) +
|
|
103
115
|
hooks.getBucketPriority(m.bucketName) * 0.1;
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
* `/status` enrichment).
|
|
26
26
|
* - {@link rejectPendingQuestions} — auto-respond to upstream questions
|
|
27
27
|
* (tool-approval → "always", clarifications → reject).
|
|
28
|
+
* - {@link approvePendingPermissions} — approve upstream permission asks
|
|
29
|
+
* that escaped the session ruleset, preventing headless turns from
|
|
30
|
+
* waiting forever for a TUI response.
|
|
28
31
|
*/
|
|
29
32
|
|
|
30
33
|
import { logWarn } from "../../util/log.js";
|
|
@@ -139,6 +142,13 @@ export interface RemoteSessionClient extends RemoteAgentClient {
|
|
|
139
142
|
}): Promise<unknown>;
|
|
140
143
|
reject(args: { requestID: string }): Promise<unknown>;
|
|
141
144
|
};
|
|
145
|
+
permission: {
|
|
146
|
+
list(): Promise<{ data?: unknown }>;
|
|
147
|
+
reply(args: {
|
|
148
|
+
requestID: string;
|
|
149
|
+
reply: "once" | "always" | "reject";
|
|
150
|
+
}): Promise<unknown>;
|
|
151
|
+
};
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
// ── Local utility ───────────────────────────────────────────────────────────
|
|
@@ -540,3 +550,62 @@ export async function rejectPendingQuestions(
|
|
|
540
550
|
}
|
|
541
551
|
}
|
|
542
552
|
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Approve pending upstream permission requests for this session.
|
|
556
|
+
*
|
|
557
|
+
* Remote agent servers normally resolve these from the permission ruleset
|
|
558
|
+
* installed at session creation. New permission categories can still appear
|
|
559
|
+
* upstream, though, and a headless Talon process has no TUI to answer them.
|
|
560
|
+
* Polling the pending list is therefore a fail-safe: approve each request
|
|
561
|
+
* once, scoped strictly to the current session, and deduplicate until the
|
|
562
|
+
* server removes it from the list.
|
|
563
|
+
*/
|
|
564
|
+
export async function approvePendingPermissions(
|
|
565
|
+
oc: RemoteSessionClient,
|
|
566
|
+
sessionId: string,
|
|
567
|
+
chatId: string,
|
|
568
|
+
seenPermissionIds: Set<string>,
|
|
569
|
+
backendLabel: string,
|
|
570
|
+
): Promise<void> {
|
|
571
|
+
const permissionsResp = await oc.permission.list();
|
|
572
|
+
const pendingPermissions = Array.isArray(permissionsResp.data)
|
|
573
|
+
? permissionsResp.data
|
|
574
|
+
: [];
|
|
575
|
+
|
|
576
|
+
for (const request of pendingPermissions) {
|
|
577
|
+
if (!request || typeof request !== "object") continue;
|
|
578
|
+
|
|
579
|
+
const data = request as {
|
|
580
|
+
id?: string;
|
|
581
|
+
sessionID?: string;
|
|
582
|
+
permission?: string;
|
|
583
|
+
patterns?: string[];
|
|
584
|
+
};
|
|
585
|
+
const requestId = data.id;
|
|
586
|
+
if (!requestId || data.sessionID !== sessionId) continue;
|
|
587
|
+
if (seenPermissionIds.has(requestId)) continue;
|
|
588
|
+
|
|
589
|
+
seenPermissionIds.add(requestId);
|
|
590
|
+
try {
|
|
591
|
+
await oc.permission.reply({ requestID: requestId, reply: "once" });
|
|
592
|
+
const detail = [
|
|
593
|
+
data.permission,
|
|
594
|
+
...(Array.isArray(data.patterns) ? data.patterns : []),
|
|
595
|
+
]
|
|
596
|
+
.filter(Boolean)
|
|
597
|
+
.join(" ");
|
|
598
|
+
logWarn(
|
|
599
|
+
"agent",
|
|
600
|
+
`[${chatId}] Auto-approved ${backendLabel} permission ${requestId}${
|
|
601
|
+
detail ? `: ${detail}` : ""
|
|
602
|
+
}`,
|
|
603
|
+
);
|
|
604
|
+
} catch (err) {
|
|
605
|
+
logWarn(
|
|
606
|
+
"agent",
|
|
607
|
+
`[${chatId}] Failed to approve ${backendLabel} permission ${requestId}: ${errMsg(err)}`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|