talon-agent 5.0.1 → 5.2.0

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 (107) hide show
  1. package/README.md +3 -1
  2. package/bin/talon.js +35 -0
  3. package/package.json +3 -3
  4. package/prompts/identity.md +10 -2
  5. package/prompts/system/agent-brief.md +43 -0
  6. package/src/app.ts +19 -26
  7. package/src/backend/builtins.ts +26 -7
  8. package/src/backend/claude-sdk/handler.ts +191 -69
  9. package/src/backend/claude-sdk/one-shot.ts +30 -7
  10. package/src/backend/claude-sdk/stream.ts +9 -0
  11. package/src/backend/codex/one-shot.ts +18 -4
  12. package/src/backend/remote-server/index.ts +6 -4
  13. package/src/backend/remote-server/model-catalog/index.ts +4 -10
  14. package/src/backend/remote-server/model-catalog/provider.ts +3 -3
  15. package/src/backend/remote-server/one-shot.ts +16 -3
  16. package/src/backend/remote-server/profiles/bind.ts +225 -0
  17. package/src/backend/remote-server/profiles/index.ts +10 -0
  18. package/src/backend/remote-server/profiles/kilo.ts +82 -0
  19. package/src/backend/remote-server/profiles/opencode.ts +61 -0
  20. package/src/backend/remote-server/server-bindings.ts +3 -4
  21. package/src/backend/runtime/one-shot-hooks.ts +45 -0
  22. package/src/bootstrap.ts +15 -1
  23. package/src/cli/chat.ts +5 -0
  24. package/src/cli/events.ts +9 -0
  25. package/src/core/agent-runtime/agent-host.ts +7 -6
  26. package/src/core/agent-runtime/capabilities.ts +3 -0
  27. package/src/core/agents/context.ts +48 -0
  28. package/src/core/agents/delivery.ts +167 -0
  29. package/src/core/agents/index.ts +37 -0
  30. package/src/core/agents/prompt.ts +116 -0
  31. package/src/core/agents/registry.ts +426 -0
  32. package/src/core/agents/runner.ts +448 -0
  33. package/src/core/agents/types.ts +124 -0
  34. package/src/core/background/cron/job-oneshot.ts +7 -12
  35. package/src/core/background/cron/job-prompt.ts +1 -1
  36. package/src/core/background/{cron/isolated-agent.ts → isolated-agent.ts} +45 -24
  37. package/src/core/background/run-log.ts +33 -0
  38. package/src/core/bus/events.ts +49 -2
  39. package/src/core/config/index.ts +25 -0
  40. package/src/core/engine/gateway-actions/agents/control.ts +299 -0
  41. package/src/core/engine/gateway-actions/agents/index.ts +31 -0
  42. package/src/core/engine/gateway-actions/agents/report.ts +107 -0
  43. package/src/core/engine/gateway-actions/index.ts +30 -0
  44. package/src/core/engine/gateway-actions/native/exec-remote.ts +1 -1
  45. package/src/core/engine/gateway-actions/native/exec.ts +1 -1
  46. package/src/core/engine/gateway-actions/native/read.ts +1 -1
  47. package/src/core/engine/gateway-actions/native/search.ts +1 -1
  48. package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
  49. package/src/core/engine/gateway-actions/native/write.ts +1 -1
  50. package/src/core/engine/gateway-routes.ts +12 -0
  51. package/src/core/engine/gateway.ts +96 -25
  52. package/src/core/frontend-runtime/capabilities.ts +18 -0
  53. package/src/core/frontend-runtime/index.ts +4 -0
  54. package/src/core/frontend-runtime/lifecycle.ts +33 -0
  55. package/src/core/frontend-runtime/registry.ts +3 -3
  56. package/src/core/frontend-runtime/run-loop.ts +59 -0
  57. package/src/core/mcp-hub/children.ts +21 -5
  58. package/src/core/mesh/{registry.ts → devices/registry.ts} +4 -4
  59. package/src/core/mesh/{service.ts → devices/service.ts} +13 -10
  60. package/src/core/mesh/{teleport.ts → devices/teleport.ts} +2 -2
  61. package/src/core/mesh/index.ts +6 -2
  62. package/src/core/mesh/{bridge-links.ts → links/bridge-links.ts} +1 -1
  63. package/src/core/mesh/{companion-pairing.ts → links/companion-pairing.ts} +1 -1
  64. package/src/core/mesh/{node-binaries.ts → links/node-binaries.ts} +5 -5
  65. package/src/core/mesh/{node-provision.ts → links/node-provision.ts} +1 -1
  66. package/src/core/mesh/{common.ts → tool-surface.ts} +7 -2
  67. package/src/core/mesh/{device-files.ts → transfers/device-files.ts} +5 -5
  68. package/src/core/prompt/embedded-prompts.ts +38 -36
  69. package/src/core/tasks/types.ts +2 -2
  70. package/src/core/tools/index.ts +5 -0
  71. package/src/core/tools/ops/agents.ts +195 -0
  72. package/src/core/tools/ops/bridge.ts +4 -0
  73. package/src/core/tools/types.ts +1 -0
  74. package/src/core/types.ts +12 -1
  75. package/src/frontend/discord/commands/info.ts +1 -1
  76. package/src/frontend/discord/render.ts +1 -1
  77. package/src/frontend/native/bridge/routes/mesh.ts +1 -1
  78. package/src/frontend/native/index.ts +2 -0
  79. package/src/frontend/presentation/reports.ts +1 -1
  80. package/src/frontend/teams/index.ts +4 -3
  81. package/src/frontend/telegram/commands/info.ts +61 -20
  82. package/src/frontend/telegram/index.ts +27 -4
  83. package/src/frontend/telegram/render/reports.ts +1 -1
  84. package/src/frontend/terminal/index.ts +6 -2
  85. package/src/frontend/whatsapp/connection/connection.ts +62 -11
  86. package/src/frontend/whatsapp/index.ts +25 -1
  87. package/src/frontend/whatsapp/runtime.ts +7 -0
  88. package/src/util/log.ts +1 -0
  89. package/src/backend/kilo/factory.ts +0 -53
  90. package/src/backend/kilo/handler/index.ts +0 -2
  91. package/src/backend/kilo/handler/message.ts +0 -44
  92. package/src/backend/kilo/index.ts +0 -61
  93. package/src/backend/kilo/model-provider.ts +0 -36
  94. package/src/backend/kilo/models/index.ts +0 -55
  95. package/src/backend/kilo/one-shot.ts +0 -42
  96. package/src/backend/kilo/server.ts +0 -98
  97. package/src/backend/kilo/sessions.ts +0 -37
  98. package/src/backend/opencode/factory.ts +0 -53
  99. package/src/backend/opencode/handler/index.ts +0 -2
  100. package/src/backend/opencode/handler/message.ts +0 -44
  101. package/src/backend/opencode/index.ts +0 -42
  102. package/src/backend/opencode/model-provider.ts +0 -36
  103. package/src/backend/opencode/models/index.ts +0 -54
  104. package/src/backend/opencode/one-shot.ts +0 -42
  105. package/src/backend/opencode/server.ts +0 -80
  106. package/src/backend/opencode/sessions.ts +0 -35
  107. /package/src/core/mesh/{transfers.ts → transfers/transfers.ts} +0 -0
