talon-agent 3.15.0 → 3.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/app.ts +24 -10
  3. package/src/backend/kilo/factory.ts +26 -0
  4. package/src/backend/kilo/handler/message.ts +12 -6
  5. package/src/backend/kilo/handler/turn.ts +43 -40
  6. package/src/backend/kilo/index.ts +7 -1
  7. package/src/backend/kilo/server.ts +28 -32
  8. package/src/backend/kilo/sessions.ts +17 -0
  9. package/src/backend/opencode/factory.ts +26 -0
  10. package/src/backend/opencode/handler/message.ts +13 -5
  11. package/src/backend/opencode/handler/turn.ts +41 -32
  12. package/src/backend/opencode/index.ts +7 -1
  13. package/src/backend/opencode/server.ts +27 -16
  14. package/src/backend/opencode/sessions.ts +17 -0
  15. package/src/backend/remote-server/client.ts +1 -1
  16. package/src/backend/remote-server/events.ts +19 -6
  17. package/src/backend/remote-server/index.ts +16 -1
  18. package/src/backend/remote-server/lifecycle.ts +5 -1
  19. package/src/backend/remote-server/mcp.ts +196 -110
  20. package/src/backend/remote-server/one-shot.ts +53 -6
  21. package/src/backend/remote-server/providers.ts +12 -0
  22. package/src/backend/remote-server/session-helpers.ts +69 -0
  23. package/src/backend/remote-server/sessions.ts +67 -7
  24. package/src/backend/remote-server/sse-stream.ts +17 -10
  25. package/src/backend/remote-server/state.ts +6 -0
  26. package/src/backend/remote-server/turn-timeout.ts +71 -0
  27. package/src/core/mcp-hub/index.ts +22 -0
  28. package/src/frontend/telegram/admin.ts +3 -1
  29. package/src/frontend/telegram/callbacks/model.ts +48 -0
  30. package/src/frontend/telegram/commands/info.ts +9 -3
  31. package/src/frontend/telegram/commands/settings.ts +8 -2
  32. package/src/frontend/telegram/handlers/queue.ts +10 -4
  33. package/src/frontend/telegram/helpers/menu.ts +5 -2
  34. 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. Per-session permission rules
8
- * only block *execution*, not *visibility* — which is why Talon has to
9
- * disconnect rival chat-namespaced MCP servers before connecting the
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, disconnecting any other chat's
16
- * server in the process. Returns the registered name so callers can
17
- * scope tool overrides to this chat alone.
18
- * - {@link ensurePluginMcpServers} — bulk-register all plugin-provided
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. The local cache
33
- * skips ~16 redundant POSTs per turn (≈12s of wasted setup) for the
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.replace(/[^a-zA-Z0-9_-]+/g, "_") || "chat";
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
- * so concurrent chats can't see each other's tool environment. Before
115
- * connecting the current chat's server we DISCONNECT every other chat's
116
- * server (except the heartbeat sentinel) — this is the only way to hide
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 (mempalace, brave-search, github, …) expose MCP servers via
209
- * `getPluginMcpServers`. Each is registered under its plugin-provided
210
- * name so the model can see them in the tool catalog. Already-connected
211
- * servers are skipped via the local cache (their `name → process` mapping
212
- * survives the entire Talon process; only `stopRemoteServer` clears it).
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 registered: string[] = [];
217
+ const byPlugin =
218
+ state.pluginMcpServersByChat.get(chatId) ?? new Map<string, string>();
219
+ state.pluginMcpServersByChat.set(chatId, byPlugin);
225
220
 
226
- for (const name of names) {
227
- if (state.registeredMcpServers.has(name)) {
228
- registered.push(name);
229
- continue;
230
- }
231
- try {
232
- const startedAt = Date.now();
233
- await client.mcp.add({
234
- name,
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
- return registered;
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
- * Returns `undefined` when no chat-scoped tools matched (e.g. MCP
271
- * registration failed silently above) — caller should skip passing
272
- * the `tools` field in that case.
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" || !isTalonToolID(toolId)) continue;
288
-
289
- const enabled = toolId.startsWith(chatToolPrefix);
290
- overrides[toolId] = enabled;
291
- matchedChatTool ||= enabled;
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. Called in handler `finally` blocks
306
- * to release the chat-namespaced MCP subprocess once the turn ends.
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
- * Note: Kilo's handler deliberately does NOT call this in its
314
- * per-turn `finally` (the disconnect-others-then-add dance in
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 chat-switch isolation actually fired (the
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(oc, contextLabel);
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
- await bindings.disconnectChatMcpServer(oc, chatMcpServerName);
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
+ }