talon-agent 3.34.0 → 3.35.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 (70) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +0 -10
  3. package/package.json +6 -4
  4. package/src/backend/claude-sdk/handler.ts +428 -415
  5. package/src/backend/codex/auth.ts +40 -1
  6. package/src/backend/codex/handler/message.ts +290 -366
  7. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  8. package/src/backend/codex/plan-usage.ts +27 -2
  9. package/src/backend/kilo/handler/message.ts +2 -0
  10. package/src/backend/kilo/server.ts +1 -0
  11. package/src/backend/openai-agents/handler/message.ts +282 -356
  12. package/src/backend/opencode/handler/message.ts +2 -0
  13. package/src/backend/opencode/server.ts +1 -0
  14. package/src/backend/remote-server/chat-turn.ts +76 -122
  15. package/src/backend/remote-server/index.ts +4 -0
  16. package/src/backend/remote-server/lifecycle.ts +37 -0
  17. package/src/backend/remote-server/mcp.ts +73 -9
  18. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  19. package/src/backend/remote-server/server-bindings.ts +12 -0
  20. package/src/backend/remote-server/sessions.ts +2 -2
  21. package/src/backend/remote-server/state.ts +8 -0
  22. package/src/backend/remote-server/turn.ts +93 -23
  23. package/src/backend/shared/cache-telemetry.ts +17 -1
  24. package/src/backend/shared/handler-to-events.ts +11 -16
  25. package/src/backend/shared/index.ts +14 -15
  26. package/src/backend/shared/result-events.ts +30 -0
  27. package/src/backend/shared/turn-phases.ts +277 -0
  28. package/src/bootstrap.ts +24 -10
  29. package/src/core/auth/expiry-monitor.ts +89 -0
  30. package/src/core/auth/login-flow.ts +247 -0
  31. package/src/core/auth/status.ts +193 -0
  32. package/src/core/background/triggers/exit.ts +19 -2
  33. package/src/core/background/triggers/resume.ts +14 -7
  34. package/src/core/background/triggers/state.ts +9 -0
  35. package/src/core/engine/gateway-actions/history.ts +12 -1
  36. package/src/core/errors.ts +1 -1
  37. package/src/core/mcp-hub/child-transport.ts +215 -0
  38. package/src/core/mcp-hub/children.ts +72 -13
  39. package/src/core/mcp-hub/index.ts +32 -13
  40. package/src/core/models/active-model.ts +29 -6
  41. package/src/core/tools/history.ts +10 -1
  42. package/src/core/weaver/shuttle.ts +4 -0
  43. package/src/frontend/shared/model-commands.ts +400 -0
  44. package/src/frontend/telegram/admin.ts +75 -52
  45. package/src/frontend/telegram/auth-panel.ts +205 -0
  46. package/src/frontend/telegram/callbacks/auth.ts +70 -0
  47. package/src/frontend/telegram/callbacks/index.ts +16 -0
  48. package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
  49. package/src/frontend/telegram/commands/auth.ts +58 -0
  50. package/src/frontend/telegram/commands/definitions.ts +4 -0
  51. package/src/frontend/telegram/commands/index.ts +10 -4
  52. package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
  53. package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
  54. package/src/frontend/whatsapp/actions/history.ts +100 -0
  55. package/src/frontend/whatsapp/actions/index.ts +4 -1
  56. package/src/frontend/whatsapp/commands.ts +371 -0
  57. package/src/frontend/whatsapp/inbound.ts +24 -46
  58. package/src/frontend/whatsapp/index.ts +12 -1
  59. package/src/frontend/whatsapp/media-store.ts +20 -1
  60. package/src/frontend/whatsapp/message-store.ts +101 -24
  61. package/src/storage/history.ts +22 -1
  62. package/src/storage/repositories/history-repo.ts +14 -0
  63. package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
  64. package/src/storage/sql/history.sql +12 -0
  65. package/src/storage/sql/schema.sql +21 -0
  66. package/src/storage/sql/statements.generated.ts +50 -1
  67. package/src/storage/sql/whatsapp-messages.sql +29 -0
  68. package/src/storage/trigger-store.ts +8 -0
  69. package/src/storage/whatsapp-messages.ts +51 -0
  70. package/src/util/watchdog.ts +32 -7
