talon-agent 3.33.0 → 3.33.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 (31) hide show
  1. package/package.json +3 -3
  2. package/src/backend/kilo/factory.ts +39 -119
  3. package/src/backend/kilo/handler/index.ts +1 -11
  4. package/src/backend/kilo/handler/message.ts +20 -317
  5. package/src/backend/kilo/server.ts +55 -272
  6. package/src/backend/kilo/sessions.ts +5 -83
  7. package/src/backend/opencode/factory.ts +41 -118
  8. package/src/backend/opencode/handler/index.ts +1 -11
  9. package/src/backend/opencode/handler/message.ts +20 -321
  10. package/src/backend/opencode/server.ts +57 -217
  11. package/src/backend/opencode/sessions.ts +5 -83
  12. package/src/backend/remote-server/chat-turn.ts +359 -0
  13. package/src/backend/remote-server/factory.ts +150 -0
  14. package/src/backend/remote-server/index.ts +14 -19
  15. package/src/backend/remote-server/server-bindings.ts +232 -0
  16. package/src/backend/{kilo/handler → remote-server}/turn.ts +103 -67
  17. package/src/core/mesh/bridge-links.ts +319 -0
  18. package/src/core/mesh/common.ts +30 -0
  19. package/src/core/mesh/device-files.ts +710 -0
  20. package/src/core/mesh/service.ts +72 -894
  21. package/src/frontend/discord/handlers/access.ts +7 -56
  22. package/src/frontend/discord/handlers/state.ts +15 -8
  23. package/src/frontend/native/server.ts +391 -323
  24. package/src/frontend/shared/access.ts +152 -0
  25. package/src/frontend/telegram/handlers/access.ts +8 -25
  26. package/src/frontend/telegram/handlers/queue.ts +3 -35
  27. package/src/frontend/telegram/handlers/state.ts +15 -8
  28. package/src/backend/kilo/events.ts +0 -60
  29. package/src/backend/kilo/handler/state.ts +0 -9
  30. package/src/backend/opencode/handler/state.ts +0 -9
  31. package/src/backend/opencode/handler/turn.ts +0 -314
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Bind one remote-server backend from its profile.
3
+ *
4
+ * Kilo is a fork of OpenCode and exposes the same HTTP API, so everything
5
+ * a backend in this family does — spawn or reuse the local server,
6
+ * register the chat and plugin MCP servers, create sessions, resolve a
7
+ * model's provider — is shared code acting on a per-backend
8
+ * `RemoteServerState`. What actually differs between the two backends is
9
+ * the handful of fields on `RemoteBackendProfile`: the SDK's client and
10
+ * server constructors, the loopback port, the delivery contract, and how
11
+ * a stored model string splits into provider/model ids.
12
+ *
13
+ * Before this module each backend carried its own ~300-line `server.ts`
14
+ * of hand-written pass-through wrappers around the shared helpers. They
15
+ * drifted in the small ways copies do (one documented the port override,
16
+ * the other didn't; one exported `errMsg`, the other aliased it) while
17
+ * doing exactly the same thing. Now a backend's `server.ts` declares its
18
+ * profile and re-exports these bindings under the historical names —
19
+ * the surface the models module, the tests, and
20
+ * `vi.mock("../backend/<name>/server.js")` all address.
21
+ */
22
+
23
+ import type { TalonConfig } from "../../util/config.js";
24
+ import type { FrontendName } from "../../core/agent-runtime/backend-registry.js";
25
+ import {
26
+ buildDeliveryContract,
27
+ type DeliveryMode,
28
+ } from "../shared/delivery-contract.js";
29
+ import type { RemoteAgentClient } from "./client.js";
30
+ import { createRemoteServerState, errMsg } from "./state.js";
31
+ import { ensureRemoteServer, stopRemoteServer } from "./lifecycle.js";
32
+ import {
33
+ ensureChatMcpServer as ensureChatMcpServerShared,
34
+ ensurePluginMcpServers as ensurePluginMcpServersShared,
35
+ buildToolOverrides as buildToolOverridesShared,
36
+ disconnectChatMcpServer as disconnectChatMcpServerShared,
37
+ refreshPluginMcpServers as refreshPluginMcpServersShared,
38
+ getRegisteredMcpServerNames as getRegisteredMcpServerNamesShared,
39
+ } from "./mcp.js";
40
+ import { ensureRemoteSession, warmRemoteSession } from "./sessions.js";
41
+ import { resolveProviderID as resolveProviderIDShared } from "./providers.js";
42
+ import { guessProviderID, getBucketPriority } from "./model-catalog/index.js";
43
+
44
+ /** Every backend in the family binds to loopback — never exposed externally. */
45
+ const LOOPBACK_HOSTNAME = "127.0.0.1";
46
+
47
+ /** A stored model value split into the ids the server's prompt API wants. */
48
+ export interface RemoteModelSelection {
49
+ providerID?: string;
50
+ modelID: string;
51
+ }
52
+
53
+ /** The per-backend seams. Everything else in the family is shared. */
54
+ export interface RemoteBackendProfile<TClient extends RemoteAgentClient> {
55
+ /** Display label for log lines and error text ("Kilo", "OpenCode"). */
56
+ label: string;
57
+ /** Loopback port the local server listens on by default. */
58
+ defaultPort: number;
59
+ /**
60
+ * Env var that overrides the port, so integration tests can spawn an
61
+ * isolated server alongside a running production Talon that holds the
62
+ * default.
63
+ */
64
+ portEnv: string;
65
+ /** Delivery contract the system-prompt suffix carries. */
66
+ deliveryContract: DeliveryMode;
67
+ /** Strict SDK client over an already-running server URL. */
68
+ createClient(baseUrl: string): TClient;
69
+ /** Spawn a fresh local server; `close()` runs from `stop()`. */
70
+ createServer(args: {
71
+ hostname: string;
72
+ port: number;
73
+ timeout: number;
74
+ }): Promise<{ url: string; close(): void }>;
75
+ /** Split a stored model-selection string into provider/model ids. */
76
+ parseModelSelection(value: string): RemoteModelSelection;
77
+ }
78
+
79
+ /** The bound backend: the shared helpers closed over this backend's state. */
80
+ export interface RemoteServerBindings<TClient extends RemoteAgentClient> {
81
+ /** `http://127.0.0.1:<port>` — where the local server is expected. */
82
+ readonly baseUrl: string;
83
+ /** Delivery-contract suffix for a given frontend's tool names. */
84
+ systemPromptSuffix(frontend: string): string;
85
+ /** Telegram-shaped suffix, kept for one-shot (cross-surface) paths. */
86
+ readonly defaultSystemPromptSuffix: string;
87
+ /**
88
+ * Store the config, gateway-port resolver, and frontend label. Does
89
+ * NOT spawn the server — that happens lazily on the first
90
+ * `ensureServer()` so heartbeat-only installs don't pay for it.
91
+ */
92
+ init(
93
+ cfg: TalonConfig,
94
+ getGatewayPort?: () => number,
95
+ frontend?: FrontendName,
96
+ ): void;
97
+ /**
98
+ * Register a callback to run whenever the server is stopped. Cache
99
+ * invalidation lives with the caches: the models module registers its
100
+ * clear here at load time, so the server never imports it (a cycle).
101
+ */
102
+ onServerStop(hook: () => void): void;
103
+ /**
104
+ * Stop the local server if we own it and clear caches. Idempotent; a
105
+ * reused pre-existing server is left running.
106
+ */
107
+ stop(): void;
108
+ /** Lazily start (or reuse) the local server and return a strict client. */
109
+ ensureServer(): Promise<TClient>;
110
+ ensureChatMcpServer(oc: TClient, chatId: string): Promise<string>;
111
+ ensurePluginMcpServers(oc: TClient, chatId: string): Promise<string[]>;
112
+ /**
113
+ * A `tools` override map enabling ONLY this chat's Talon tools. Session
114
+ * permission rules cover execution; this covers visibility, because the
115
+ * server exposes every registered MCP server's tools to every session.
116
+ */
117
+ buildToolOverrides(
118
+ oc: TClient,
119
+ chatServerName: string,
120
+ pluginServerNames?: readonly string[],
121
+ ): Promise<Record<string, boolean> | undefined>;
122
+ disconnectChatMcpServer(oc: TClient, serverName: string): Promise<void>;
123
+ refreshPluginMcpServers(chatId: string): Promise<{
124
+ added: string[];
125
+ removed: string[];
126
+ errors: Record<string, string>;
127
+ }>;
128
+ updateSystemPrompt(prompt: string): void;
129
+ /**
130
+ * Resume the stored session id if the server confirms it is alive;
131
+ * otherwise create a fresh one with Talon's standard permission ruleset.
132
+ */
133
+ ensureSession(oc: TClient, chatId: string): Promise<string>;
134
+ /** Front-load a chat's cold start after `/reset`. Never throws. */
135
+ warmSession(chatId: string): Promise<void>;
136
+ /** Resolve a model id to its provider id against the live catalog. */
137
+ resolveProviderID(oc: TClient, modelID: string): Promise<string>;
138
+ parseModelSelection(value: string): RemoteModelSelection;
139
+ /** Throws if `init` has not run. */
140
+ getConfig(): TalonConfig;
141
+ /**
142
+ * Locally-cached MCP registrations. Test-only: `GET /mcp` returns `{}`
143
+ * regardless of state on both servers.
144
+ */
145
+ getRegisteredMcpServerNames(): string[];
146
+ errMsg(e: unknown): string;
147
+ }
148
+
149
+ export function bindRemoteServer<TClient extends RemoteAgentClient>(
150
+ profile: RemoteBackendProfile<TClient>,
151
+ ): RemoteServerBindings<TClient> {
152
+ const port = Number(process.env[profile.portEnv] ?? profile.defaultPort);
153
+ const state = createRemoteServerState<TClient>({
154
+ label: profile.label,
155
+ hostname: LOOPBACK_HOSTNAME,
156
+ port,
157
+ });
158
+ const stopHooks = new Set<() => void>();
159
+
160
+ const systemPromptSuffix = (frontend: string): string =>
161
+ `\n\n${buildDeliveryContract(profile.deliveryContract, frontend)}\n`;
162
+
163
+ const ensureServer = (): Promise<TClient> =>
164
+ ensureRemoteServer({
165
+ state,
166
+ createClient: profile.createClient,
167
+ createServer: profile.createServer,
168
+ });
169
+ const ensureSession = (oc: TClient, chatId: string): Promise<string> =>
170
+ ensureRemoteSession(oc, state, chatId);
171
+ const ensureChatMcpServer = (oc: TClient, chatId: string): Promise<string> =>
172
+ ensureChatMcpServerShared(oc, state, chatId);
173
+ const ensurePluginMcpServers = (
174
+ oc: TClient,
175
+ chatId: string,
176
+ ): Promise<string[]> => ensurePluginMcpServersShared(oc, state, chatId);
177
+
178
+ return {
179
+ baseUrl: state.baseUrl,
180
+ systemPromptSuffix,
181
+ defaultSystemPromptSuffix: systemPromptSuffix("telegram"),
182
+ init(cfg, getGatewayPort, frontend) {
183
+ state.config = cfg;
184
+ if (getGatewayPort) state.gatewayPortFn = getGatewayPort;
185
+ if (frontend) state.frontendName = frontend;
186
+ },
187
+ onServerStop(hook) {
188
+ stopHooks.add(hook);
189
+ },
190
+ stop() {
191
+ stopRemoteServer(state, () => {
192
+ for (const hook of stopHooks) hook();
193
+ });
194
+ },
195
+ ensureServer,
196
+ ensureChatMcpServer,
197
+ ensurePluginMcpServers,
198
+ buildToolOverrides: (oc, chatServerName, pluginServerNames = []) =>
199
+ buildToolOverridesShared(oc, state, chatServerName, pluginServerNames),
200
+ disconnectChatMcpServer: (oc, serverName) =>
201
+ disconnectChatMcpServerShared(oc, state, serverName),
202
+ refreshPluginMcpServers: async (chatId) =>
203
+ refreshPluginMcpServersShared(await ensureServer(), state, chatId),
204
+ updateSystemPrompt(prompt) {
205
+ if (state.config) state.config.systemPrompt = prompt;
206
+ },
207
+ ensureSession,
208
+ warmSession: (chatId) =>
209
+ warmRemoteSession(state, chatId, {
210
+ ensureServer,
211
+ ensureSession,
212
+ ensureChatMcpServer,
213
+ ensurePluginMcpServers,
214
+ }),
215
+ resolveProviderID: (oc, modelID) =>
216
+ resolveProviderIDShared(oc, state, modelID, {
217
+ guessProviderID,
218
+ getBucketPriority,
219
+ }),
220
+ parseModelSelection: profile.parseModelSelection,
221
+ getConfig() {
222
+ if (!state.config) {
223
+ throw new Error(
224
+ `${profile.label} agent not initialized — call init${profile.label}Agent first`,
225
+ );
226
+ }
227
+ return state.config;
228
+ },
229
+ getRegisteredMcpServerNames: () => getRegisteredMcpServerNamesShared(state),
230
+ errMsg,
231
+ };
232
+ }
@@ -1,29 +1,60 @@
1
1
  /**
2
- * Run one Kilo turn over the HTTP API + SSE event stream.
2
+ * Run one turn over the remote server's HTTP API + SSE event stream.
3
3
  *
4
- * `runKiloTurn` subscribes to SSE before issuing the prompt (so no early
5
- * events are lost), fires `promptAsync`, awaits the SSE close, then reads the
6
- * authoritative parts list. `subscribeToTurnEvents` translates relevant SSE
7
- * events into shared stream-state mutations and observes natural turn idle.
4
+ * `runRemoteTurn` subscribes to SSE before issuing the prompt (so no early
5
+ * events are lost), fires `promptAsync`, awaits the SSE close, then reads
6
+ * the authoritative parts list. `subscribeToTurnEvents` translates the
7
+ * relevant SSE events into shared stream-state mutations and observes the
8
+ * turn going idle.
9
+ *
10
+ * Kilo and OpenCode share the SSE schema (Kilo is a fork), so this is one
11
+ * implementation parameterised by the backend label. The two per-backend
12
+ * copies it replaces had already drifted apart in comments only.
8
13
  */
9
14
 
10
- import type { KiloClient } from "@kilocode/sdk/v2";
11
- import { logWarn } from "../../../util/log.js";
12
- import { errMsg } from "../server.js";
15
+ import { logWarn } from "../../util/log.js";
16
+ import { createStreamState, recordTokens, sleep } from "../shared/index.js";
17
+ import { processStreamEvent, finalizePartsIntoState } from "./events.js";
18
+ import { findLastAssistantMessage } from "./messages.js";
13
19
  import {
14
20
  approvePendingPermissions,
15
21
  extractAssistantUsage,
22
+ extractPartsSummary,
16
23
  rejectPendingQuestions,
17
- type KiloAssistantInfo,
18
- } from "../sessions.js";
19
- import { createStreamState, recordTokens, sleep } from "../../shared/index.js";
20
- import { processStreamEvent, finalizePartsIntoState } from "../events.js";
21
- import { subscribeSseStream } from "../../remote-server/sse-stream.js";
22
- import { awaitRemoteTurn } from "../../remote-server/turn-timeout.js";
23
- import { findLastAssistantMessage as findLastAssistantMessageShared } from "../../remote-server/messages.js";
24
+ type RemoteAssistantInfo,
25
+ type RemoteSessionClient,
26
+ } from "./session-helpers.js";
27
+ import {
28
+ subscribeSseStream,
29
+ type SseSubscribableClient,
30
+ } from "./sse-stream.js";
31
+ import { errMsg } from "./state.js";
32
+ import { awaitRemoteTurn, type RemoteTurnAbortClient } from "./turn-timeout.js";
24
33
 
