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
@@ -1,24 +1,10 @@
1
1
  /**
2
- * OpenCode main message handler.
3
- *
4
- * Orchestrates the full turn lifecycle on top of OpenCode's HTTP API + SSE
5
- * event stream. Behaviour mirrors the Kilo backend and shares its
6
- * non-SDK-specific primitives via `../../shared/` and `../../remote-server/`.
7
- * The OpenCode-specific turn driver + SSE subscription live in `turn.ts`.
2
+ * OpenCode main message handler — the shared remote-server chat turn
3
+ * bound to OpenCode's server bindings.
8
4
  */
9
5
 
10
- import {
11
- getSession,
12
- incrementTurns,
13
- recordUsage,
14
- setSessionId,
15
- setSessionName,
16
- } from "../../../storage/sessions.js";
17
- import { getChatSettings } from "../../../storage/chat-settings.js";
18
- import { log, logError } from "../../../util/log.js";
19
- import { traceMessage } from "../../../util/trace.js";
20
6
  import type { QueryParams, QueryResult } from "../../shared/handler-types.js";
21
-
7
+ import { runRemoteChatTurn } from "../../remote-server/chat-turn.js";
22
8
  import {
23
9
  ensureServer,
24
10
  ensureSession,
@@ -30,311 +16,24 @@ import {
30
16
  getConfig,
31
17
  opencodeSystemPromptSuffix,
32
18
  } from "../server.js";
33
- import {
34
- frontendsForChat,
35
- nonTerminalFrontends,
36
- } from "../../shared/frontends.js";
37
- import { getOpenCodeTurnSummary } from "../sessions.js";
38
- import {
39
- createStreamState,
40
- recordTokens,
41
- finalizeResponseText,
42
- formatUserPrompt,
43
- prepareSystemPrompt,
44
- extractSessionName,
45
- summarizeUsage,
46
- routeDelivery,
47
- applyRetryDecision,
48
- recordTurnMetrics,
49
- recordFailedTurnAccounting,
50
- registerTurnInterrupt,
51
- } from "../../shared/index.js";
52
- import { activeSessions } from "./state.js";
53
- import { runOpenCodeTurn } from "./turn.js";
54
-
55
- export async function handleMessage(
56
- params: QueryParams,
57
- _retried = false,
58
- ): Promise<QueryResult> {
59
- const config = getConfig();
60
- if (!config) throw new Error("OpenCode agent not initialized");
61
-
62
- const {
63
- chatId,
64
- text,
65
- senderName,
66
- senderHandle,
67
- isGroup,
68
- messageId,
69
- onTextBlock,
70
- onToolUse,
71
- } = params;
72
- const t0 = Date.now();
73
- const session = getSession(chatId);
74
- const previousTurns = session.turns;
75
-
76
- // Resolve active model + provider lookup against OpenCode's catalog
77
- const chatSettings = getChatSettings(chatId);
78
- const activeModel = params.model ?? chatSettings.model ?? config.model;
79
- const { providerID: selectedProviderID, modelID } =
80
- parseStoredOpenCodeModelSelection(activeModel);
81
-
82
- const oc = await ensureServer();
83
- const providerID =
84
- selectedProviderID ?? (await resolveProviderID(oc, modelID));
85
- log(
86
- "agent",
87
- `[${chatId}] OpenCode model resolved: provider=${providerID} model=${modelID}` +
88
- (selectedProviderID ? "" : " (provider via catalog lookup)"),
89
- );
90
- const sessionId = await ensureSession(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
- );
98
-
99
- // Build the prompt (time tag + sender + msg_id reference)
100
- const prompt = formatUserPrompt({
101
- text,
102
- senderName: senderName ?? "user",
103
- senderHandle,
104
- isGroup,
105
- messageId,
106
- });
107
-
108
- // Per-session frozen prompt + OpenCode-specific delivery suffix
109
- const { text: systemPrompt } = prepareSystemPrompt({
110
- config,
111
- previousTurns,
112
- backendSuffix: opencodeSystemPromptSuffix(
113
- frontendsForChat(chatId, nonTerminalFrontends(config.frontend))[0] ??
114
- "telegram",
115
- ),
116
- chatId,
117
- sessionEpoch: session.createdAt,
118
- });
119
-
120
- log("agent", `[${chatId}] <- (${text.length} chars)`);
121
- traceMessage(chatId, "in", text, { senderName, isGroup });
122
- activeSessions.set(chatId, sessionId);
123
-
124
- // Bind the stream state to the chat so token mutators mirror counts
125
- // into the live-turn overlay — /status updates while the turn runs.
126
- const state = createStreamState(chatId);
127
- state.newSessionId = sessionId;
128
- // A user interrupt is a synthetic turn terminator: marking the flag
129
- // before aborting the session routes the close through the same clean
130
- // path a model-fired end_turn takes (MessageAborted swallowed as the
131
- // expected close, no retry), settling with whatever the turn produced.
132
- const unregisterInterrupt = registerTurnInterrupt(chatId, async () => {
133
- state.turnTerminated = true;
134
- await oc.session.abort({ sessionID: sessionId });
135
- });
136
- const promptStartedAt = Date.now();
137
- const seenQuestionIds = new Set<string>();
138
- const seenPermissionIds = new Set<string>();
139
- const seenToolCallIds = new Set<string>();
140
-
141
- const setupMs = Date.now() - t0;
142
- let promptMs = 0;
143
-
144
- try {
145
- // Drive the OpenCode turn: subscribe to SSE events in parallel with
146
- // promptAsync, surface tool calls + terminator into shared state, and
147
- // exit when the turn closes / goes idle. `onStreamDelta` is
148
- // intentionally NOT forwarded — Telegram's contract is "send the final
149
- // reply once", not live edit_message updates. Final delivery happens
150
- // through `end_turn` / `send`.
151
- const turnStart = Date.now();
152
- await runOpenCodeTurn({
153
- oc,
154
- sessionId,
155
- prompt,
156
- systemPrompt,
157
- providerID,
158
- modelID,
159
- state,
160
- chatId,
161
- seenQuestionIds,
162
- seenPermissionIds,
163
- seenToolCallIds,
164
- toolOverrides,
165
- onStreamDelta: undefined,
166
- onTextBlock,
167
- onToolUse,
168
- });
169
- promptMs = Date.now() - turnStart;
170
- } catch (err) {
171
- const outcome = await applyRetryDecision({
172
- err,
173
- chatId,
174
- activeModel,
175
- retried: _retried,
176
- params,
177
- recurseWithRetried: (p) => handleMessage(p, true),
178
- backendLabel: "OpenCode",
179
- });
180
- if (outcome.retry) return outcome.retry;
181
19
 
182
- // Terminal failure — account for whatever the turn consumed before
183
- // dying and drop the live overlay (the retry path above did its own
184
- // accounting inside the recursive attempt).
185
- recordFailedTurnAccounting({
186
- backend: "opencode",
187
- chatId,
188
- durationMs: Date.now() - t0,
189
- toolCalls: state.toolCalls,
190
- apiCalls: state.numApiCalls,
191
- model: activeModel,
192
- usage: {
193
- inputTokens: state.sdkInputTokens,
194
- outputTokens: state.sdkOutputTokens,
195
- cacheRead: state.sdkCacheRead,
196
- cacheWrite: state.sdkCacheWrite,
197
- },
198
- contextTokens: state.contextTokens,
199
- contextWindow: state.contextWindow,
200
- });
201
-
202
- logError(
203
- "agent",
204
- `[${chatId}] OpenCode error: ${outcome.classified.message}`,
205
- );
206
- throw outcome.classified;
207
- } finally {
208
- unregisterInterrupt();
209
- if (activeSessions.get(chatId) === sessionId) {
210
- activeSessions.delete(chatId);
211
- }
212
- // Note: we deliberately do NOT disconnect the chat MCP server here.
213
- // The server is named per-chat so it's safe to keep across turns;
214
- // re-spawning the subprocess each turn cost ~800ms per message.
215
- // Per-prompt overrides isolate visibility across concurrent chats.
216
- }
217
-
218
- // ── Post-loop accounting ──────────────────────────────────────────────────
219
-
220
- // If the SSE loop missed any usage info, fall back to the session
221
- // summary endpoint (which always reflects the final server state).
222
- if (
223
- state.sdkInputTokens === 0 &&
224
- state.sdkOutputTokens === 0 &&
225
- state.sdkCacheRead === 0
226
- ) {
227
- try {
228
- const summary = await getOpenCodeTurnSummary(
229
- oc,
230
- sessionId,
231
- promptStartedAt,
232
- );
233
- if (summary.usage.assistantMessages > 0) {
234
- recordTokens(state, {
235
- inputTokens: summary.usage.inputTokens,
236
- outputTokens: summary.usage.outputTokens,
237
- cacheRead: summary.usage.cacheRead,
238
- cacheWrite: summary.usage.cacheWrite,
239
- });
240
- }
241
- } catch {
242
- // best-effort — session summaries can race on cancellation
243
- }
244
- }
245
-
246
- const responseText = finalizeResponseText(state);
247
- const durationMs = Date.now() - t0;
248
- recordTurnMetrics({
249
- chatId,
250
- backend: "opencode",
251
- durationMs,
252
- toolCalls: state.toolCalls,
253
- apiCalls: state.numApiCalls,
254
- usage: {
255
- inputTokens: state.sdkInputTokens,
256
- outputTokens: state.sdkOutputTokens,
257
- cacheRead: state.sdkCacheRead,
258
- cacheWrite: state.sdkCacheWrite,
20
+ export function handleMessage(params: QueryParams): Promise<QueryResult> {
21
+ // Assembled per call, not at module load: tests mock `../server.js`
22
+ // with a partial surface and only touch the members they need.
23
+ return runRemoteChatTurn(
24
+ {
25
+ id: "opencode",
26
+ label: "OpenCode",
27
+ getConfig,
28
+ ensureServer,
29
+ parseModelSelection: parseStoredOpenCodeModelSelection,
30
+ resolveProviderID,
31
+ ensureSession,
32
+ ensureChatMcpServer,
33
+ ensurePluginMcpServers,
34
+ buildToolOverrides,
35
+ systemPromptSuffix: opencodeSystemPromptSuffix,
259
36
  },
260
- });
261
-
262
- if (state.newSessionId) {
263
- const stored = getSession(chatId).sessionId;
264
- if (stored !== state.newSessionId) {
265
- setSessionId(chatId, state.newSessionId);
266
- }
267
- }
268
-
269
- incrementTurns(chatId);
270
- recordUsage(chatId, {
271
- inputTokens: state.sdkInputTokens,
272
- outputTokens: state.sdkOutputTokens,
273
- cacheRead: state.sdkCacheRead,
274
- cacheWrite: state.sdkCacheWrite,
275
- durationMs,
276
- model: activeModel,
277
- });
278
-
279
- // Set a descriptive session name from the user's first message
280
- if (previousTurns === 0) {
281
- const name = extractSessionName(text);
282
- if (name) setSessionName(chatId, name);
283
- }
284
-
285
- // ── Delivery — decision tree shared with the Kilo backend ──────────────────
286
- const delivery = await routeDelivery({
287
- backendLabel: "OpenCode",
288
- chatId,
289
- state,
290
- responseText,
291
- onTextBlock,
292
- });
293
-
294
- log(
295
- "agent",
296
- `[${chatId}] delivery: ${delivery.route} (${delivery.chars} chars)`,
297
- );
298
-
299
- log(
300
- "agent",
301
- `[${chatId}] -> (${summarizeUsage(
302
- {
303
- inputTokens: state.sdkInputTokens,
304
- outputTokens: state.sdkOutputTokens,
305
- cacheRead: state.sdkCacheRead,
306
- cacheWrite: state.sdkCacheWrite,
307
- },
308
- { durationMs, toolCalls: state.toolCalls },
309
- )} terminator=${state.turnTerminated ? "yes" : "no"} ` +
310
- `delivered=${state.deliveredTextNorms.length} ` +
311
- `respLen=${responseText.length} ` +
312
- `setup=${setupMs}ms turn=${promptMs}ms ` +
313
- `events=${formatEventCounts(state.eventCounts)})`,
37
+ params,
314
38
  );
315
- traceMessage(chatId, "out", responseText, {
316
- durationMs,
317
- toolCalls: state.toolCalls,
318
- });
319
-
320
- return {
321
- text: responseText,
322
- durationMs,
323
- inputTokens: state.sdkInputTokens,
324
- outputTokens: state.sdkOutputTokens,
325
- cacheRead: state.sdkCacheRead,
326
- cacheWrite: state.sdkCacheWrite,
327
- };
328
- }
329
-
330
- /**
331
- * Compact summary of which SSE event types fired this turn. `none` for a
332
- * silent turn. Otherwise renders as `delta×42,part.updated×1`.
333
- */
334
- function formatEventCounts(counts: Record<string, number>): string {
335
- const entries = Object.entries(counts);
336
- if (entries.length === 0) return "none";
337
- return entries
338
- .map(([type, n]) => `${type.replace(/^(message|session)\./, "")}×${n}`)
339
- .join(",");
340
39
  }
@@ -1,19 +1,14 @@
1
1
  /**
2
- * OpenCode server lifecycle — thin wrapper around `backend/remote-server/`.
2
+ * OpenCode server lifecycle — the OpenCode profile bound through
3
+ * `backend/remote-server/server-bindings.ts`.
3
4
  *
4
- * The MCP / session / provider plumbing is identical to Kilo (Kilo is a
5
- * fork of OpenCode that exposes the same HTTP API), so this module
6
- * keeps only the OpenCode-specific bits:
7
- *
8
- * - Constants (port 4096, system-prompt suffix wording).
9
- * - SDK-specific client factory (`createOpencodeClient` +
10
- * `createOpencodeServer`).
11
- * - Backend-specific model parser (the fuzzy `provider/model` splitter
12
- * from `./models.ts`).
13
- * - Pre-warm hook that triggers immediately at init.
14
- *
15
- * The shared machinery (server spawn, MCP registration, session creation,
16
- * provider resolution) lives in `backend/remote-server/`.
5
+ * The server spawn, MCP registration, session creation, and provider
6
+ * resolution are the shared remote-server family implementation (Kilo is
7
+ * a fork with the same HTTP API). This module holds only what is
8
+ * OpenCode's: the SDK constructors, port 4096, the text-preferred
9
+ * delivery contract, and the fuzzy `provider/model` parser. The exports
10
+ * keep their historical names — the models module, one-shot runner,
11
+ * tests, and `vi.mock("../backend/opencode/server.js")` all address them.
17
12
  */
18
13
 
19
14
  import {
@@ -21,219 +16,64 @@ import {
21
16
  createOpencodeServer,
22
17
  type OpencodeClient,
23
18
  } from "@opencode-ai/sdk/v2";
24
- import type { TalonConfig } from "../../util/config.js";
25
- import type { FrontendName } from "../../core/agent-runtime/backend-registry.js";
26
- import { buildDeliveryContract } from "../shared/delivery-contract.js";
27
19
  import {
28
- guessProviderID,
29
- getBucketPriority,
30
20
  normalizeModelLookup,
31
- parseRemoteModelQuery as parseOpenCodeModelQuery,
21
+ parseRemoteModelQuery,
32
22
  } from "../remote-server/model-catalog/index.js";
33
23
  import {
34
- createRemoteServerState,
35
- ensureRemoteServer as ensureRemoteServerShared,
36
- stopRemoteServer,
37
- ensureChatMcpServer as ensureChatMcpServerShared,
38
- ensurePluginMcpServers as ensurePluginMcpServersShared,
39
- buildToolOverrides as buildToolOverridesShared,
40
- disconnectChatMcpServer as disconnectChatMcpServerShared,
41
- refreshPluginMcpServers as refreshPluginMcpServersShared,
42
- ensureRemoteSession,
43
- warmRemoteSession,
44
- resolveProviderID as resolveProviderIDShared,
45
- getRegisteredMcpServerNames as getRegisteredMcpServerNamesShared,
46
- errMsg as sharedErrMsg,
47
- type RemoteServerState,
48
- } from "../remote-server/index.js";
49
-
50
- // ── Constants ───────────────────────────────────────────────────────────────
51
-
52
- const OPENCODE_HOSTNAME = "127.0.0.1";
53
- // Overridable via `OPENCODE_PORT` env so integration tests can spawn an
54
- // isolated server alongside a running production Talon (which holds 4096).
55
- const OPENCODE_PORT = Number(process.env.OPENCODE_PORT ?? 4096);
56
- const OPENCODE_BASE_URL = `http://${OPENCODE_HOSTNAME}:${OPENCODE_PORT}`;
57
- // Text-preferred delivery: plain assistant text is the reply; tools
58
- // only for genuine side effects. Single-sourced from the shared
59
- // contract templates (prompts/system/contract-text-preferred.md).
60
- export function opencodeSystemPromptSuffix(frontend: string): string {
61
- return `\n\n${buildDeliveryContract("text-preferred", frontend)}\n`;
62
- }
63
-
64
- /** Telegram-shaped default, kept for one-shot (cross-surface) paths. */
65
- const OPENCODE_SYSTEM_PROMPT_SUFFIX = opencodeSystemPromptSuffix("telegram");
66
-
67
- // ── State ───────────────────────────────────────────────────────────────────
68
-
69
- const state: RemoteServerState<OpencodeClient> =
70
- createRemoteServerState<OpencodeClient>({
71
- label: "OpenCode",
72
- hostname: OPENCODE_HOSTNAME,
73
- port: OPENCODE_PORT,
74
- });
75
-
76
- function createStrictOpencodeClient(baseUrl: string): OpencodeClient {
77
- return createOpencodeClient({
78
- baseUrl,
79
- throwOnError: true,
80
- });
81
- }
82
-
83
- // ── Init / teardown ─────────────────────────────────────────────────────────
84
-
85
- export function initOpenCodeAgent(
86
- cfg: TalonConfig,
87
- getGatewayPort?: () => number,
88
- frontend?: FrontendName,
89
- ): void {
90
- state.config = cfg;
91
- if (getGatewayPort) state.gatewayPortFn = getGatewayPort;
92
- if (frontend) state.frontendName = frontend;
93
- }
24
+ bindRemoteServer,
25
+ type RemoteModelSelection,
26
+ } from "../remote-server/server-bindings.js";
94
27
 
95
28
  /**
96
- * Callbacks run when the server stops — cache invalidation lives with the
97
- * caches. The models module registers its clear here at load time, so the
98
- * server never has to import it (which would be a cycle).
29
+ * Parse the stored model-selection string into a `{providerID?, modelID}`
30
+ * pair. The parser is fuzzy — it tries to extract a provider hint from the
31
+ * prefix while preserving the full model id when ambiguous. See
32
+ * `remote-server/model-catalog/` for the underlying `parseRemoteModelQuery`.
99
33
  */
100
- const stopHooks = new Set<() => void>();
101
-
102
- /** Register a callback to run whenever the OpenCode server is stopped. */
103
- export function onServerStop(hook: () => void): void {
104
- stopHooks.add(hook);
105
- }
106
-
107
- export function stopOpenCodeServer(): void {
108
- stopRemoteServer(state, () => {
109
- for (const hook of stopHooks) hook();
110
- });
111
- }
112
-
113
- // ── Server lifecycle ────────────────────────────────────────────────────────
114
-
115
- export function ensureServer(): Promise<OpencodeClient> {
116
- return ensureRemoteServerShared({
117
- state,
118
- createClient: createStrictOpencodeClient,
119
- createServer: ({ hostname, port, timeout }) =>
120
- createOpencodeServer({ hostname, port, timeout }),
121
- });
122
- }
123
-
124
- // ── MCP server registration ─────────────────────────────────────────────────
125
-
126
- export function ensureChatMcpServer(
127
- oc: OpencodeClient,
128
- chatId: string,
129
- ): Promise<string> {
130
- return ensureChatMcpServerShared(oc, state, chatId);
131
- }
132
-
133
- export function ensurePluginMcpServers(
134
- oc: OpencodeClient,
135
- chatId: string,
136
- ): Promise<string[]> {
137
- return ensurePluginMcpServersShared(oc, state, chatId);
138
- }
139
-
140
- export function buildToolOverrides(
141
- oc: OpencodeClient,
142
- chatServerName: string,
143
- pluginServerNames: readonly string[] = [],
144
- ): Promise<Record<string, boolean> | undefined> {
145
- return buildToolOverridesShared(oc, state, chatServerName, pluginServerNames);
146
- }
147
-
148
- export function disconnectChatMcpServer(
149
- oc: OpencodeClient,
150
- serverName: string,
151
- ): Promise<void> {
152
- return disconnectChatMcpServerShared(oc, state, serverName);
153
- }
154
-
155
- export async function refreshPluginMcpServers(chatId: string) {
156
- const oc = await ensureServer();
157
- return refreshPluginMcpServersShared(oc, state, chatId);
158
- }
159
-
160
- export function updateSystemPrompt(prompt: string): void {
161
- if (state.config) state.config.systemPrompt = prompt;
162
- }
163
-
164
- // ── Session management ─────────────────────────────────────────────────────
165
-
166
- export function ensureSession(
167
- oc: OpencodeClient,
168
- chatId: string,
169
- ): Promise<string> {
170
- return ensureRemoteSession(oc, state, chatId);
171
- }
172
-
173
- /**
174
- * Front-load a chat's cold start after `/reset`. Mirrors the Claude
175
- * backend's `warmSession` so the `sessions` capability slot behaves the
176
- * same across backends. Never throws — see `warmRemoteSession`.
177
- */
178
- export function warmSession(chatId: string): Promise<void> {
179
- return warmRemoteSession(state, chatId, {
180
- ensureServer,
181
- ensureSession,
182
- ensureChatMcpServer,
183
- ensurePluginMcpServers,
184
- });
185
- }
186
-
187
- // ── Provider resolution ────────────────────────────────────────────────────
188
-
189
- export function resolveProviderID(
190
- oc: OpencodeClient,
191
- modelID: string,
192
- ): Promise<string> {
193
- return resolveProviderIDShared(oc, state, modelID, {
194
- guessProviderID,
195
- getBucketPriority,
196
- });
197
- }
198
-
199
- /**
200
- * Parse the stored model-selection string into a `{providerID?, modelID}` pair.
201
- *
202
- * OpenCode model ids occasionally encode the provider as a `provider/model`
203
- * prefix (e.g. `openrouter/anthropic/claude-3.5-sonnet`). The parser here
204
- * is fuzzy — it tries to extract a provider hint from the prefix while
205
- * preserving the full model id when ambiguous. See `./models.ts` for the
206
- * underlying `parseOpenCodeModelQuery` logic.
207
- */
208
- export function parseStoredOpenCodeModelSelection(value: string): {
209
- providerID?: string;
210
- modelID: string;
211
- } {
212
- const { providerQuery, modelQuery } = parseOpenCodeModelQuery(value);
34
+ export function parseStoredOpenCodeModelSelection(
35
+ value: string,
36
+ ): RemoteModelSelection {
37
+ const { providerQuery, modelQuery } = parseRemoteModelQuery(value);
213
38
  return {
214
39
  providerID: providerQuery ? normalizeModelLookup(providerQuery) : undefined,
215
40
  modelID: modelQuery,
216
41
  };
217
42
  }
218
43
 
219
- // ── Internal accessors ─────────────────────────────────────────────────────
220
-
221
- export function getConfig(): TalonConfig {
222
- if (!state.config) {
223
- throw new Error(
224
- "OpenCode agent not initialized — call initOpenCodeAgent first",
225
- );
226
- }
227
- return state.config;
228
- }
229
-
230
- /**
231
- * Snapshot of the locally-cached MCP server registrations. Test-only.
232
- */
233
- export function getRegisteredMcpServerNames(): string[] {
234
- return getRegisteredMcpServerNamesShared(state);
235
- }
236
-
237
- const errMsg = sharedErrMsg;
238
-
239
- export { OPENCODE_BASE_URL, OPENCODE_SYSTEM_PROMPT_SUFFIX, errMsg };
44
+ // Text-preferred delivery: plain assistant text is the reply; tools only
45
+ // for genuine side effects. Single-sourced from the shared contract
46
+ // templates (prompts/system/contract-text-preferred.md).
47
+ const opencode = bindRemoteServer<OpencodeClient>({
48
+ label: "OpenCode",
49
+ defaultPort: 4096,
50
+ portEnv: "OPENCODE_PORT",
51
+ deliveryContract: "text-preferred",
52
+ createClient: (baseUrl) =>
53
+ createOpencodeClient({ baseUrl, throwOnError: true }),
54
+ createServer: ({ hostname, port, timeout }) =>
55
+ createOpencodeServer({ hostname, port, timeout }),
56
+ parseModelSelection: parseStoredOpenCodeModelSelection,
57
+ });
58
+
59
+ export const OPENCODE_BASE_URL = opencode.baseUrl;
60
+ export const opencodeSystemPromptSuffix = opencode.systemPromptSuffix;
61
+ export const OPENCODE_SYSTEM_PROMPT_SUFFIX = opencode.defaultSystemPromptSuffix;
62
+ export const initOpenCodeAgent = opencode.init;
63
+ export const stopOpenCodeServer = opencode.stop;
64
+ export const {
65
+ onServerStop,
66
+ ensureServer,
67
+ ensureChatMcpServer,
68
+ ensurePluginMcpServers,
69
+ buildToolOverrides,
70
+ disconnectChatMcpServer,
71
+ refreshPluginMcpServers,
72
+ updateSystemPrompt,
73
+ ensureSession,
74
+ warmSession,
75
+ resolveProviderID,
76
+ getConfig,
77
+ getRegisteredMcpServerNames,
78
+ errMsg,
79
+ } = opencode;