@@ -115,261 +115,302 @@ export interface RemoteModelPresentation {
115
115
  ): string;
116
116
  }
117
117
 
118
- export function createRemoteModelPresentation(
119
- options: RemotePresentationOptions,
120
- ): RemoteModelPresentation {
121
- const {
122
- label,
123
- getCatalog,
124
- maxCallbackIdLength,
125
- allowCallbackSeparators,
126
- quickPickLimit,
127
- groupThreshold = 60,
128
- } = options;
118
+ /** `RemotePresentationOptions` with defaults applied — what every renderer takes. */
119
+ type Knobs = Required<RemotePresentationOptions>;
129
120
 
130
- function isCallbackSafeModelID(modelID: string): boolean {
131
- if (modelID.length > maxCallbackIdLength) return false;
132
- if (allowCallbackSeparators) return true;
133
- return !modelID.includes(":") && !modelID.includes("/");
134
- }
121
+ const LIST_PREVIEW = 24;
122
+ const FREE_PREVIEW = 8;
135
123
 
136
- function getQuickPickModels(
137
- catalog: RemoteModelCatalog,
138
- currentModelID?: string,
139
- ): Array<RemoteModelCatalogEntry> {
140
- const picks: Array<RemoteModelCatalogEntry> = [];
141
- const seen = new Set<string>();
124
+ function isCallbackSafeModelID(knobs: Knobs, modelID: string): boolean {
125
+ if (modelID.length > knobs.maxCallbackIdLength) return false;
126
+ if (knobs.allowCallbackSeparators) return true;
127
+ return !modelID.includes(":") && !modelID.includes("/");
128
+ }
142
129
 
143
- const tryAdd = (model: RemoteModelCatalogEntry | undefined) => {
144
- if (!model || seen.has(model.id) || !isCallbackSafeModelID(model.id))
145
- return;
146
- picks.push(model);
147
- seen.add(model.id);
148
- };
130
+ /** `• <selection> — <name> (<provider> · <price> · <ctx> · <availability>)` */
131
+ function describeModel(
132
+ model: RemoteModelCatalogEntry,
133
+ catalog: RemoteModelCatalog,
134
+ ): string {
135
+ const tags = [
136
+ model.providerName,
137
+ model.free ? "free" : `$${model.costInput}/${model.costOutput}`,
138
+ `${formatCtxWindow(model.contextWindow)} ctx`,
139
+ getAvailabilityLabel(model),
140
+ ];
141
+ return `• ${getRemoteModelSelectionValue(model, catalog)} — ${model.name} (${tags.join(" · ")})`;
142
+ }
149
143
 
150
- if (currentModelID) {
151
- const currentModel = resolveRemoteModelInput(currentModelID, catalog);
152
- if (currentModel.kind === "exact") {
153
- tryAdd(currentModel.model);
154
- } else if (currentModel.kind === "ambiguous") {
155
- tryAdd(currentModel.matches[0]);
156
- }
157
- }
144
+ function describeProvider(
145
+ provider: RemoteModelCatalog["providers"][number],
146
+ ): string {
147
+ const detail = provider.connected
148
+ ? "connected"
149
+ : provider.loginRequired
150
+ ? `login: ${provider.authMethods.join(", ")}`
151
+ : provider.envRequired
152
+ ? `env: ${provider.envKeys.join(", ")}`
153
+ : provider.source;
154
+ return `• ${provider.name} (${provider.id}) — ${detail} · ${provider.modelCount} models`;
155
+ }
158
156
 
159
- for (const model of catalog.connectedFreeModels) {
160
- if (picks.length >= quickPickLimit) break;
161
- tryAdd(model);
162
- }
157
+ function getQuickPickModels(
158
+ knobs: Knobs,
159
+ catalog: RemoteModelCatalog,
160
+ currentModelID?: string,
161
+ ): Array<RemoteModelCatalogEntry> {
162
+ const picks: Array<RemoteModelCatalogEntry> = [];
163
+ const seen = new Set<string>();
164
+
165
+ const tryAdd = (model: RemoteModelCatalogEntry | undefined) => {
166
+ if (!model || seen.has(model.id) || !isCallbackSafeModelID(knobs, model.id))
167
+ return;
168
+ picks.push(model);
169
+ seen.add(model.id);
170
+ };
163
171
 
164
- if (picks.length < quickPickLimit) {
165
- for (const model of catalog.connectedModels) {
166
- if (picks.length >= quickPickLimit) break;
167
- tryAdd(model);
168
- }
172
+ if (currentModelID) {
173
+ const currentModel = resolveRemoteModelInput(currentModelID, catalog);
174
+ if (currentModel.kind === "exact") {
175
+ tryAdd(currentModel.model);
176
+ } else if (currentModel.kind === "ambiguous") {
177
+ tryAdd(currentModel.matches[0]);
169
178
  }
179
+ }
170
180
 
171
- return picks;
181
+ for (const model of catalog.connectedFreeModels) {
182
+ if (picks.length >= knobs.quickPickLimit) break;
183
+ tryAdd(model);
172
184
  }
173
185
 
174
- async function getSettingsPresentation(
175
- activeModel: string,
176
- options: RemotePickerOptions = {},
177
- ): Promise<RemotePickerResult> {
178
- const callbackPrefix = options.callbackPrefix ?? "settings:model:";
179
- const navPrefix = options.navCallbackPrefix ?? "settings:models";
180
- const catalog = await getCatalog();
181
- const current = getRemoteModelInfo(catalog, activeModel);
186
+ if (picks.length < knobs.quickPickLimit) {
187
+ for (const model of catalog.connectedModels) {
188
+ if (picks.length >= knobs.quickPickLimit) break;
189
+ tryAdd(model);
190
+ }
191
+ }
182
192
 
183
- const filter = options.filter === "free" ? "free" : "all";
184
- const selectable = catalog.connectedModels.filter((m) =>
185
- isCallbackSafeModelID(m.id),
186
- );
187
- const freeCount = selectable.filter((m) => m.free).length;
188
- const scoped = selectable.filter(
189
- (m) =>
190
- (filter === "all" || m.free) &&
191
- (!options.provider || m.providerID === options.provider),
192
- );
193
+ return picks;
194
+ }
193
195
 
194
- // A remote catalog can run to hundreds of models, and only the first
195
- // page's worth ever fit on screen — so offer the provider list as the
196
- // way in when nothing narrower was asked for and the flat list would be
197
- // unreadable anyway.
198
- const providers = catalog.connectedProviders;
199
- const asGroups =
200
- !options.provider &&
201
- providers.length > 1 &&
202
- scoped.length > groupThreshold;
196
+ /** One page of the picker: provider chips when the flat list is unreadable, models otherwise. */
197
+ type PickerPage = {
198
+ asGroups: boolean;
199
+ page: number;
200
+ totalPages: number;
201
+ providers: RemoteModelCatalog["connectedProviders"];
202
+ models: Array<RemoteModelCatalogEntry>;
203
+ };
203
204
 
204
- const pageSize = Math.max(1, options.pageSize ?? quickPickLimit);
205
- const source = asGroups ? providers : scoped;
206
- const totalPages = Math.max(1, Math.ceil(source.length / pageSize));
207
- const page = Math.min(Math.max(1, options.page ?? 1), totalPages);
208
- const slice = source.slice((page - 1) * pageSize, page * pageSize);
205
+ function pagePicker(
206
+ knobs: Knobs,
207
+ catalog: RemoteModelCatalog,
208
+ scoped: Array<RemoteModelCatalogEntry>,
209
+ options: RemotePickerOptions,
210
+ ): PickerPage {
211
+ // A remote catalog can run to hundreds of models, and only the first
212
+ // page's worth ever fit on screen — so offer the provider list as the
213
+ // way in when nothing narrower was asked for and the flat list would be
214
+ // unreadable anyway.
215
+ const providers = catalog.connectedProviders;
216
+ const asGroups =
217
+ !options.provider &&
218
+ providers.length > 1 &&
219
+ scoped.length > knobs.groupThreshold;
220
+ const pageSize = Math.max(1, options.pageSize ?? knobs.quickPickLimit);
221
+ const source = asGroups ? providers : scoped;
222
+ const totalPages = Math.max(1, Math.ceil(source.length / pageSize));
223
+ const page = Math.min(Math.max(1, options.page ?? 1), totalPages);
224
+ const from = (page - 1) * pageSize;
225
+ return {
226
+ asGroups,
227
+ page,
228
+ totalPages,
229
+ providers: asGroups ? providers.slice(from, from + pageSize) : [],
230
+ models: asGroups ? [] : scoped.slice(from, from + pageSize),
231
+ };
232
+ }
209
233
 
210
- const modelButtons: Array<ModelButton> = asGroups
211
- ? (slice as typeof providers).map((p) => ({
212
- text: `${p.name} (${p.modelCount})`,
213
- callback_data: `${navPrefix}:provider:${p.id}`,
214
- }))
215
- : (slice as typeof scoped).map((m) => {
216
- const btnLabel =
217
- m.id.length <= 20 ? m.id : m.name.length <= 20 ? m.name : m.id;
218
- const txt = m.free ? `${btnLabel} ★` : btnLabel;
219
- const sel =
220
- current &&
221
- m.id === current.id &&
222
- m.providerID === current.providerID;
223
- return {
224
- text: sel ? `✓ ${txt}` : txt,
225
- callback_data: `${callbackPrefix}${m.id}`,
226
- };
227
- });
228
- if (!asGroups) {
229
- modelButtons.push({
230
- text: "Reset",
231
- callback_data: `${callbackPrefix}reset`,
232
- });
233
- }
234
+ function pickerButtons(
235
+ paged: PickerPage,
236
+ current: RemoteModelCatalogEntry | undefined,
237
+ callbackPrefix: string,
238
+ navPrefix: string,
239
+ ): Array<ModelButton> {
240
+ if (paged.asGroups) {
241
+ return paged.providers.map((p) => ({
242
+ text: `${p.name} (${p.modelCount})`,
243
+ callback_data: `${navPrefix}:provider:${p.id}`,
244
+ }));
245
+ }
246
+ const buttons = paged.models.map((m) => {
247
+ const btnLabel =
248
+ m.id.length <= 20 ? m.id : m.name.length <= 20 ? m.name : m.id;
249
+ const txt = m.free ? `${btnLabel} ★` : btnLabel;
250
+ const sel =
251
+ current && m.id === current.id && m.providerID === current.providerID;
252
+ return {
253
+ text: sel ? `✓ ${txt}` : txt,
254
+ callback_data: `${callbackPrefix}${m.id}`,
255
+ };
256
+ });
257
+ buttons.push({ text: "Reset", callback_data: `${callbackPrefix}reset` });
258
+ return buttons;
259
+ }
234
260
 
235
- const details: Array<string> = [];
236
- if (current) {
237
- details.push(
238
- `Provider: ${current.providerName} · ${getAvailabilityLabel(current)}`,
239
- );
240
- details.push(
241
- `Context: ${formatCtxWindow(current.contextWindow)} · reasoning ${current.reasoning ? "yes" : "no"} · tools ${current.toolcall ? "yes" : "no"}`,
242
- );
243
- }
244
- const np = catalog.connectedProviders.length;
245
- const nm = catalog.connectedModels.length;
261
+ function pickerDetails(
262
+ knobs: Knobs,
263
+ catalog: RemoteModelCatalog,
264
+ current: RemoteModelCatalogEntry | undefined,
265
+ ): Array<string> {
266
+ const details: Array<string> = [];
267
+ if (current) {
246
268
  details.push(
247
- `${label}: ${np} provider${np === 1 ? "" : "s"} connected · ${nm} model${nm === 1 ? "" : "s"} usable`,
269
+ `Provider: ${current.providerName} · ${getAvailabilityLabel(current)}`,
270
+ );
271
+ details.push(
272
+ `Context: ${formatCtxWindow(current.contextWindow)} · reasoning ${current.reasoning ? "yes" : "no"} · tools ${current.toolcall ? "yes" : "no"}`,
248
273
  );
249
- if (catalog.loginProviders.length > 0) {
250
- const preview = catalog.loginProviders
251
- .slice(0, 4)
252
- .map((p) => p.name)
253
- .join(", ");
254
- details.push(
255
- `Login available: ${preview}${catalog.loginProviders.length > 4 ? "…" : ""}`,
256
- );
257
- }
258
- details.push("Hint: use /model <name> to switch.");
259
- return {
260
- modelButtons,
261
- modelDetails: details,
262
- view: asGroups ? "groups" : "models",
263
- page,
264
- totalPages,
265
- filter,
266
- freeCount,
267
- totalCount: selectable.length,
268
- ...(options.provider ? { provider: options.provider } : {}),
269
- };
270
274
  }
275
+ const np = catalog.connectedProviders.length;
276
+ const nm = catalog.connectedModels.length;
277
+ details.push(
278
+ `${knobs.label}: ${np} provider${np === 1 ? "" : "s"} connected · ${nm} model${nm === 1 ? "" : "s"} usable`,
279
+ );
280
+ if (catalog.loginProviders.length > 0) {
281
+ const preview = catalog.loginProviders
282
+ .slice(0, 4)
283
+ .map((p) => p.name)
284
+ .join(", ");
285
+ details.push(
286
+ `Login available: ${preview}${catalog.loginProviders.length > 4 ? "…" : ""}`,
287
+ );
288
+ }
289
+ details.push("Hint: use /model <name> to switch.");
290
+ return details;
291
+ }
271
292
 
272
- async function renderModelSummary(
273
- activeModel: string,
274
- defaultModel: string,
275
- ): Promise<{ text: string; quickButtons: Array<ModelButton> }> {
276
- const { modelButtons, modelDetails } =
277
- await getSettingsPresentation(activeModel);
278
- const catalog = await getCatalog();
279
- const current = getRemoteModelInfo(catalog, activeModel);
280
- const currentLabel = current
281
- ? getRemoteModelSelectionValue(current, catalog)
282
- : activeModel;
283
- const freePreview = catalog.connectedFreeModels.slice(0, 8);
293
+ async function getSettingsPresentation(
294
+ knobs: Knobs,
295
+ activeModel: string,
296
+ options: RemotePickerOptions = {},
297
+ ): Promise<RemotePickerResult> {
298
+ const callbackPrefix = options.callbackPrefix ?? "settings:model:";
299
+ const navPrefix = options.navCallbackPrefix ?? "settings:models";
300
+ const catalog = await knobs.getCatalog();
301
+ const current = getRemoteModelInfo(catalog, activeModel);
284
302
 
285
- const lines = [
286
- `Model: ${currentLabel}${activeModel === defaultModel ? " (default)" : ""}`,
287
- ...modelDetails,
288
- ];
289
- if (freePreview.length > 0) {
290
- lines.push("", "Free now");
291
- for (const m of freePreview) {
292
- const tags = [
293
- m.providerName,
294
- m.free ? "free" : `$${m.costInput}/${m.costOutput}`,
295
- `${formatCtxWindow(m.contextWindow)} ctx`,
296
- getAvailabilityLabel(m),
297
- ];
298
- lines.push(
299
- `• ${getRemoteModelSelectionValue(m, catalog)} — ${m.name} (${tags.join(" · ")})`,
300
- );
301
- }
302
- }
303
- return { text: lines.join("\n"), quickButtons: modelButtons };
303
+ const filter = options.filter === "free" ? "free" : "all";
304
+ const selectable = catalog.connectedModels.filter((m) =>
305
+ isCallbackSafeModelID(knobs, m.id),
306
+ );
307
+ const freeCount = selectable.filter((m) => m.free).length;
308
+ const scoped = selectable.filter(
309
+ (m) =>
310
+ (filter === "all" || m.free) &&
311
+ (!options.provider || m.providerID === options.provider),
312
+ );
313
+ const paged = pagePicker(knobs, catalog, scoped, options);
314
+ return {
315
+ modelButtons: pickerButtons(paged, current, callbackPrefix, navPrefix),
316
+ modelDetails: pickerDetails(knobs, catalog, current),
317
+ view: paged.asGroups ? "groups" : "models",
318
+ page: paged.page,
319
+ totalPages: paged.totalPages,
320
+ filter,
321
+ freeCount,
322
+ totalCount: selectable.length,
323
+ ...(options.provider ? { provider: options.provider } : {}),
324
+ };
325
+ }
326
+
327
+ async function renderModelSummary(
328
+ knobs: Knobs,
329
+ activeModel: string,
330
+ defaultModel: string,
331
+ ): Promise<{ text: string; quickButtons: Array<ModelButton> }> {
332
+ const { modelButtons, modelDetails } = await getSettingsPresentation(
333
+ knobs,
334
+ activeModel,
335
+ );
336
+ const catalog = await knobs.getCatalog();
337
+ const current = getRemoteModelInfo(catalog, activeModel);
338
+ const currentLabel = current
339
+ ? getRemoteModelSelectionValue(current, catalog)
340
+ : activeModel;
341
+ const freePreview = catalog.connectedFreeModels.slice(0, FREE_PREVIEW);
342
+
343
+ const lines = [
344
+ `Model: ${currentLabel}${activeModel === defaultModel ? " (default)" : ""}`,
345
+ ...modelDetails,
346
+ ];
347
+ if (freePreview.length > 0) {
348
+ lines.push("", "Free now");
349
+ for (const m of freePreview) lines.push(describeModel(m, catalog));
304
350
  }
351
+ return { text: lines.join("\n"), quickButtons: modelButtons };
352
+ }
305
353
 
306
- async function renderModelList(
307
- mode: "free" | "all" | "providers",
308
- ): Promise<string> {
309
- const catalog = await getCatalog();
310
- if (mode === "providers") {
311
- const lines = [`${label} Providers`];
312
- for (const p of catalog.providers.slice(0, 24)) {
313
- const detail = p.connected
314
- ? "connected"
315
- : p.loginRequired
316
- ? `login: ${p.authMethods.join(", ")}`
317
- : p.envRequired
318
- ? `env: ${p.envKeys.join(", ")}`
319
- : p.source;
320
- lines.push(
321
- `• ${p.name} (${p.id}) — ${detail} · ${p.modelCount} models`,
322
- );
323
- }
324
- if (catalog.providers.length > 24)
325
- lines.push(`…and ${catalog.providers.length - 24} more`);
326
- return lines.join("\n");
327
- }
328
- const source =
329
- mode === "free" ? catalog.connectedFreeModels : catalog.connectedModels;
330
- const title =
331
- mode === "free" ? "Connected Free Models" : "Connected Models";
332
- const lines = [title];
333
- for (const m of source.slice(0, 24)) {
334
- const tags = [
335
- m.providerName,
336
- m.free ? "free" : `$${m.costInput}/${m.costOutput}`,
337
- `${formatCtxWindow(m.contextWindow)} ctx`,
338
- getAvailabilityLabel(m),
339
- ];
340
- lines.push(
341
- `• ${getRemoteModelSelectionValue(m, catalog)} — ${m.name} (${tags.join(" · ")})`,
342
- );
354
+ async function renderModelList(
355
+ knobs: Knobs,
356
+ mode: "free" | "all" | "providers",
357
+ ): Promise<string> {
358
+ const catalog = await knobs.getCatalog();
359
+ if (mode === "providers") {
360
+ const lines = [`${knobs.label} Providers`];
361
+ for (const p of catalog.providers.slice(0, LIST_PREVIEW)) {
362
+ lines.push(describeProvider(p));
343
363
  }
344
- if (source.length > 24) lines.push(`…and ${source.length - 24} more`);
364
+ if (catalog.providers.length > LIST_PREVIEW)
365
+ lines.push(`…and ${catalog.providers.length - LIST_PREVIEW} more`);
345
366
  return lines.join("\n");
346
367
  }
347
-
348
- function formatSelectionError(
349
- input: string,
350
- resolution: Exclude<RemoteModelResolution, { kind: "exact" }>,
351
- catalog: RemoteModelCatalog,
352
- ): string {
353
- if (resolution.kind === "missing")
354
- return `No ${label} model matched "${input}".`;
355
- const preview = resolution.matches
356
- .slice(0, 6)
357
- .map((m) => {
358
- const provider =
359
- m.providerName === m.providerID
360
- ? m.providerName
361
- : `${m.providerName} / ${m.providerID}`;
362
- return `${getRemoteModelSelectionValue(m, catalog)} — ${provider} (${getAvailabilityLabel(m)})`;
363
- })
364
- .join(", ");
365
- return `Model query "${input}" is ambiguous. Try one of: ${preview}`;
368
+ const source =
369
+ mode === "free" ? catalog.connectedFreeModels : catalog.connectedModels;
370
+ const title = mode === "free" ? "Connected Free Models" : "Connected Models";
371
+ const lines = [title];
372
+ for (const m of source.slice(0, LIST_PREVIEW)) {
373
+ lines.push(describeModel(m, catalog));
366
374
  }
375
+ if (source.length > LIST_PREVIEW)
376
+ lines.push(`…and ${source.length - LIST_PREVIEW} more`);
377
+ return lines.join("\n");
378
+ }
379
+
380
+ function formatSelectionError(
381
+ knobs: Knobs,
382
+ input: string,
383
+ resolution: Exclude<RemoteModelResolution, { kind: "exact" }>,
384
+ catalog: RemoteModelCatalog,
385
+ ): string {
386
+ if (resolution.kind === "missing")
387
+ return `No ${knobs.label} model matched "${input}".`;
388
+ const preview = resolution.matches
389
+ .slice(0, 6)
390
+ .map((m) => {
391
+ const provider =
392
+ m.providerName === m.providerID
393
+ ? m.providerName
394
+ : `${m.providerName} / ${m.providerID}`;
395
+ return `${getRemoteModelSelectionValue(m, catalog)} — ${provider} (${getAvailabilityLabel(m)})`;
396
+ })
397
+ .join(", ");
398
+ return `Model query "${input}" is ambiguous. Try one of: ${preview}`;
399
+ }
367
400
 
401
+ export function createRemoteModelPresentation(
402
+ options: RemotePresentationOptions,
403
+ ): RemoteModelPresentation {
404
+ const knobs: Knobs = { groupThreshold: 60, ...options };
368
405
  return {
369
- getQuickPickModels,
370
- getSettingsPresentation,
371
- renderModelSummary,
372
- renderModelList,
373
- formatSelectionError,
406
+ getQuickPickModels: (catalog, currentModelID) =>
407
+ getQuickPickModels(knobs, catalog, currentModelID),
408
+ getSettingsPresentation: (activeModel, pickerOptions) =>
409
+ getSettingsPresentation(knobs, activeModel, pickerOptions),
410
+ renderModelSummary: (activeModel, defaultModel) =>
411
+ renderModelSummary(knobs, activeModel, defaultModel),
412
+ renderModelList: (mode) => renderModelList(knobs, mode),
413
+ formatSelectionError: (input, resolution, catalog) =>
414
+ formatSelectionError(knobs, input, resolution, catalog),
374
415
  };
375
416
  }
@@ -107,6 +107,12 @@ export interface RemoteServerBindings<TClient extends RemoteAgentClient> {
107
107
  stop(): void;
108
108
  /** Lazily start (or reuse) the local server and return a strict client. */
109
109
  ensureServer(): Promise<TClient>;
110
+ /**
111
+ * Register a chat turn's abort controller for the lifetime of the turn.
112
+ * `stop()` aborts every registered controller so no turn outlives its
113
+ * server. Returns the unregister function; call it when the turn ends.
114
+ */
115
+ trackActiveTurn(controller: AbortController): () => void;
110
116
  ensureChatMcpServer(oc: TClient, chatId: string): Promise<string>;
111
117
  ensurePluginMcpServers(oc: TClient, chatId: string): Promise<string[]>;
112
118
  /**
@@ -193,6 +199,12 @@ export function bindRemoteServer<TClient extends RemoteAgentClient>(
193
199
  });
194
200
  },
195
201
  ensureServer,
202
+ trackActiveTurn(controller) {
203
+ state.activeTurns.add(controller);
204
+ return () => {
205
+ state.activeTurns.delete(controller);
206
+ };
207
+ },
196
208
  ensureChatMcpServer,
197
209
  ensurePluginMcpServers,
198
210
  buildToolOverrides: (oc, chatServerName, pluginServerNames = []) =>
@@ -21,7 +21,7 @@ import {
21
21
  TALON_MCP_SERVER_NAME,
22
22
  TALON_PLUGIN_MCP_SERVER_NAME,
23
23
  getChatMcpServerName,
24
- safeMcpNamePart,
24
+ getPluginMcpServerPrefix,
25
25
  } from "./mcp.js";
26
26
 
27
27
  /**
@@ -52,7 +52,7 @@ import {
52
52
  */
53
53
  export function buildPermissionRuleset(chatId: string): RemotePermissionRule[] {
54
54
  const ourServerName = getChatMcpServerName(chatId);
55
- const ourPluginPrefix = `${TALON_PLUGIN_MCP_SERVER_NAME}-${safeMcpNamePart(chatId, "chat")}-`;
55
+ const ourPluginPrefix = getPluginMcpServerPrefix(chatId);
56
56
  return [
57
57
  { permission: "tool", pattern: `${ourServerName}_*`, action: "allow" },
58
58
  {
@@ -56,6 +56,13 @@ export interface RemoteServerState<TClient extends RemoteAgentClient> {
56
56
  readonly registeredMcpTools: Map<string, readonly string[]>;
57
57
  /** Plugin-name → registered server-name mapping for each chat context. */
58
58
  readonly pluginMcpServersByChat: Map<string, Map<string, string>>;
59
+ /**
60
+ * One controller per in-flight chat turn. `stopRemoteServer` aborts them
61
+ * so a turn cannot outlive the server it is streaming from — without
62
+ * this, a mid-turn backend swap left the SSE await hanging until the
63
+ * 600s deadline while the question watchdog hammered the dead socket.
64
+ */
65
+ readonly activeTurns: Set<AbortController>;
59
66
  }
60
67
 
61
68
  /** Inputs for {@link createRemoteServerState}. */
@@ -90,6 +97,7 @@ export function createRemoteServerState<TClient extends RemoteAgentClient>(
90
97
  registeredMcpServers: new Set(),
91
98
  registeredMcpTools: new Map(),
92
99
  pluginMcpServersByChat: new Map(),
100
+ activeTurns: new Set(),
93
101
  };
94
102
  }
95
103