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.
Files changed (33) 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/shared/status-context.ts +161 -0
  29. package/src/frontend/telegram/callbacks/model.ts +48 -0
  30. package/src/frontend/telegram/commands/settings.ts +8 -2
  31. package/src/frontend/telegram/helpers/menu.ts +5 -2
  32. package/src/frontend/terminal/commands.ts +152 -0
  33. package/src/util/respawn.ts +66 -38
@@ -4,7 +4,7 @@
4
4
  * `runOpenCodeTurn` 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
  * The streaming + event-processing logic is shared with the Kilo backend via
10
10
  * `backend/remote-server/events.ts`.
@@ -14,6 +14,7 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2";
14
14
  import { logWarn } from "../../../util/log.js";
15
15
  import { errMsg } from "../server.js";
16
16
  import {
17
+ approvePendingPermissions,
17
18
  extractPartsSummary,
18
19
  extractAssistantUsage,
19
20
  rejectPendingQuestions,
@@ -25,6 +26,7 @@ import {
25
26
  finalizePartsIntoState,
26
27
  } from "../../remote-server/events.js";
27
28
  import { subscribeSseStream } from "../../remote-server/sse-stream.js";
29
+ import { awaitRemoteTurn } from "../../remote-server/turn-timeout.js";
28
30
  import { findLastAssistantMessage as findLastAssistantMessageShared } from "../../remote-server/messages.js";
29
31
 
30
32
  export interface RunOpenCodeTurnInputs {
@@ -37,7 +39,9 @@ export interface RunOpenCodeTurnInputs {
37
39
  state: ReturnType<typeof createStreamState>;
38
40
  chatId: string;
39
41
  seenQuestionIds: Set<string>;
42
+ seenPermissionIds: Set<string>;
40
43
  seenToolCallIds: Set<string>;
44
+ toolOverrides?: Record<string, boolean>;
41
45
  onStreamDelta?: (accumulated: string, phase?: "thinking" | "text") => void;
42
46
  onTextBlock?: (text: string) => Promise<void>;
43
47
  onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
@@ -62,7 +66,9 @@ export async function runOpenCodeTurn(
62
66
  state,
63
67
  chatId,
64
68
  seenQuestionIds,
69
+ seenPermissionIds,
65
70
  seenToolCallIds,
71
+ toolOverrides,
66
72
  onStreamDelta,
67
73
  onTextBlock,
68
74
  onToolUse,
@@ -81,26 +87,18 @@ export async function runOpenCodeTurn(
81
87
  onStreamDelta,
82
88
  onTextBlock,
83
89
  onToolUse,
84
- onTerminator: async () => {
85
- // End_turn fired — abort the in-flight session so OpenCode doesn't
86
- // burn another round-trip "wrapping up" after the model declared
87
- // done. session.idle then fires for our session and the SSE
88
- // iterator exits cleanly.
89
- try {
90
- await oc.session.abort({ sessionID: sessionId });
91
- } catch (err) {
92
- logWarn("agent", `[${chatId}] session.abort failed: ${errMsg(err)}`);
93
- }
94
- },
95
90
  abortSignal: sseAbort.signal,
96
91
  });
97
92
 
98
- // Question watchdog: Talon manages its own tool permissions, so any
99
- // upstream-side question (tool approval, clarification) is auto-handled.
93
+ // Headless-interaction watchdog: resolve upstream questions and any
94
+ // permission requests that escaped the session ruleset.
100
95
  const questionWatchdog = (async () => {
101
96
  while (!sseAbort.signal.aborted) {
102
97
  try {
103
- await rejectPendingQuestions(oc, sessionId, chatId, seenQuestionIds);
98
+ await Promise.all([
99
+ rejectPendingQuestions(oc, sessionId, chatId, seenQuestionIds),
100
+ approvePendingPermissions(oc, sessionId, chatId, seenPermissionIds),
101
+ ]);
104
102
  } catch (err) {
105
103
  logWarn(
106
104
  "agent",
@@ -114,15 +112,21 @@ export async function runOpenCodeTurn(
114
112
  try {
115
113
  // Fire and forget — promptAsync returns immediately. The await below
116
114
  // is on the SSE close event.
117
- await oc.session.promptAsync({
118
- sessionID: sessionId,
119
- parts: [{ type: "text", text: prompt }],
120
- model: { providerID, modelID },
121
- system: systemPrompt,
122
- });
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
+ });
123
124
 
124
- // Await turn completion via SSE.
125
- await sseDone;
125
+ // Await turn completion via SSE.
126
+ await sseDone;
127
+ })(),
128
+ { client: oc, sessionId, chatId, label: "OpenCode" },
129
+ );
126
130
 
127
131
  // Read authoritative final state from the messages endpoint.
128
132
  const messagesResp = await oc.session.messages({ sessionID: sessionId });
@@ -153,17 +157,23 @@ export async function runOpenCodeTurn(
153
157
  }
154
158
  }
155
159
  } catch (err) {
156
- // If the model called end_turn we aborted intentionally — swallow.
160
+ // Explicit user interrupts abort intentionally — swallow that close path.
157
161
  if (state.turnTerminated && /abort/i.test(errMsg(err))) {
158
162
  return;
159
163
  }
160
164
  throw err;
161
165
  } finally {
162
166
  sseAbort.abort();
163
- await sseDone.catch(() => {});
167
+ // A dead SSE socket may ignore the local abort flag until another event
168
+ // arrives. Bound cleanup so a timed-out turn cannot wedge its caller in
169
+ // the finally block it was meant to escape.
170
+ await Promise.race([sseDone.catch(() => {}), sleep(1_000)]);
164
171
  await questionWatchdog.catch(() => {});
165
172
  try {
166
- await rejectPendingQuestions(oc, sessionId, chatId, seenQuestionIds);
173
+ await Promise.all([
174
+ rejectPendingQuestions(oc, sessionId, chatId, seenQuestionIds),
175
+ approvePendingPermissions(oc, sessionId, chatId, seenPermissionIds),
176
+ ]);
167
177
  } catch {
168
178
  /* noop */
169
179
  }
@@ -191,7 +201,6 @@ interface SubscribeInputs {
191
201
  onStreamDelta?: (accumulated: string, phase?: "thinking" | "text") => void;
192
202
  onTextBlock?: (text: string) => Promise<void>;
193
203
  onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
194
- onTerminator: () => Promise<void>;
195
204
  abortSignal: AbortSignal;
196
205
  }
197
206
 
@@ -211,14 +220,13 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
211
220
  onStreamDelta,
212
221
  onTextBlock,
213
222
  onToolUse,
214
- onTerminator,
215
223
  abortSignal,
216
224
  } = inputs;
217
225
 
218
226
  // `subscribeSseStream` handles the narrowing with a runtime guard + the
219
227
  // subscribe-failed warning.
220
228
  const stream = await subscribeSseStream(oc, chatId);
221
- if (!stream) return;
229
+ if (!stream) throw new Error("OpenCode SSE connection unavailable");
222
230
 
223
231
  try {
224
232
  for await (const evt of stream) {
@@ -254,8 +262,7 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
254
262
  data?: Record<string, unknown>;
255
263
  }
256
264
  | undefined;
257
- // MessageAbortedError is our own abort signal when a terminator
258
- // tool fired; expected close path, not an upstream failure.
265
+ // MessageAbortedError is expected for an explicit user interrupt.
259
266
  const isOurAbort =
260
267
  state.turnTerminated &&
261
268
  (errProp?.name === "MessageAbortedError" ||
@@ -288,7 +295,9 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
288
295
  });
289
296
 
290
297
  if (outcome.kind === "terminator_fired") {
291
- onTerminator().catch(() => {});
298
+ // Aborting here can race with a prompt accepted immediately after
299
+ // this turn settles and cancel that next turn on the reused session.
300
+ // Delivery is already complete, so wait for natural idle instead.
292
301
  continue;
293
302
  }
294
303
 
@@ -23,7 +23,13 @@ export {
23
23
  getOpenCodeSessionSnapshot,
24
24
  } from "./sessions.js";
25
25
 
26
- export { initOpenCodeAgent, stopOpenCodeServer } from "./server.js";
26
+ export {
27
+ initOpenCodeAgent,
28
+ stopOpenCodeServer,
29
+ refreshPluginMcpServers,
30
+ updateSystemPrompt,
31
+ warmSession,
32
+ } from "./server.js";
27
33
 
28
34
  export { handleMessage } from "./handler/index.js";
29
35
 
@@ -23,7 +23,6 @@ import {
23
23
  } from "@opencode-ai/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,
@@ -91,20 +92,6 @@ export function initOpenCodeAgent(
91
92
  state.config = cfg;
92
93
  if (getGatewayPort) state.gatewayPortFn = getGatewayPort;
93
94
  if (frontend) state.frontendName = frontend;
94
-
95
- // Pre-warm plugin MCP servers in the background. Same rationale as
96
- // the Kilo backend's init pre-warm.
97
- prewarmPluginMcpServers().catch((err) => {
98
- logWarn(
99
- "agent",
100
- `Plugin MCP pre-warm failed (non-fatal): ${sharedErrMsg(err)}`,
101
- );
102
- });
103
- }
104
-
105
- async function prewarmPluginMcpServers(): Promise<void> {
106
- const client = await ensureServer();
107
- await ensurePluginMcpServers(client, "prewarm");
108
95
  }
109
96
 
110
97
  /**
@@ -155,8 +142,9 @@ export function ensurePluginMcpServers(
155
142
  export function buildToolOverrides(
156
143
  oc: OpencodeClient,
157
144
  chatServerName: string,
145
+ pluginServerNames: readonly string[] = [],
158
146
  ): Promise<Record<string, boolean> | undefined> {
159
- return buildToolOverridesShared(oc, state, chatServerName);
147
+ return buildToolOverridesShared(oc, state, chatServerName, pluginServerNames);
160
148
  }
161
149
 
162
150
  export function disconnectChatMcpServer(
@@ -166,6 +154,15 @@ export function disconnectChatMcpServer(
166
154
  return disconnectChatMcpServerShared(oc, state, serverName);
167
155
  }
168
156
 
157
+ export async function refreshPluginMcpServers(chatId: string) {
158
+ const oc = await ensureServer();
159
+ return refreshPluginMcpServersShared(oc, state, chatId);
160
+ }
161
+
162
+ export function updateSystemPrompt(prompt: string): void {
163
+ if (state.config) state.config.systemPrompt = prompt;
164
+ }
165
+
169
166
  // ── Session management ─────────────────────────────────────────────────────
170
167
 
171
168
  export function ensureSession(
@@ -175,6 +172,20 @@ export function ensureSession(
175
172
  return ensureRemoteSession(oc, state, chatId);
176
173
  }
177
174
 
175
+ /**
176
+ * Front-load a chat's cold start after `/reset`. Mirrors the Claude
177
+ * backend's `warmSession` so the `sessions` capability slot behaves the
178
+ * same across backends. Never throws — see `warmRemoteSession`.
179
+ */
180
+ export function warmSession(chatId: string): Promise<void> {
181
+ return warmRemoteSession(state, chatId, {
182
+ ensureServer,
183
+ ensureSession,
184
+ ensureChatMcpServer,
185
+ ensurePluginMcpServers,
186
+ });
187
+ }
188
+
178
189
  // ── Provider resolution ────────────────────────────────────────────────────
179
190
 
180
191
  export function resolveProviderID(
@@ -18,6 +18,7 @@ import {
18
18
  getTurnSummary,
19
19
  getSessionSnapshot,
20
20
  rejectPendingQuestions as rejectPendingQuestionsShared,
21
+ approvePendingPermissions as approvePendingPermissionsShared,
21
22
  type RemoteAssistantInfo,
22
23
  type RemoteSessionSnapshot,
23
24
  type RemoteSessionClient,
@@ -97,3 +98,19 @@ export function rejectPendingQuestions(
97
98
  "OpenCode",
98
99
  );
99
100
  }
101
+
102
+ /** Auto-approve pending OpenCode permissions for this headless session. */
103
+ export function approvePendingPermissions(
104
+ oc: OpencodeClient,
105
+ sessionId: string,
106
+ chatId: string,
107
+ seenPermissionIds: Set<string>,
108
+ ): Promise<void> {
109
+ return approvePendingPermissionsShared(
110
+ oc as unknown as RemoteSessionClient,
111
+ sessionId,
112
+ chatId,
113
+ seenPermissionIds,
114
+ "OpenCode",
115
+ );
116
+ }
@@ -35,7 +35,7 @@ export type RemoteMcpServerConfig =
35
35
 
36
36
  /** Permission-rule shape both SDKs share. */
37
37
  export interface RemotePermissionRule {
38
- permission: "tool" | "edit" | "bash";
38
+ permission: "tool" | "edit" | "bash" | "external_directory";
39
39
  pattern: string;
40
40
  action: "allow" | "deny" | "ask";
41
41
  }
@@ -108,9 +108,21 @@ export async function processStreamEvent(
108
108
  ): Promise<ProcessEventOutcome> {
109
109
  if (!event || typeof event !== "object") return { kind: "continue" };
110
110
  const props = event.properties ?? {};
111
- // Scope to our session only
111
+ // Scope to our session only. OpenCode/Kilo use three wire shapes:
112
+ // session events put sessionID on properties, part events put it inside
113
+ // properties.part, and message events put it inside properties.info.
114
+ // Missing the nested part shape lets a concurrent heartbeat/dream tool
115
+ // event terminate the active chat turn.
116
+ const part = props.part as Record<string, unknown> | undefined;
117
+ const info = props.info as Record<string, unknown> | undefined;
112
118
  const evtSessionID =
113
- typeof props.sessionID === "string" ? props.sessionID : undefined;
119
+ typeof props.sessionID === "string"
120
+ ? props.sessionID
121
+ : typeof part?.sessionID === "string"
122
+ ? part.sessionID
123
+ : typeof info?.sessionID === "string"
124
+ ? info.sessionID
125
+ : undefined;
114
126
  if (evtSessionID && evtSessionID !== ctx.sessionId) {
115
127
  return { kind: "stop", reason: "out_of_scope" };
116
128
  }
@@ -246,11 +258,12 @@ async function processPartUpdate(
246
258
  const stateObj = part.state as
247
259
  { status?: string; input?: Record<string, unknown> } | undefined;
248
260
 
249
- // Fire onToolUse ONCE when the tool transitions to running or completed
250
- // with input available. Subsequent state changes don't re-fire.
261
+ // Record a tool only after the upstream reports successful completion.
262
+ // Treating `running` as delivery made failed MCP calls flip end_turn and
263
+ // suppress fallback text even though nothing reached the user.
251
264
  if (
252
265
  !stateObj ||
253
- (stateObj.status !== "running" && stateObj.status !== "completed") ||
266
+ stateObj.status !== "completed" ||
254
267
  !callID ||
255
268
  ctx.seenToolCallIds.has(callID)
256
269
  ) {
@@ -436,7 +449,7 @@ export function finalizePartsIntoState(inputs: FinalizePartsInputs): {
436
449
  const callID = typeof part.callID === "string" ? part.callID : "";
437
450
  const toolName = typeof part.tool === "string" ? part.tool : "tool";
438
451
  if (callID && seenToolCallIds.has(callID)) continue;
439
- if (stateObj?.input) {
452
+ if (stateObj?.status === "completed" && stateObj.input) {
440
453
  if (callID) seenToolCallIds.add(callID);
441
454
  recordToolUse(state, toolName, stateObj.input);
442
455
  toolsProcessed++;
@@ -51,15 +51,30 @@ export {
51
51
 
52
52
  export {
53
53
  TALON_MCP_SERVER_NAME,
54
+ TALON_PLUGIN_MCP_SERVER_NAME,
54
55
  getChatMcpServerName,
56
+ getPluginMcpServerName,
57
+ safeMcpNamePart,
55
58
  isTalonToolID,
56
59
  ensureChatMcpServer,
57
60
  ensurePluginMcpServers,
58
61
  buildToolOverrides,
59
62
  disconnectChatMcpServer,
63
+ refreshPluginMcpServers,
60
64
  getRegisteredMcpServerNames,
61
65
  } from "./mcp.js";
62
66
 
63
- export { buildPermissionRuleset, ensureRemoteSession } from "./sessions.js";
67
+ export {
68
+ buildPermissionRuleset,
69
+ ensureRemoteSession,
70
+ warmRemoteSession,
71
+ type RemoteWarmDeps,
72
+ } from "./sessions.js";
64
73
 
65
74
  export { type ProviderResolverHooks, resolveProviderID } from "./providers.js";
75
+
76
+ export {
77
+ RemoteTurnTimeoutError,
78
+ remoteTurnTimeoutMs,
79
+ awaitRemoteTurn,
80
+ } from "./turn-timeout.js";
@@ -182,6 +182,8 @@ export function stopRemoteServer<TClient extends RemoteAgentClient>(
182
182
  state.clientPromise = null;
183
183
  state.modelProviderCache.clear();
184
184
  state.registeredMcpServers.clear();
185
+ state.registeredMcpTools.clear();
186
+ state.pluginMcpServersByChat.clear();
185
187
  try {
186
188
  extraCleanup?.();
187
189
  } catch (err) {
@@ -193,7 +195,9 @@ export function stopRemoteServer<TClient extends RemoteAgentClient>(
193
195
  if (state.serverHandle) {
194
196
  state.serverHandle.close();
195
197
  state.serverHandle = null;
196
- state.client = null;
197
198
  log("agent", `${state.label} server stopped`);
198
199
  }
200
+ // Always discard this process's client, including when the server was
201
+ // reused and is intentionally left running for its external owner.
202
+ state.client = null;
199
203
  }