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
package/package.json
CHANGED
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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:
|
|
185
|
-
//
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
//
|
|
98
|
-
//
|
|
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
|
|
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.
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
//
|
|
305
|
-
//
|
|
306
|
-
|
|
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 {
|
|
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
|
|
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.
|
|
206
|
-
//
|
|
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 ──────────────────────────────────────────────────
|