talon-agent 3.15.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.
Files changed (31) 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/callbacks/model.ts +48 -0
  29. package/src/frontend/telegram/commands/settings.ts +8 -2
  30. package/src/frontend/telegram/helpers/menu.ts +5 -2
  31. package/src/util/respawn.ts +66 -38
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.15.0",
3
+ "version": "3.15.1",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
package/src/app.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  import { shutdownTriggers } from "./core/background/triggers/index.js";
25
25
  import { pruneSettledTriggers } from "./storage/trigger-store.js";
26
26
  import { startWatchdog, stopWatchdog } from "./util/watchdog.js";
27
+ import { spawnSuccessor } from "./util/respawn.js";
27
28
  import { log, logError, logWarn } from "./util/log.js";
28
29
  import {
29
30
  getVfs,
@@ -121,6 +122,14 @@ async function gracefulShutdown(signal: string): Promise<void> {
121
122
 
122
123
  const forceTimer = setTimeout(() => {
123
124
  logError("shutdown", "Timeout exceeded, forcing exit");
125
+ // Hand off even on the forced path. A restart must survive a
126
+ // subsystem that won't stop (a wedged FUSE unmount, an MCP server
127
+ // ignoring SIGTERM, a backend child that never acks): without this
128
+ // the timeout exits without a successor and `/restart` silently
129
+ // takes the daemon down for good. The successor may briefly race
130
+ // the long-poll we failed to release, but grammy retries the 409 —
131
+ // a few seconds of overlap beats staying down.
132
+ spawnSuccessor();
124
133
  process.exit(1);
125
134
  }, SHUTDOWN_TIMEOUT_MS);
126
135
  forceTimer.unref();
@@ -148,13 +157,14 @@ async function gracefulShutdown(signal: string): Promise<void> {
148
157
  await shutdownStep("frontends", () =>
149
158
  Promise.allSettled(frontends.map((frontend) => frontend.stop())),
150
159
  );
151
- if (config.backend === "opencode") {
152
- await shutdownStep("opencode server", async () => {
153
- const { stopOpenCodeServer } =
154
- await import("./backend/opencode/index.js");
155
- stopOpenCodeServer();
156
- });
157
- }
160
+ // Tear down every instantiated backend, including per-chat overrides.
161
+ // Checking only config.backend orphaned an OpenCode child whenever the
162
+ // process default was Claude but one chat had switched to OpenCode.
163
+ await shutdownStep("backend pool", async () => {
164
+ const { cleanupBackendPool } =
165
+ await import("./core/engine/backend-controller/index.js");
166
+ await cleanupBackendPool();
167
+ });
158
168
  // Destroy plugins (cleanup resources)
159
169
  if (config.plugins.length > 0) {
160
170
  await shutdownStep("plugins", async () => {
@@ -181,11 +191,15 @@ async function gracefulShutdown(signal: string): Promise<void> {
181
191
  await shutdownHub();
182
192
  });
183
193
  flushDatabase();
184
- // Guarded removal: after a /restart handoff the successor has already
185
- // written its own pid here — deleting unconditionally would orphan it
186
- // (the bug that made `talon restart` spawn duplicate daemons).
194
+ // Guarded removal: only clear the record if it still names us. A
195
+ // successor that raced ahead and wrote its own pid here must not be
196
+ // orphaned (the bug that made `talon restart` spawn duplicate daemons).
187
197
  removePidRecordIfOwnedBy(process.pid);
188
198
  log("shutdown", "State saved");
199
+ // Hand off last: the frontends are stopped, so the successor binds
200
+ // Telegram's long-poll only after we have released it. No-op unless
201
+ // /restart or /update armed a respawn.
202
+ spawnSuccessor();
189
203
  process.exit(0);
190
204
  }
191
205
 
@@ -19,6 +19,9 @@ import {
19
19
  type ChatBackend,
20
20
  type BackgroundRunner,
21
21
  type ModelCatalog,
22
+ type SessionBackend,
23
+ type SystemControl,
24
+ type ToolRuntime,
22
25
  type UsageTelemetry,
23
26
  } from "../../core/agent-runtime/capabilities.js";
24
27
 
@@ -35,6 +38,9 @@ import {
35
38
  getProviderModels as kiloGetProviderModels,
36
39
  formatModelError as kiloFormatModelError,
37
40
  listModels as kiloListModels,
41
+ refreshPluginMcpServers as kiloRefreshPluginMcpServers,
42
+ updateSystemPrompt as kiloUpdateSystemPrompt,
43
+ warmSession as kiloWarmSession,
38
44
  } from "./index.js";
39
45
 
40
46
  const kiloFactory: BackendFactory = {
@@ -102,6 +108,23 @@ const kiloFactory: BackendFactory = {
102
108
  },
103
109
  };
104
110
 
111
+ const tools: ToolRuntime = {
112
+ refreshTools: (chatId) => kiloRefreshPluginMcpServers(chatId),
113
+ };
114
+
115
+ // Session state lives on the Kilo server, and `/reset` already clears
116
+ // Talon's stored id centrally (storage/sessions.ts), so the next turn
117
+ // creates a fresh one — no `resetChat` needed. `warmSession` front-loads
118
+ // that creation plus the plugin-MCP sweep, matching what the Claude
119
+ // backend does after a reset.
120
+ const sessions: SessionBackend = {
121
+ warmSession: (chatId) => kiloWarmSession(chatId),
122
+ };
123
+
124
+ const control: SystemControl = {
125
+ updateSystemPrompt: (prompt) => kiloUpdateSystemPrompt(prompt),
126
+ };
127
+
105
128
  const backend = composeBackend({
106
129
  id: "kilo",
107
130
  label: "Kilo",
@@ -109,7 +132,10 @@ const kiloFactory: BackendFactory = {
109
132
  chat,
110
133
  background,
111
134
  models,
135
+ sessions,
136
+ tools,
112
137
  usage,
138
+ control,
113
139
  });
114
140
 
115
141
  return {
@@ -23,6 +23,7 @@ import {
23
23
  ensureSession,
24
24
  ensureChatMcpServer,
25
25
  ensurePluginMcpServers,
26
+ buildToolOverrides,
26
27
  resolveProviderID,
27
28
  parseStoredKiloModelSelection,
28
29
  getConfig,
@@ -86,12 +87,13 @@ export async function handleMessage(
86
87
  (selectedProviderID ? "" : " (provider via catalog lookup)"),
87
88
  );
88
89
  const sessionId = await ensureSession(oc, chatId);
89
- await ensureChatMcpServer(oc, chatId);
90
- await ensurePluginMcpServers(oc, chatId);
91
- // Note: we deliberately don't pass `tools` to promptAsync. The session
92
- // was created with a `permission` ruleset (ensureSession in server.ts)
93
- // that allow-lists this chat's MCP tools and auto-allows built-in
94
- // read/bash/edit. The deprecated `tools` map is subsumed by that.
90
+ const chatMcpServerName = await ensureChatMcpServer(oc, chatId);
91
+ const pluginMcpServerNames = await ensurePluginMcpServers(oc, chatId);
92
+ const toolOverrides = await buildToolOverrides(
93
+ oc,
94
+ chatMcpServerName,
95
+ pluginMcpServerNames,
96
+ );
95
97
 
96
98
  // Build the prompt (time tag + sender + msg_id reference)
97
99
  const prompt = formatUserPrompt({
@@ -132,6 +134,7 @@ export async function handleMessage(
132
134
  });
133
135
  const promptStartedAt = Date.now();
134
136
  const seenQuestionIds = new Set<string>();
137
+ const seenPermissionIds = new Set<string>();
135
138
  const seenToolCallIds = new Set<string>();
136
139
 
137
140
  const setupMs = Date.now() - t0;
@@ -155,7 +158,9 @@ export async function handleMessage(
155
158
  state,
156
159
  chatId,
157
160
  seenQuestionIds,
161
+ seenPermissionIds,
158
162
  seenToolCallIds,
163
+ toolOverrides,
159
164
  onStreamDelta: undefined,
160
165
  onTextBlock,
161
166
  onToolUse,
@@ -203,6 +208,7 @@ export async function handleMessage(
203
208
  // Note: we deliberately do NOT disconnect the chat MCP server here.
204
209
  // The server is named per-chat so it's safe to keep across turns;
205
210
  // re-spawning the subprocess each turn cost ~800ms per message.
211
+ // Per-prompt overrides isolate visibility across concurrent chats.
206
212
  }
207
213
 
208
214
  // ── Post-loop accounting ──────────────────────────────────────────────────
@@ -4,13 +4,14 @@
4
4
  * `runKiloTurn` subscribes to SSE before issuing the prompt (so no early
5
5
  * events are lost), fires `promptAsync`, awaits the SSE close, then reads the
6
6
  * authoritative parts list. `subscribeToTurnEvents` translates relevant SSE
7
- * events into shared stream-state mutations and fires the terminator abort.
7
+ * events into shared stream-state mutations and observes natural turn idle.
8
8
  */
9
9
 
10
10
  import type { KiloClient } from "@kilocode/sdk/v2";
11
11
  import { logWarn } from "../../../util/log.js";
12
12
  import { errMsg } from "../server.js";
13
13
  import {
14
+ approvePendingPermissions,
14
15
  extractAssistantUsage,
15
16
  rejectPendingQuestions,
16
17
  type KiloAssistantInfo,
@@ -18,6 +19,7 @@ import {
18
19
  import { createStreamState, recordTokens, sleep } from "../../shared/index.js";
19
20
  import { processStreamEvent, finalizePartsIntoState } from "../events.js";
20
21
  import { subscribeSseStream } from "../../remote-server/sse-stream.js";
22
+ import { awaitRemoteTurn } from "../../remote-server/turn-timeout.js";
21
23
  import { findLastAssistantMessage as findLastAssistantMessageShared } from "../../remote-server/messages.js";
22
24
 
23
25
  export interface RunKiloTurnInputs {
@@ -30,7 +32,9 @@ export interface RunKiloTurnInputs {
30
32
  state: ReturnType<typeof createStreamState>;
31
33
  chatId: string;
32
34
  seenQuestionIds: Set<string>;
35
+ seenPermissionIds: Set<string>;
33
36
  seenToolCallIds: Set<string>;
37
+ toolOverrides?: Record<string, boolean>;
34
38
  onStreamDelta?: (accumulated: string, phase?: "thinking" | "text") => void;
35
39
  onTextBlock?: (text: string) => Promise<void>;
36
40
  onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
@@ -61,7 +65,9 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
61
65
  state,
62
66
  chatId,
63
67
  seenQuestionIds,
68
+ seenPermissionIds,
64
69
  seenToolCallIds,
70
+ toolOverrides,
65
71
  onStreamDelta,
66
72
  onTextBlock,
67
73
  onToolUse,
@@ -80,27 +86,18 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
80
86
  onStreamDelta,
81
87
  onTextBlock,
82
88
  onToolUse,
83
- onTerminator: async () => {
84
- // End_turn fired — abort the in-flight session so Kilo doesn't
85
- // burn another round-trip "wrapping up" after the model declared
86
- // done. session.idle then fires for our session and the SSE
87
- // iterator exits cleanly.
88
- try {
89
- await oc.session.abort({ sessionID: sessionId });
90
- } catch (err) {
91
- logWarn("agent", `[${chatId}] session.abort failed: ${errMsg(err)}`);
92
- }
93
- },
94
89
  abortSignal: sseAbort.signal,
95
90
  });
96
91
 
97
- // Question watchdog: Talon manages its own tool permissions, so any
98
- // Kilo-side question (tool approval, clarification) is rejected
99
- // automatically.
92
+ // Headless-interaction watchdog: resolve upstream questions and any
93
+ // permission requests that escaped the session ruleset.
100
94
  const questionWatchdog = (async () => {
101
95
  while (!sseAbort.signal.aborted) {
102
96
  try {
103
- await rejectPendingQuestions(oc, sessionId, chatId, seenQuestionIds);
97
+ await Promise.all([
98
+ rejectPendingQuestions(oc, sessionId, chatId, seenQuestionIds),
99
+ approvePendingPermissions(oc, sessionId, chatId, seenPermissionIds),
100
+ ]);
104
101
  } catch (err) {
105
102
  logWarn(
106
103
  "agent",
@@ -113,18 +110,23 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
113
110
 
114
111
  try {
115
112
  // Fire and forget — promptAsync returns immediately. The await below
116
- // is on the SSE close event. No `tools` field: the deprecated
117
- // per-prompt tool override map is merged into session-level
118
- // `permission` rules set in ensureSession.
119
- await oc.session.promptAsync({
120
- sessionID: sessionId,
121
- parts: [{ type: "text", text: prompt }],
122
- model: { providerID, modelID },
123
- system: systemPrompt,
124
- });
113
+ // is on the SSE close event. Per-prompt overrides hide sibling chats'
114
+ // MCP tools while session permissions independently deny execution.
115
+ await awaitRemoteTurn(
116
+ (async () => {
117
+ await oc.session.promptAsync({
118
+ sessionID: sessionId,
119
+ parts: [{ type: "text", text: prompt }],
120
+ model: { providerID, modelID },
121
+ system: systemPrompt,
122
+ ...(toolOverrides ? { tools: toolOverrides } : {}),
123
+ });
125
124
 
126
- // Await turn completion via SSE.
127
- await sseDone;
125
+ // Await turn completion via SSE.
126
+ await sseDone;
127
+ })(),
128
+ { client: oc, sessionId, chatId, label: "Kilo" },
129
+ );
128
130
 
129
131
  // Read authoritative final state from the messages endpoint. The SSE
130
132
  // handler already populated state mid-flight; this re-reads to fill
@@ -156,17 +158,23 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
156
158
  }
157
159
  }
158
160
  } catch (err) {
159
- // If the model called end_turn we aborted intentionally — swallow.
161
+ // Explicit user interrupts abort intentionally — swallow that close path.
160
162
  if (state.turnTerminated && /abort/i.test(errMsg(err))) {
161
163
  return;
162
164
  }
163
165
  throw err;
164
166
  } finally {
165
167
  sseAbort.abort();
166
- await sseDone.catch(() => {});
168
+ // A dead SSE socket may ignore the local abort flag until another event
169
+ // arrives. Bound cleanup so a timed-out turn cannot wedge its caller in
170
+ // the finally block it was meant to escape.
171
+ await Promise.race([sseDone.catch(() => {}), sleep(1_000)]);
167
172
  await questionWatchdog.catch(() => {});
168
173
  try {
169
- await rejectPendingQuestions(oc, sessionId, chatId, seenQuestionIds);
174
+ await Promise.all([
175
+ rejectPendingQuestions(oc, sessionId, chatId, seenQuestionIds),
176
+ approvePendingPermissions(oc, sessionId, chatId, seenPermissionIds),
177
+ ]);
170
178
  } catch {
171
179
  /* noop */
172
180
  }
@@ -194,7 +202,6 @@ interface SubscribeInputs {
194
202
  onStreamDelta?: (accumulated: string, phase?: "thinking" | "text") => void;
195
203
  onTextBlock?: (text: string) => Promise<void>;
196
204
  onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
197
- onTerminator: () => Promise<void>;
198
205
  abortSignal: AbortSignal;
199
206
  }
200
207
 
@@ -214,7 +221,6 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
214
221
  onStreamDelta,
215
222
  onTextBlock,
216
223
  onToolUse,
217
- onTerminator,
218
224
  abortSignal,
219
225
  } = inputs;
220
226
 
@@ -222,7 +228,7 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
222
228
  // subscribe-failed warning. Returns `undefined` for both "subscribe
223
229
  // rejected" and "no iterable in the response shape".
224
230
  const stream = await subscribeSseStream(oc, chatId);
225
- if (!stream) return;
231
+ if (!stream) throw new Error("Kilo SSE connection unavailable");
226
232
 
227
233
  try {
228
234
  for await (const evt of stream) {
@@ -260,10 +266,7 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
260
266
  data?: Record<string, unknown>;
261
267
  }
262
268
  | undefined;
263
- // MessageAbortedError is OUR own abort signal — fired when a
264
- // terminator tool led us to call `oc.session.abort` to
265
- // short-circuit the model's wrap-up. It's the EXPECTED close
266
- // path, not an upstream failure.
269
+ // MessageAbortedError is expected for an explicit user interrupt.
267
270
  const isOurAbort =
268
271
  state.turnTerminated &&
269
272
  (errProp?.name === "MessageAbortedError" ||
@@ -301,9 +304,9 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
301
304
  if (outcome.kind === "terminator_fired") {
302
305
  // tool_calls counter increment happens per-tool inside
303
306
  // events.ts processPartUpdate. Don't double-count here.
304
- // Fire-and-forget — abort the session so the model's post-end_turn
305
- // wrap-up doesn't burn another API call.
306
- onTerminator().catch(() => {});
307
+ // Aborting here can race with a prompt accepted immediately after
308
+ // this turn settles and cancel that next turn on the reused session.
309
+ // Delivery is already complete, so wait for natural idle instead.
307
310
  continue;
308
311
  }
309
312
 
@@ -41,7 +41,13 @@ export {
41
41
  } from "./sessions.js";
42
42
 
43
43
  // ── Server / lifecycle ─────────────────────────────────────────────────────
44
- export { initKiloAgent, stopKiloServer } from "./server.js";
44
+ export {
45
+ initKiloAgent,
46
+ stopKiloServer,
47
+ refreshPluginMcpServers,
48
+ updateSystemPrompt,
49
+ warmSession,
50
+ } from "./server.js";
45
51
 
46
52
  // ── Handler ────────────────────────────────────────────────────────────────
47
53
  export { handleMessage } from "./handler/index.js";
@@ -23,7 +23,6 @@ import {
23
23
  } from "@kilocode/sdk/v2";
24
24
  import type { TalonConfig } from "../../util/config.js";
25
25
  import type { FrontendName } from "../../core/agent-runtime/backend-registry.js";
26
- import { logWarn } from "../../util/log.js";
27
26
  import { buildDeliveryContract } from "../shared/delivery-contract.js";
28
27
  import {
29
28
  guessProviderID,
@@ -39,7 +38,9 @@ import {
39
38
  ensurePluginMcpServers as ensurePluginMcpServersShared,
40
39
  buildToolOverrides as buildToolOverridesShared,
41
40
  disconnectChatMcpServer as disconnectChatMcpServerShared,
41
+ refreshPluginMcpServers as refreshPluginMcpServersShared,
42
42
  ensureRemoteSession,
43
+ warmRemoteSession,
43
44
  resolveProviderID as resolveProviderIDShared,
44
45
  getRegisteredMcpServerNames as getRegisteredMcpServerNamesShared,
45
46
  errMsg as sharedErrMsg,
@@ -121,35 +122,6 @@ export function initKiloAgent(
121
122
  state.config = cfg;
122
123
  if (getGatewayPort) state.gatewayPortFn = getGatewayPort;
123
124
  if (frontend) state.frontendName = frontend;
124
-
125
- // Pre-warm plugin MCP servers in the background so the first chat
126
- // message doesn't pay the ~12s subprocess-spawn cost. We don't pre-warm
127
- // chat-namespaced servers (those depend on chatId, not known yet); the
128
- // first turn for any chat still incurs ~800ms for that one server, but
129
- // the dominant cost (16+ plugin servers in series) is amortised away.
130
- // Errors are swallowed — pre-warm is best-effort, the per-turn ensure
131
- // still runs and would log any real failures.
132
- prewarmPluginMcpServers().catch((err) => {
133
- logWarn(
134
- "agent",
135
- `Plugin MCP pre-warm failed (non-fatal): ${sharedErrMsg(err)}`,
136
- );
137
- });
138
- }
139
-
140
- /**
141
- * Background pre-warm of plugin MCP servers. Connects each
142
- * plugin-provided MCP server to the Kilo HTTP server eagerly so the
143
- * first turn doesn't spend 12+ seconds spawning subprocesses in
144
- * series.
145
- */
146
- async function prewarmPluginMcpServers(): Promise<void> {
147
- const client = await ensureServer();
148
- // Sentinel chat id so plugin MCP servers don't bind their bridge calls
149
- // to a real chat (those calls would fail the gateway's active-context
150
- // check anyway). Plugin tools that need a real chat context get
151
- // re-bound when a chat actually starts.
152
- await ensurePluginMcpServers(client, "prewarm");
153
125
  }
154
126
 
155
127
  /**
@@ -226,8 +198,9 @@ export function ensurePluginMcpServers(
226
198
  export function buildToolOverrides(
227
199
  oc: KiloClient,
228
200
  chatServerName: string,
201
+ pluginServerNames: readonly string[] = [],
229
202
  ): Promise<Record<string, boolean> | undefined> {
230
- return buildToolOverridesShared(oc, state, chatServerName);
203
+ return buildToolOverridesShared(oc, state, chatServerName, pluginServerNames);
231
204
  }
232
205
 
233
206
  /** Disconnect a per-chat MCP server (explicit teardown for hot-swap paths). */
@@ -238,6 +211,15 @@ export function disconnectChatMcpServer(
238
211
  return disconnectChatMcpServerShared(oc, state, serverName);
239
212
  }
240
213
 
214
+ export async function refreshPluginMcpServers(chatId: string) {
215
+ const oc = await ensureServer();
216
+ return refreshPluginMcpServersShared(oc, state, chatId);
217
+ }
218
+
219
+ export function updateSystemPrompt(prompt: string): void {
220
+ if (state.config) state.config.systemPrompt = prompt;
221
+ }
222
+
241
223
  // ── Session management ─────────────────────────────────────────────────────
242
224
 
243
225
  /**
@@ -249,6 +231,20 @@ export function ensureSession(oc: KiloClient, chatId: string): Promise<string> {
249
231
  return ensureRemoteSession(oc, state, chatId);
250
232
  }
251
233
 
234
+ /**
235
+ * Front-load a chat's cold start after `/reset`. Mirrors the Claude
236
+ * backend's `warmSession` so the `sessions` capability slot behaves the
237
+ * same across backends. Never throws — see `warmRemoteSession`.
238
+ */
239
+ export function warmSession(chatId: string): Promise<void> {
240
+ return warmRemoteSession(state, chatId, {
241
+ ensureServer,
242
+ ensureSession,
243
+ ensureChatMcpServer,
244
+ ensurePluginMcpServers,
245
+ });
246
+ }
247
+
252
248
  // ── Provider resolution ────────────────────────────────────────────────────
253
249
 
254
250
  /**
@@ -312,7 +308,7 @@ export function getConfig(): TalonConfig {
312
308
  /**
313
309
  * Snapshot of the locally-cached MCP server registrations. Test-only:
314
310
  * Kilo's `GET /mcp` returns `{}` regardless of state, so integration
315
- * tests need this to assert chat-switch isolation actually fired.
311
+ * tests use this to assert concurrent chat registrations are retained.
316
312
  */
317
313
  export function getRegisteredMcpServerNames(): string[] {
318
314
  return getRegisteredMcpServerNamesShared(state);
@@ -20,6 +20,7 @@ import {
20
20
  getTurnSummary,
21
21
  getSessionSnapshot,
22
22
  rejectPendingQuestions as rejectPendingQuestionsShared,
23
+ approvePendingPermissions as approvePendingPermissionsShared,
23
24
  type RemoteAssistantInfo,
24
25
  type RemoteSessionSnapshot,
25
26
  type RemoteSessionClient,
@@ -99,3 +100,19 @@ export function rejectPendingQuestions(
99
100
  "Kilo",
100
101
  );
101
102
  }
103
+
104
+ /** Auto-approve pending Kilo permissions for this headless session. */
105
+ export function approvePendingPermissions(
106
+ oc: KiloClient,
107
+ sessionId: string,
108
+ chatId: string,
109
+ seenPermissionIds: Set<string>,
110
+ ): Promise<void> {
111
+ return approvePendingPermissionsShared(
112
+ oc as unknown as RemoteSessionClient,
113
+ sessionId,
114
+ chatId,
115
+ seenPermissionIds,
116
+ "Kilo",
117
+ );
118
+ }
@@ -19,6 +19,9 @@ import {
19
19
  type ChatBackend,
20
20
  type BackgroundRunner,
21
21
  type ModelCatalog,
22
+ type SessionBackend,
23
+ type SystemControl,
24
+ type ToolRuntime,
22
25
  type UsageTelemetry,
23
26
  } from "../../core/agent-runtime/capabilities.js";
24
27
 
@@ -35,6 +38,9 @@ import {
35
38
  getProviderModels as ocGetProviderModels,
36
39
  formatModelError as ocFormatModelError,
37
40
  listModels as ocListModels,
41
+ refreshPluginMcpServers as ocRefreshPluginMcpServers,
42
+ updateSystemPrompt as ocUpdateSystemPrompt,
43
+ warmSession as ocWarmSession,
38
44
  } from "./index.js";
39
45
 
40
46
  const opencodeFactory: BackendFactory = {
@@ -95,6 +101,23 @@ const opencodeFactory: BackendFactory = {
95
101
  },
96
102
  };
97
103
 
104
+ const tools: ToolRuntime = {
105
+ refreshTools: (chatId) => ocRefreshPluginMcpServers(chatId),
106
+ };
107
+
108
+ // Session state lives on the OpenCode server, and `/reset` already
109
+ // clears Talon's stored id centrally (storage/sessions.ts), so the
110
+ // next turn creates a fresh one — no `resetChat` needed. `warmSession`
111
+ // front-loads that creation plus the plugin-MCP sweep, matching what
112
+ // the Claude backend does after a reset.
113
+ const sessions: SessionBackend = {
114
+ warmSession: (chatId) => ocWarmSession(chatId),
115
+ };
116
+
117
+ const control: SystemControl = {
118
+ updateSystemPrompt: (prompt) => ocUpdateSystemPrompt(prompt),
119
+ };
120
+
98
121
  const backend = composeBackend({
99
122
  id: "opencode",
100
123
  label: "OpenCode",
@@ -102,7 +125,10 @@ const opencodeFactory: BackendFactory = {
102
125
  chat,
103
126
  background,
104
127
  models,
128
+ sessions,
129
+ tools,
105
130
  usage,
131
+ control,
106
132
  });
107
133
 
108
134
  return {
@@ -24,6 +24,7 @@ import {
24
24
  ensureSession,
25
25
  ensureChatMcpServer,
26
26
  ensurePluginMcpServers,
27
+ buildToolOverrides,
27
28
  resolveProviderID,
28
29
  parseStoredOpenCodeModelSelection,
29
30
  getConfig,
@@ -87,8 +88,13 @@ export async function handleMessage(
87
88
  (selectedProviderID ? "" : " (provider via catalog lookup)"),
88
89
  );
89
90
  const sessionId = await ensureSession(oc, chatId);
90
- await ensureChatMcpServer(oc, chatId);
91
- await ensurePluginMcpServers(oc, chatId);
91
+ const chatMcpServerName = await ensureChatMcpServer(oc, chatId);
92
+ const pluginMcpServerNames = await ensurePluginMcpServers(oc, chatId);
93
+ const toolOverrides = await buildToolOverrides(
94
+ oc,
95
+ chatMcpServerName,
96
+ pluginMcpServerNames,
97
+ );
92
98
 
93
99
  // Build the prompt (time tag + sender + msg_id reference)
94
100
  const prompt = formatUserPrompt({
@@ -129,6 +135,7 @@ export async function handleMessage(
129
135
  });
130
136
  const promptStartedAt = Date.now();
131
137
  const seenQuestionIds = new Set<string>();
138
+ const seenPermissionIds = new Set<string>();
132
139
  const seenToolCallIds = new Set<string>();
133
140
 
134
141
  const setupMs = Date.now() - t0;
@@ -152,7 +159,9 @@ export async function handleMessage(
152
159
  state,
153
160
  chatId,
154
161
  seenQuestionIds,
162
+ seenPermissionIds,
155
163
  seenToolCallIds,
164
+ toolOverrides,
156
165
  onStreamDelta: undefined,
157
166
  onTextBlock,
158
167
  onToolUse,
@@ -202,9 +211,8 @@ export async function handleMessage(
202
211
  }
203
212
  // Note: we deliberately do NOT disconnect the chat MCP server here.
204
213
  // The server is named per-chat so it's safe to keep across turns;
205
- // re-spawning the subprocess each turn cost ~800ms per message. The
206
- // chat-switch disconnect dance (remote-server/mcp.ts) handles
207
- // visibility scoping when the active chat changes.
214
+ // re-spawning the subprocess each turn cost ~800ms per message.
215
+ // Per-prompt overrides isolate visibility across concurrent chats.
208
216
  }
209
217
 
210
218
  // ── Post-loop accounting ──────────────────────────────────────────────────