@@ -19,7 +19,9 @@ import { log, logWarn } from "../../util/log.js";
19
19
  import { ALLOWED_TOOLS_BACKGROUND } from "../../core/constants.js";
20
20
  import { EFFORT_MAP } from "./constants.js";
21
21
  import { buildMcpServers, buildPluginMcpServers } from "./options.js";
22
+ import { isBackgroundToolContext } from "../../core/agents/context.js";
22
23
  import { warnIfBelowCacheMinimum } from "../runtime/cache/cache-telemetry.js";
24
+ import { emitAssistantText } from "../runtime/one-shot-hooks.js";
23
25
 
24
26
  const DEFAULT_SUBPROCESS_KILL_GRACE_MS = 5 * 1000;
25
27
 
@@ -65,6 +67,7 @@ export async function runOneShotAgent(
65
67
  contextLabel,
66
68
  abortController,
67
69
  appendLog,
70
+ onAssistantText,
68
71
  } = params;
69
72
 
70
73
  // Reasoning effort is opt-in for background runs (config `heartbeatEffort`
@@ -126,7 +129,7 @@ export async function runOneShotAgent(
126
129
  // settlement figure the task table records.
127
130
  let usage: OneShotUsage | undefined;
128
131
  for await (const msg of qi) {
129
- await formatAndAppendMessage(appendLog, msg);
132
+ await formatAndAppendMessage(appendLog, msg, onAssistantText);
130
133
  if (msg.type === "result") {
131
134
  const u = msg.usage;
132
135
  usage = {
@@ -142,8 +145,12 @@ export async function runOneShotAgent(
142
145
 
143
146
  /**
144
147
  * Per-context MCP server selection.
145
- * - "heartbeat": frontend tools + all loaded plugins (full surface so the
146
- * heartbeat agent can post messages, react, read history, etc.).
148
+ * - background tool contexts (`heartbeat`, and every `agent:<id>` sub-agent
149
+ * run — see `core/agents/context.ts`): frontend tools + all loaded plugins,
150
+ * the full surface these runs need to post messages, react, read history
151
+ * and reach their own agent tools. The servers are keyed by the context
152
+ * label itself, so each sub-agent gets its own hub session and its tool
153
+ * calls arrive at the gateway identified as that agent.
147
154
  * - "dream": only mempalace (when configured) — dream is a memory
148
155
  * consolidation pass and shouldn't be doing outbound messaging.
149
156
  * - anything else: empty (treat unknown contexts as plugin-free).
@@ -154,16 +161,19 @@ export async function runOneShotAgent(
154
161
  * servers still load and the agent runs normally.
155
162
  */
156
163
  function assembleMcpServers(contextLabel: string): Record<string, unknown> {
157
- if (contextLabel === "heartbeat") {
164
+ if (isBackgroundToolContext(contextLabel)) {
158
165
  let frontendServers: Record<string, unknown> = {};
159
166
  try {
160
- frontendServers = buildMcpServers("heartbeat") as Record<string, unknown>;
167
+ frontendServers = buildMcpServers(contextLabel) as Record<
168
+ string,
169
+ unknown
170
+ >;
161
171
  } catch {
162
172
  frontendServers = {};
163
173
  }
164
174
  let pluginServers: Record<string, unknown> = {};
165
175
  try {
166
- pluginServers = buildPluginMcpServers("heartbeat");
176
+ pluginServers = buildPluginMcpServers(contextLabel);
167
177
  } catch {
168
178
  pluginServers = {};
169
179
  }
@@ -183,6 +193,7 @@ function assembleMcpServers(contextLabel: string): Record<string, unknown> {
183
193
  async function formatAndAppendMessage(
184
194
  appendLog: (text: string) => Promise<void>,
185
195
  msg: SDKMessage,
196
+ onAssistantText?: OneShotAgentParams["onAssistantText"],
186
197
  ): Promise<void> {
187
198
  try {
188
199
  const ts = new Date().toISOString().slice(11, 19);
@@ -200,7 +211,11 @@ async function formatAndAppendMessage(
200
211
  });
201
212
 
202
213
  if (textBlocks.length > 0) {
203
- await appendLog(`\n## [${ts}] Assistant\n${textBlocks.join("\n")}\n`);
214
+ const assistantText = textBlocks.join("\n");
215
+ // Report before the log write: an append failure (full disk, closed
216
+ // handle) must not also swallow the run's result for a hook caller.
217
+ emitAssistantText(onAssistantText, assistantText);
218
+ await appendLog(`\n## [${ts}] Assistant\n${assistantText}\n`);
204
219
  }
205
220
  if (toolUseBlocks.length > 0) {
206
221
  await appendLog(`\n${toolUseBlocks.join("\n\n")}\n`);
@@ -208,6 +223,14 @@ async function formatAndAppendMessage(
208
223
  break;
209
224
  }
210
225
  case "result": {
226
+ // Deliberately NOT reported through `onAssistantText`. The SDK's
227
+ // terminal `result` message restates the last assistant turn's text
228
+ // (`subtype: "success"`) or carries an error string (the
229
+ // `error_*` subtypes) — never anything the `assistant` case above
230
+ // has not already emitted. Reporting it too would hand every hook
231
+ // caller a duplicate final segment, and the truncated copy at that
232
+ // (2000 chars, below). The log keeps it because a run log wants the
233
+ // settlement line; a sub-agent result does not.
211
234
  const result =
212
235
  "result" in msg
213
236
  ? (msg as { result: string }).result
@@ -422,6 +422,15 @@ function readResultError(msg: SDKResultMessage, state: StreamState): void {
422
422
  .filter((e) => typeof e === "string" && !e.startsWith("[ede_diagnostic]"))
423
423
  .join("; ")
424
424
  .slice(0, 500);
425
+ // A known startup failure (SDK ≥ 0.3.274) names its cause — the CLI
426
+ // never ran a turn, so nothing else in the result says why. Lead with
427
+ // it; the diagnostics text is the same line stderr carried.
428
+ if (msg.startup_failure_reason) {
429
+ state.resultErrorText =
430
+ `Claude Code failed to start (${msg.startup_failure_reason})` +
431
+ (diagnostics ? `: ${diagnostics}` : "");
432
+ return;
433
+ }
425
434
  state.resultErrorText =
426
435
  state.lastTrailingText.trim() ||
427
436
  diagnostics ||
@@ -20,6 +20,7 @@
20
20
  import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
21
21
  import { log, logWarn } from "../../util/log.js";
22
22
  import { appendBackendSuffix } from "../runtime/index.js";
23
+ import { emitAssistantText } from "../runtime/one-shot-hooks.js";
23
24
  import { ensureCodex, getCodexAuthInfo } from "./init.js";
24
25
  import {
25
26
  CODEX_SYSTEM_PROMPT_SUFFIX,
@@ -79,6 +80,7 @@ export async function runOneShotAgent(
79
80
  contextLabel,
80
81
  abortController,
81
82
  appendLog,
83
+ onAssistantText,
82
84
  } = params;
83
85
 
84
86
  const codex = ensureCodex(contextLabel);
@@ -143,7 +145,7 @@ export async function runOneShotAgent(
143
145
  let usage: OneShotUsage | undefined;
144
146
  for await (const event of events) {
145
147
  if (abortController.signal.aborted) break;
146
- await appendCodexEvent(appendLog, event);
148
+ await appendCodexEvent(appendLog, event, onAssistantText);
147
149
  if (event.type === "turn.completed") {
148
150
  const u = (event as { usage?: Record<string, number> }).usage;
149
151
  if (u) {
@@ -217,6 +219,7 @@ export async function runOneShotAgent(
217
219
  async function appendCodexEvent(
218
220
  appendLog: (text: string) => Promise<void>,
219
221
  event: { type: string } & Record<string, unknown>,
222
+ onAssistantText?: OneShotAgentParams["onAssistantText"],
220
223
  ): Promise<void> {
221
224
  const ts = new Date().toISOString().slice(11, 19);
222
225
 
@@ -260,7 +263,7 @@ async function appendCodexEvent(
260
263
  case "item.completed": {
261
264
  const item = (event as unknown as { item?: Record<string, unknown> })
262
265
  .item;
263
- if (item) await appendCodexItem(appendLog, item, ts);
266
+ if (item) await appendCodexItem(appendLog, item, ts, onAssistantText);
264
267
  return;
265
268
  }
266
269
  default:
@@ -268,17 +271,28 @@ async function appendCodexEvent(
268
271
  }
269
272
  }
270
273
 
271
- /** Append one `ThreadItem` to the run log. */
274
+ /**
275
+ * Append one `ThreadItem` to the run log, and report the model's final
276
+ * answers to the run's optional `onAssistantText` consumer.
277
+ *
278
+ * Only `agent_message` items are reported: `reasoning` items are the model's
279
+ * thinking and the rest are tool/command/diff payloads, none of which is the
280
+ * run's answer.
281
+ */
272
282
  async function appendCodexItem(
273
283
  appendLog: (text: string) => Promise<void>,
274
284
  item: Record<string, unknown>,
275
285
  ts: string,
286
+ onAssistantText?: OneShotAgentParams["onAssistantText"],
276
287
  ): Promise<void> {
277
288
  const type = typeof item.type === "string" ? item.type : "unknown";
278
289
 
279
290
  if (type === "agent_message") {
280
291
  const text = typeof item.text === "string" ? item.text : "";
281
- if (text) await appendLog(`\n## [${ts}] Assistant\n${text}\n`);
292
+ if (text) {
293
+ emitAssistantText(onAssistantText, text);
294
+ await appendLog(`\n## [${ts}] Assistant\n${text}\n`);
295
+ }
282
296
  return;
283
297
  }
284
298
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared remote-server backend framework — barrel re-export.
3
3
  *
4
- * Helpers used by `backend/opencode` and `backend/kilo` (both wrap a
4
+ * Helpers used by the OpenCode and Kilo drivers (both wrap a
5
5
  * long-running upstream agent server that exposes a common HTTP API
6
6
  * for MCP registration, session lifecycle, tool listing, and provider
7
7
  * resolution).
@@ -21,9 +21,11 @@
21
21
  * the chat-turn orchestration, and the registry factory composition.
22
22
  * This is where the code that used to be copied per backend lives.
23
23
  *
24
- * - Concrete backends (`backend/opencode`, `backend/kilo`) — a
25
- * `RemoteBackendProfile` (SDK constructors, port, delivery contract,
26
- * model-selection parser) plus re-exports under historical names.
24
+ * - Profiles (`profiles/bind.ts` + `profiles/{kilo,opencode}.ts`) —
25
+ * `bindRemoteProfile` closes all of the above over one driver's
26
+ * state, and each driver is one file of constants: SDK
27
+ * constructors, port, delivery contract, model-selection parser,
28
+ * model-picker budget.
27
29
  *
28
30
  * What's NOT here (intentionally):
29
31
  *
@@ -3,9 +3,9 @@
3
3
  * family (OpenCode + its Kilo fork). Both servers expose identical
4
4
  * `/provider/list` + `/provider/auth` wire formats, so the catalog cache,
5
5
  * query resolution, presentation, and the `Backend.models` adapter live here
6
- * once. Each backend calls `createRemoteModelCatalogModule` with its own SDK
7
- * client + branding/UI knobs and re-exports the bound functions under its
8
- * historical names.
6
+ * once. `profiles/bind.ts` calls `createRemoteModelCatalogModule` with each
7
+ * driver's SDK client + branding/picker knobs and hangs the result off that
8
+ * profile.
9
9
  */
10
10
 
11
11
  import {
@@ -23,13 +23,7 @@ import type {
23
23
  RemoteProviderClient,
24
24
  } from "./types.js";
25
25
 
26
- export type {
27
- ModelButton,
28
- RemoteModelCatalog,
29
- RemoteModelCatalogEntry,
30
- RemoteModelResolution,
31
- } from "./types.js";
32
- export { sortCatalogModels } from "./catalog.js";
26
+ export type { RemoteProviderClient } from "./types.js";
33
27
  export {
34
28
  getBucketPriority,
35
29
  getRemoteModelSelectionValue,
@@ -3,9 +3,9 @@
3
3
  * interface (resolveModel, getModelInfo, getProviders, …).
4
4
  *
5
5
  * One implementation for the whole OpenCode family. The catalog surface is
6
- * injected (rather than captured from the module factory) so each backend's
7
- * `model-provider.ts` binds its own `models/index.js` — which also keeps that
8
- * module the single seam tests mock.
6
+ * injected (rather than captured from the module factory) so
7
+ * `profiles/bind.ts` can point it at that driver's own cached catalog —
8
+ * and so a test can point it at a static fixture without a module mock.
9
9
  */
10
10
 
11
11
  import type {
@@ -42,6 +42,7 @@ import {
42
42
  type RemoteAssistantInfo,
43
43
  } from "./session-helpers.js";
44
44
  import { appendBackendSuffix, sleep } from "../runtime/index.js";
45
+ import { emitAssistantText } from "../runtime/one-shot-hooks.js";
45
46
  import { buildPermissionRuleset } from "./sessions.js";
46
47
 
47
48
  // ── Client surface ──────────────────────────────────────────────────────────
@@ -108,6 +109,7 @@ export async function runRemoteOneShotAgent<
108
109
  contextLabel,
109
110
  abortController,
110
111
  appendLog,
112
+ onAssistantText,
111
113
  } = params;
112
114
  const { label, errMsg } = bindings;
113
115
 
@@ -210,7 +212,7 @@ export async function runRemoteOneShotAgent<
210
212
  : [];
211
213
 
212
214
  for (const part of parts) {
213
- await appendResponsePart(appendLog, part);
215
+ await appendResponsePart(appendLog, part, onAssistantText);
214
216
  }
215
217
 
216
218
  // The prompt response's assistant info carries the run's token usage —
@@ -250,17 +252,28 @@ export async function runRemoteOneShotAgent<
250
252
 
251
253
  // ── Run-log rendering ───────────────────────────────────────────────────────
252
254
 
253
- /** Render one response part into the Markdown run log. */
255
+ /**
256
+ * Render one response part into the Markdown run log, and report the
257
+ * assistant's final text to the run's optional `onAssistantText` consumer.
258
+ *
259
+ * Only the `text` parts are reported: `reasoning` parts are the model's
260
+ * thinking and tool parts are call payloads, neither of which is the run's
261
+ * answer.
262
+ */
254
263
  async function appendResponsePart(
255
264
  appendLog: (text: string) => Promise<void>,
256
265
  part: Record<string, unknown>,
266
+ onAssistantText?: OneShotAgentParams["onAssistantText"],
257
267
  ): Promise<void> {
258
268
  const ts = new Date().toISOString().slice(11, 19);
259
269
  const type = typeof part.type === "string" ? part.type : "unknown";
260
270
 
261
271
  if (type === "text") {
262
272
  const text = typeof part.text === "string" ? part.text : "";
263
- if (text) await appendLog(`\n## [${ts}] Assistant\n${text}\n`);
273
+ if (text) {
274
+ emitAssistantText(onAssistantText, text);
275
+ await appendLog(`\n## [${ts}] Assistant\n${text}\n`);
276
+ }
264
277
  return;
265
278
  }
266
279
 
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Bind one remote-server driver from a profile.
3
+ *
4
+ * Everything a member of this family does — spawn or reuse the local
5
+ * server, register the chat and plugin MCP servers, create sessions,
6
+ * fetch and render the model catalog, run a chat turn, run a one-shot
7
+ * turn, read back a session snapshot — is shared code in
8
+ * `backend/remote-server/`. What differs between OpenCode and its Kilo
9
+ * fork is a short list of constants and two SDK constructors.
10
+ *
11
+ * `bindRemoteProfile` is where that list becomes a driver. It closes the
12
+ * shared helpers over one profile's state and returns a single object
13
+ * that is simultaneously:
14
+ *
15
+ * - the {@link RemoteServerBindings} the shared turn paths need,
16
+ * - the bound model catalog + `Backend.models` adapter, and
17
+ * - a `RemoteBackendFactoryInputs`, ready for
18
+ * `createRemoteBackendFactory` in `backend/builtins.ts`.
19
+ *
20
+ * Before this module each backend carried a `server.ts`, `sessions.ts`,
21
+ * `models/index.ts`, `model-provider.ts`, `handler/message.ts`,
22
+ * `one-shot.ts`, `index.ts` and `factory.ts` whose entire content was
23
+ * re-exporting these bindings under backend-prefixed names. Those 16
24
+ * files are the profile objects in `./kilo.ts` and `./opencode.ts` now.
25
+ */
26
+
27
+ import type { BackendId } from "../../../core/agent-runtime/model-ref.js";
28
+ import type { OneShotAgentParams, OneShotUsage } from "../../../core/types.js";
29
+ import type {
30
+ QueryParams,
31
+ QueryResult,
32
+ } from "../../runtime/turn/handler-types.js";
33
+ import type { DeliveryMode } from "../../runtime/prompt/delivery-contract.js";
34
+ import { runRemoteChatTurn } from "../chat-turn.js";
35
+ import type { RemoteAgentClient } from "../client.js";
36
+ import type { RemoteBackendFactoryInputs } from "../factory.js";
37
+ import {
38
+ createRemoteModelCatalogModule,
39
+ createRemoteModelProvider,
40
+ formatRemoteUnavailableModel,
41
+ getRemoteModelSelectionValue,
42
+ resolveRemoteModelInput,
43
+ type RemoteModelCatalogModule,
44
+ type RemoteProviderClient,
45
+ } from "../model-catalog/index.js";
46
+ import {
47
+ runRemoteOneShotAgent,
48
+ type RemoteOneShotClient,
49
+ } from "../one-shot.js";
50
+ import {
51
+ bindRemoteServer,
52
+ type RemoteModelSelection,
53
+ type RemoteServerBindings,
54
+ } from "../server-bindings.js";
55
+ import {
56
+ getSessionSnapshot,
57
+ type RemoteSessionClient,
58
+ } from "../session-helpers.js";
59
+
60
+ /**
61
+ * The client shape a profile's SDK must satisfy: the shared helper
62
+ * surface, the provider catalog, and the session lifecycle the one-shot
63
+ * runner drives directly. Both `OpencodeClient` and `KiloClient` match
64
+ * structurally.
65
+ */
66
+ export type RemoteProfileClient = RemoteAgentClient &
67
+ RemoteOneShotClient &
68
+ RemoteProviderClient;
69
+
70
+ /** Everything that differs between two members of this family. */
71
+ export interface RemoteProfileDefinition<TClient extends RemoteProfileClient> {
72
+ /** Registry id — matches `config.backend` ("kilo"). */
73
+ id: BackendId;
74
+ /** Display label for log lines, headers and error text ("Kilo"). */
75
+ label: string;
76
+ /** npm package of the SDK, for the startup log line. */
77
+ sdkPackage: string;
78
+ /** Loopback port the local server listens on by default. */
79
+ defaultPort: number;
80
+ /**
81
+ * Env var that overrides the port, so integration tests can spawn an
82
+ * isolated server alongside a running production Talon that holds the
83
+ * default.
84
+ */
85
+ portEnv: string;
86
+ /** Delivery contract the system-prompt suffix carries. */
87
+ deliveryContract: DeliveryMode;
88
+ /** Strict SDK client over an already-running server URL. */
89
+ createClient(baseUrl: string): TClient;
90
+ /** Spawn a fresh local server; `close()` runs from `stop()`. */
91
+ createServer(args: {
92
+ hostname: string;
93
+ port: number;
94
+ timeout: number;
95
+ }): Promise<{ url: string; close(): void }>;
96
+ /**
97
+ * Split a stored model-selection string into provider/model ids. The
98
+ * one genuinely behavioural knob: the two upstream routers disagree
99
+ * about what a `provider/model` prefix means.
100
+ */
101
+ parseModelSelection(value: string): RemoteModelSelection;
102
+ /**
103
+ * Model-picker budget, set by the surface the picker renders through.
104
+ * Discord StringSelectMenu values hold 100 chars of anything; Telegram
105
+ * `callback_data` holds 64 bytes and the keyboard is tight.
106
+ */
107
+ maxCallbackIdLength: number;
108
+ allowCallbackSeparators: boolean;
109
+ quickPickLimit: number;
110
+ }
111
+
112
+ /**
113
+ * A bound driver: the server bindings, the catalog module, and the
114
+ * registry factory inputs, in one object.
115
+ */
116
+ export type RemoteProfile<TClient extends RemoteProfileClient> =
117
+ RemoteServerBindings<TClient> &
118
+ RemoteBackendFactoryInputs & {
119
+ /** The bound catalog — cache, resolution, and picker rendering. */
120
+ catalog: RemoteModelCatalogModule;
121
+ /**
122
+ * The knobs this driver was built from. Kept on the result so a
123
+ * live-backend test can rebind the catalog to its own throwaway
124
+ * server without restating the profile's picker budget.
125
+ */
126
+ definition: RemoteProfileDefinition<TClient>;
127
+ };
128
+
129
+ export function bindRemoteProfile<TClient extends RemoteProfileClient>(
130
+ definition: RemoteProfileDefinition<TClient>,
131
+ ): RemoteProfile<TClient> {
132
+ const { id, label, sdkPackage } = definition;
133
+
134
+ const server = bindRemoteServer<TClient>({
135
+ label,
136
+ defaultPort: definition.defaultPort,
137
+ portEnv: definition.portEnv,
138
+ deliveryContract: definition.deliveryContract,
139
+ createClient: definition.createClient,
140
+ createServer: definition.createServer,
141
+ parseModelSelection: definition.parseModelSelection,
142
+ });
143
+
144
+ const catalog = createRemoteModelCatalogModule({
145
+ label,
146
+ getClient: () => server.ensureServer(),
147
+ maxCallbackIdLength: definition.maxCallbackIdLength,
148
+ allowCallbackSeparators: definition.allowCallbackSeparators,
149
+ quickPickLimit: definition.quickPickLimit,
150
+ });
151
+ // A stopped server invalidates the catalog it served.
152
+ server.onServerStop(catalog.clearCache);
153
+
154
+ const models = createRemoteModelProvider({
155
+ label,
156
+ getCatalog: (forceRefresh) => catalog.getCatalog(forceRefresh),
157
+ getModelInfo: (modelId) => catalog.getModelInfo(modelId),
158
+ resolveModelInput: (query, cat) => resolveRemoteModelInput(query, cat),
159
+ getSelectionValue: (model, cat) => getRemoteModelSelectionValue(model, cat),
160
+ formatUnavailableModel: (model) => formatRemoteUnavailableModel(model),
161
+ getSettingsPresentation: (activeModel, pickerOptions) =>
162
+ catalog.getSettingsPresentation(activeModel, pickerOptions),
163
+ });
164
+
165
+ const handleMessage = (params: QueryParams): Promise<QueryResult> =>
166
+ runRemoteChatTurn(
167
+ {
168
+ id,
169
+ label,
170
+ getConfig: server.getConfig,
171
+ ensureServer: server.ensureServer,
172
+ trackActiveTurn: server.trackActiveTurn,
173
+ parseModelSelection: server.parseModelSelection,
174
+ resolveProviderID: server.resolveProviderID,
175
+ ensureSession: server.ensureSession,
176
+ ensureChatMcpServer: server.ensureChatMcpServer,
177
+ ensurePluginMcpServers: server.ensurePluginMcpServers,
178
+ buildToolOverrides: server.buildToolOverrides,
179
+ systemPromptSuffix: server.systemPromptSuffix,
180
+ },
181
+ params,
182
+ );
183
+
184
+ const runOneShotAgent = (
185
+ params: OneShotAgentParams,
186
+ ): Promise<OneShotUsage | void> =>
187
+ runRemoteOneShotAgent(
188
+ {
189
+ label,
190
+ // The one-shot runner has no frontend in hand (heartbeat and
191
+ // dream are cross-surface), so it carries the telegram-shaped
192
+ // suffix — the same one the per-backend runners passed.
193
+ systemPromptSuffix: server.defaultSystemPromptSuffix,
194
+ ensureServer: server.ensureServer,
195
+ parseModelSelection: server.parseModelSelection,
196
+ resolveProviderID: server.resolveProviderID,
197
+ ensureChatMcpServer: server.ensureChatMcpServer,
198
+ ensurePluginMcpServers: server.ensurePluginMcpServers,
199
+ buildToolOverrides: server.buildToolOverrides,
200
+ disconnectChatMcpServer: server.disconnectChatMcpServer,
201
+ errMsg: server.errMsg,
202
+ },
203
+ params,
204
+ );
205
+
206
+ return {
207
+ ...server,
208
+ id,
209
+ label,
210
+ sdkPackage,
211
+ definition,
212
+ catalog,
213
+ models,
214
+ handleMessage,
215
+ runOneShotAgent,
216
+ async getSessionSnapshot(sessionId) {
217
+ if (!sessionId) return undefined;
218
+ const oc = await server.ensureServer();
219
+ return getSessionSnapshot(
220
+ oc as unknown as RemoteSessionClient,
221
+ sessionId,
222
+ );
223
+ },
224
+ };
225
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Remote-server profiles — the drivers built from `./bind.ts`.
3
+ *
4
+ * One import for `backend/builtins.ts`, which is the only place that
5
+ * lists them (structure rule 4). Adding a member of this family is
6
+ * adding a profile module here and a line there.
7
+ */
8
+
9
+ export { kiloProfile } from "./kilo.js";
10
+ export { opencodeProfile } from "./opencode.js";
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Kilo — a remote-server profile.
3
+ *
4
+ * Kilo is a fork of OpenCode and exposes the same HTTP API, so the whole
5
+ * driver is `bindRemoteProfile` over the constants below: the
6
+ * `@kilocode/sdk` constructors, port 4097, the text-or-tools delivery
7
+ * contract, the `kilo/`-prefix model parser, and the Discord-sized model
8
+ * picker budget.
9
+ *
10
+ * Kilo delivery model: the reply reaches the user either as a `text`
11
+ * part (what most Kilo-routed models emit by default — DeepSeek, GLM,
12
+ * openrouter routes) or through a delivery tool (`end_turn` / `send` /
13
+ * `react`) when reply-to targeting, buttons, photos, or polls are
14
+ * needed. Both routes work; the shared text-or-tools contract documents
15
+ * the choice.
16
+ *
17
+ * Note: the model catalog's internal type names are `Remote*` and its
18
+ * wire shape is OpenCode's — Kilo's provider-bucket API is forked from
19
+ * it, so the names match what the upstream actually emits.
20
+ */
21
+
22
+ import {
23
+ createKiloClient,
24
+ createKiloServer,
25
+ type KiloClient,
26
+ } from "@kilocode/sdk/v2";
27
+ import type { RemoteModelSelection } from "../server-bindings.js";
28
+ import { bindRemoteProfile, type RemoteProfile } from "./bind.js";
29
+
30
+ /**
31
+ * Parse the stored model-selection string into a `{providerID?, modelID}`
32
+ * pair.
33
+ *
34
+ * Kilo model ids frequently contain `/` and `:` inside the model.id itself
35
+ * (e.g. `inclusionai/ling-2.6-1t:free`, `deepseek/deepseek-v4-flash:free`).
36
+ * A naive `provider/model` splitter mis-treats those vendor prefixes as
37
+ * the provider, so we generally return the whole string as the model id
38
+ * and let `resolveProviderID` look up the real provider from the live
39
+ * catalog.
40
+ *
41
+ * Exception: if the value starts with the literal `kilo/` prefix
42
+ * (Talon's old hint that "this is a kilo-routed model"), strip it AND
43
+ * pin providerID to `"kilo"`. Otherwise the upstream Kilo router gets
44
+ * `kilo/deepseek/deepseek-v4-flash:free` as the model id and concats
45
+ * its own provider in front, producing
46
+ * `Model not found: opencode/kilo/deepseek/deepseek-v4-flash:free`.
47
+ */
48
+ function parseStoredKiloModelSelection(value: string): RemoteModelSelection {
49
+ const trimmed = value.trim();
50
+ if (trimmed.startsWith("kilo/")) {
51
+ return {
52
+ providerID: "kilo",
53
+ modelID: trimmed.slice("kilo/".length),
54
+ };
55
+ }
56
+ return {
57
+ providerID: undefined,
58
+ modelID: trimmed,
59
+ };
60
+ }
61
+
62
+ export const kiloProfile: RemoteProfile<KiloClient> =
63
+ bindRemoteProfile<KiloClient>({
64
+ id: "kilo",
65
+ label: "Kilo",
66
+ sdkPackage: "@kilocode/sdk",
67
+ defaultPort: 4097,
68
+ portEnv: "KILO_PORT",
69
+ deliveryContract: "text-or-tools",
70
+ createClient: (baseUrl) =>
71
+ createKiloClient({ baseUrl, throwOnError: true }),
72
+ createServer: ({ hostname, port, timeout }) =>
73
+ createKiloServer({ hostname, port, timeout }),
74
+ parseModelSelection: parseStoredKiloModelSelection,
75
+ // Kilo's model picker renders through Discord StringSelectMenus,
76
+ // which allow 25 options and values up to 100 chars with any
77
+ // characters — Kilo ids routinely contain "/" and ":" (e.g.
78
+ // "inclusionai/ling-2.6-1t:free").
79
+ maxCallbackIdLength: 90,
80
+ allowCallbackSeparators: true,
81
+ quickPickLimit: 24,
82
+ });