25
- export interface RunKiloTurnInputs {
26
- oc: KiloClient;
34
+ /**
35
+ * The slice of the SDK client a turn touches. Both concrete SDK clients
36
+ * satisfy it structurally; callers holding a typed SDK client cast.
37
+ */
38
+ export interface RemoteTurnClient
39
+ extends SseSubscribableClient, RemoteTurnAbortClient {
40
+ session: RemoteTurnAbortClient["session"] & {
41
+ promptAsync(args: {
42
+ sessionID: string;
43
+ parts: Array<{ type: "text"; text: string }>;
44
+ model: { providerID: string; modelID: string };
45
+ system: string;
46
+ tools?: Record<string, boolean>;
47
+ }): Promise<unknown>;
48
+ messages(args: { sessionID: string }): Promise<{ data?: unknown }>;
49
+ };
50
+ question: RemoteSessionClient["question"];
51
+ permission: RemoteSessionClient["permission"];
52
+ }
53
+
54
+ export interface RunRemoteTurnInputs {
55
+ /** Backend label for log lines ("Kilo", "OpenCode"). */
56
+ label: string;
57
+ oc: RemoteTurnClient;
27
58
  sessionId: string;
28
59
  prompt: string;
29
60
  systemPrompt: string;
@@ -41,21 +72,24 @@ export interface RunKiloTurnInputs {
41
72
  }
42
73
 
43
74
  /**
44
- * Run one Kilo turn end-to-end.
75
+ * Run one turn end-to-end.
45
76
  *
46
77
  * Strategy:
47
78
  * 1. Subscribe to SSE BEFORE issuing the prompt so no early events are
48
79
  * lost. The subscription tracks state mutations and resolves when the
49
80
  * turn ends (`session.turn.close` / `session.idle` / `session.error`).
50
81
  * 2. Fire the prompt via `session.promptAsync` — returns immediately;
51
- * Kilo runs the model task in the background and emits SSE events.
82
+ * the server runs the model task in the background and emits SSE.
52
83
  * 3. Await the SSE close event — Talon's await is on event iteration we
53
84
  * control, never on a long-running HTTP call we can't interrupt.
54
85
  * 4. Read the authoritative parts list via `session.messages` and drain
55
86
  * it through `finalizePartsIntoState`.
56
87
  */
57
- export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
88
+ export async function runRemoteTurn(
89
+ inputs: RunRemoteTurnInputs,
90
+ ): Promise<void> {
58
91
  const {
92
+ label,
59
93
  oc,
60
94
  sessionId,
61
95
  prompt,
@@ -72,12 +106,14 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
72
106
  onTextBlock,
73
107
  onToolUse,
74
108
  } = inputs;
109
+ const sessionClient = oc as unknown as RemoteSessionClient;
75
110
 
76
- // SSE subscription FIRST — Kilo can fire `session.turn.open` and early
77
- // `message.part.updated` events immediately after promptAsync returns,
78
- // so the iterator must already be alive.
111
+ // SSE subscription FIRST — `session.turn.open` and early
112
+ // `message.part.updated` events can fire immediately after promptAsync
113
+ // returns, so the iterator must already be alive.
79
114
  const sseAbort = new AbortController();
80
115
  const sseDone = subscribeToTurnEvents({
116
+ label,
81
117
  oc,
82
118
  sessionId,
83
119
  state,
@@ -91,13 +127,27 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
91
127
 
92
128
  // Headless-interaction watchdog: resolve upstream questions and any
93
129
  // permission requests that escaped the session ruleset.
130
+ const settlePending = (): Promise<unknown> =>
131
+ Promise.all([
132
+ rejectPendingQuestions(
133
+ sessionClient,
134
+ sessionId,
135
+ chatId,
136
+ seenQuestionIds,
137
+ label,
138
+ ),
139
+ approvePendingPermissions(
140
+ sessionClient,
141
+ sessionId,
142
+ chatId,
143
+ seenPermissionIds,
144
+ label,
145
+ ),
146
+ ]);
94
147
  const questionWatchdog = (async () => {
95
148
  while (!sseAbort.signal.aborted) {
96
149
  try {
97
- await Promise.all([
98
- rejectPendingQuestions(oc, sessionId, chatId, seenQuestionIds),
99
- approvePendingPermissions(oc, sessionId, chatId, seenPermissionIds),
100
- ]);
150
+ await settlePending();
101
151
  } catch (err) {
102
152
  logWarn(
103
153
  "agent",
@@ -121,11 +171,9 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
121
171
  system: systemPrompt,
122
172
  ...(toolOverrides ? { tools: toolOverrides } : {}),
123
173
  });
124
-
125
- // Await turn completion via SSE.
126
174
  await sseDone;
127
175
  })(),
128
- { client: oc, sessionId, chatId, label: "Kilo" },
176
+ { client: oc, sessionId, chatId, label },
129
177
  );
130
178
 
131
179
  // Read authoritative final state from the messages endpoint. The SSE
@@ -135,7 +183,8 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
135
183
  const messages = Array.isArray(messagesResp.data)
136
184
  ? (messagesResp.data as Array<Record<string, unknown>>)
137
185
  : [];
138
- const lastAssistant = findLastAssistantMessage(messages);
186
+ const lastAssistant =
187
+ findLastAssistantMessage<RemoteAssistantInfo>(messages);
139
188
  const parts = lastAssistant?.parts ?? [];
140
189
  const assistantInfo = lastAssistant?.info;
141
190
 
@@ -143,6 +192,7 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
143
192
  parts,
144
193
  state,
145
194
  seenToolCallIds,
195
+ extractPartsSummary,
146
196
  onToolUse,
147
197
  });
148
198
 
@@ -171,30 +221,16 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
171
221
  await Promise.race([sseDone.catch(() => {}), sleep(1_000)]);
172
222
  await questionWatchdog.catch(() => {});
173
223
  try {
174
- await Promise.all([
175
- rejectPendingQuestions(oc, sessionId, chatId, seenQuestionIds),
176
- approvePendingPermissions(oc, sessionId, chatId, seenPermissionIds),
177
- ]);
224
+ await settlePending();
178
225
  } catch {
179
226
  /* noop */
180
227
  }
181
228
  }
182
229
  }
183
230
 
184
- /**
185
- * Find the most recent assistant message in a session-messages list and
186
- * surface its parts + assistant info. Kilo's `session.messages` returns the
187
- * full chronological list; we only care about the last assistant message.
188
- */
189
- const findLastAssistantMessage = (
190
- messages: Array<Record<string, unknown>>,
191
- ): {
192
- parts: Array<Record<string, unknown>>;
193
- info?: KiloAssistantInfo;
194
- } | null => findLastAssistantMessageShared<KiloAssistantInfo>(messages);
195
-
196
231
  interface SubscribeInputs {
197
- oc: KiloClient;
232
+ label: string;
233
+ oc: RemoteTurnClient;
198
234
  sessionId: string;
199
235
  state: ReturnType<typeof createStreamState>;
200
236
  chatId: string;
@@ -206,13 +242,15 @@ interface SubscribeInputs {
206
242
  }
207
243
 
208
244
  /**
209
- * Subscribe to Kilo's global SSE event stream and translate relevant events
210
- * into stream-state mutations / callback firings. Only events scoped to our
211
- * `sessionId` are processed; others are dropped silently. Errors are logged
212
- * but do not propagate (the messages endpoint is the source-of-truth fallback).
245
+ * Subscribe to the server's global SSE event stream and translate relevant
246
+ * events into stream-state mutations / callback firings. Only events scoped
247
+ * to our `sessionId` are processed; others are dropped silently. Errors are
248
+ * logged but do not propagate — the messages endpoint is the source of
249
+ * truth for the turn result.
213
250
  */
214
251
  async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
215
252
  const {
253
+ label,
216
254
  oc,
217
255
  sessionId,
218
256
  state,
@@ -228,20 +266,18 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
228
266
  // subscribe-failed warning. Returns `undefined` for both "subscribe
229
267
  // rejected" and "no iterable in the response shape".
230
268
  const stream = await subscribeSseStream(oc, chatId);
231
- if (!stream) throw new Error("Kilo SSE connection unavailable");
269
+ if (!stream) throw new Error(`${label} SSE connection unavailable`);
232
270
 
233
271
  try {
234
272
  for await (const evt of stream) {
235
273
  if (abortSignal.aborted) break;
236
274
  if (!evt || typeof evt !== "object") continue;
237
275
 
238
- // Kilo's SSE wire format wraps every event in
239
- // `{payload: {type, properties}}`. Unwrap here so type/properties
240
- // land where the rest of the loop expects them.
276
+ // The SSE wire format wraps every event in `{payload: {type,
277
+ // properties}}`. Unwrap here so type/properties land where the rest
278
+ // of the loop expects them.
241
279
  const payload =
242
- evt && typeof evt === "object" && "payload" in evt
243
- ? (evt as { payload?: unknown }).payload
244
- : evt;
280
+ "payload" in evt ? (evt as { payload?: unknown }).payload : evt;
245
281
  if (!payload || typeof payload !== "object") continue;
246
282
  const event = payload as {
247
283
  type?: string;
@@ -249,9 +285,10 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
249
285
  };
250
286
 
251
287
  // session.error is observed here for logging; everything else goes
252
- // through the shared pure helper. Kilo's SSE stream is global, so
288
+ // through the shared pure helper. The SSE stream is global, so
253
289
  // scope-filter to our own sessionId before attributing the error to
254
- // this chat.
290
+ // this chat — a heartbeat session.error would otherwise pollute the
291
+ // chat's log.
255
292
  if (event.type === "session.error") {
256
293
  const props = event.properties ?? {};
257
294
  const evtSessionID =
@@ -280,14 +317,14 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
280
317
  ]
281
318
  .filter(Boolean)
282
319
  .join(" ");
283
- logWarn("agent", `[${chatId}] Kilo session.error: ${detail}`);
320
+ logWarn("agent", `[${chatId}] ${label} session.error: ${detail}`);
284
321
  // Stash the error message so the handler's delivery branch can
285
- // surface it as `⚠️ Kilo: <message>` instead of silence.
322
+ // surface it as `⚠️ <label>: <message>` instead of silence.
286
323
  const msg = errProp.message ?? errProp.name;
287
324
  if (msg) state.syntheticError = msg;
288
325
  }
289
- // session.error for OUR session ends the turn — Kilo isn't going
290
- // to produce more events for this prompt.
326
+ // session.error for OUR session ends the turn — the server isn't
327
+ // going to produce more events for this prompt.
291
328
  return;
292
329
  }
293
330
 
@@ -296,6 +333,7 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
296
333
  sessionId,
297
334
  state,
298
335
  seenToolCallIds,
336
+ backendLabel: label,
299
337
  onStreamDelta,
300
338
  onTextBlock,
301
339
  onToolUse,
@@ -316,8 +354,6 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
316
354
  }
317
355
  }
318
356
  } catch (err) {
319
- // SSE iteration errors are non-fatal — the messages endpoint is the
320
- // source of truth for the turn result.
321
357
  if (!abortSignal.aborted) {
322
358
  logWarn("agent", `[${chatId}] SSE iteration failed: ${errMsg(err)}`);
323
359
  }