@songsid/agend 2.1.0-beta.39 → 2.1.0-beta.40

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.
@@ -95,6 +95,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
95
95
  controlClient: TmuxControlClient | null;
96
96
  classicChannels: ClassicChannelManager | null;
97
97
  private pendingClassicStarts;
98
+ /** In-flight /model selections, keyed by nonce (see handleModelSelection). */
99
+ private pendingModelSelects;
98
100
  private failoverActive;
99
101
  readonly ipcStoppingInstances: Set<string>;
100
102
  private adapterRestarting;
@@ -364,6 +366,16 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
364
366
  private forwardToClassicInstance;
365
367
  /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
366
368
  private pasteRawToClassicInstance;
369
+ /** Resolve the backend name configured for an instance (fleet or classic). */
370
+ private backendNameForInstance;
371
+ /** Best-effort model list for `/model` (empty ⇒ caller falls back to free-text). Never throws. */
372
+ private getModelOptions;
373
+ /** `/model` slash handler (admin only). No arg → DC menu; `/model <name>` → apply directly. */
374
+ private handleModelSlash;
375
+ /** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
376
+ private handleModelSelection;
377
+ /** Apply a model to an instance: runtime paste (claude-code) or persist + restart (others). */
378
+ applyModel(instanceName: string, model: string): Promise<string>;
367
379
  /** Read recent chat log for agent context */
368
380
  private getRecentChatLog;
369
381
  /** Return a user-facing blocker without mutating ClassicBot state. */
@@ -19,6 +19,8 @@ import { TmuxManager } from "./tmux-manager.js";
19
19
  import { AccessManager } from "./channel/access-manager.js";
20
20
  import { IpcClient } from "./channel/ipc-bridge.js";
21
21
  import { createAdapter } from "./channel/factory.js";
22
+ import { createBackend } from "./backend/factory.js";
23
+ import { isModelCompatible } from "./backend/types.js";
22
24
  import { createLogger } from "./logger.js";
23
25
  import { processAttachments } from "./channel/attachment-handler.js";
24
26
  import { routeToolCall } from "./channel/tool-router.js";
@@ -82,6 +84,7 @@ const CANCEL_BTN_MAX_RETRIES = 3;
82
84
  const CANCEL_BTN_IDLE_CHECK_INTERVAL_MS = 5 * 60_000;
83
85
  const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
84
86
  const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
87
+ const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
85
88
  export class FleetManager {
86
89
  dataDir;
87
90
  children = new Map();
@@ -144,6 +147,8 @@ export class FleetManager {
144
147
  controlClient = null;
145
148
  classicChannels = null;
146
149
  pendingClassicStarts = new Map();
150
+ /** In-flight /model selections, keyed by nonce (see handleModelSelection). */
151
+ pendingModelSelects = new Map();
147
152
  // Model failover state
148
153
  failoverActive = new Map(); // instance → current failover model
149
154
  // IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
@@ -1341,6 +1346,8 @@ export class FleetManager {
1341
1346
  this.adapter.on("callback_query", safeHandler(async (data) => {
1342
1347
  if (await this.handleClassicBackendSelection(data))
1343
1348
  return;
1349
+ if (await this.handleModelSelection(data))
1350
+ return;
1344
1351
  if (data.callbackData.startsWith("hang:")) {
1345
1352
  const parts = data.callbackData.split(":");
1346
1353
  const action = parts[1];
@@ -1443,6 +1450,9 @@ export class FleetManager {
1443
1450
  const result = await this.topicCommands.sendCompact(name);
1444
1451
  await data.respond(result);
1445
1452
  }
1453
+ else if (data.command === "model") {
1454
+ await this.handleModelSlash(data, adapterId);
1455
+ }
1446
1456
  else if (data.command === "cancel") {
1447
1457
  const name = this.resolveSlashTarget(data.channelId, adapterId);
1448
1458
  if (!name) {
@@ -1628,6 +1638,8 @@ export class FleetManager {
1628
1638
  adapter.on("callback_query", safeHandler(async (data) => {
1629
1639
  if (await this.handleClassicBackendSelection(data))
1630
1640
  return;
1641
+ if (await this.handleModelSelection(data))
1642
+ return;
1631
1643
  if (data.callbackData.startsWith("hang:")) {
1632
1644
  const parts = data.callbackData.split(":");
1633
1645
  const action = parts[1];
@@ -1717,6 +1729,9 @@ export class FleetManager {
1717
1729
  this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
1718
1730
  await data.respond(t("save.sent", `/chat load ${filename}`, name));
1719
1731
  }
1732
+ else if (data.command === "model") {
1733
+ await this.handleModelSlash(data, adapterId);
1734
+ }
1720
1735
  else if (data.command === "cancel") {
1721
1736
  const name = this.resolveSlashTarget(data.channelId, adapterId);
1722
1737
  if (!name) {
@@ -4183,6 +4198,131 @@ When users create specialized instances, suggest these configurations:
4183
4198
  ipc.send({ type: "raw_paste", content: text });
4184
4199
  this.logger.info({ instanceName, text: text.slice(0, 100) }, "Raw paste sent to classic instance");
4185
4200
  }
4201
+ /** Resolve the backend name configured for an instance (fleet or classic). */
4202
+ backendNameForInstance(instanceName) {
4203
+ const fleetCfg = this.fleetConfig?.instances[instanceName];
4204
+ if (fleetCfg?.backend)
4205
+ return fleetCfg.backend;
4206
+ const classic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined
4207
+ ? this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend)
4208
+ : undefined;
4209
+ return classic ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
4210
+ }
4211
+ /** Best-effort model list for `/model` (empty ⇒ caller falls back to free-text). Never throws. */
4212
+ async getModelOptions(instanceName) {
4213
+ try {
4214
+ const backend = createBackend(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
4215
+ if (!backend.listModels)
4216
+ return [];
4217
+ return (await backend.listModels({
4218
+ workingDirectory: this.fleetConfig?.instances[instanceName]?.working_directory ?? "",
4219
+ instanceDir: this.getInstanceDir(instanceName),
4220
+ instanceName,
4221
+ mcpServers: {},
4222
+ })) ?? [];
4223
+ }
4224
+ catch (err) {
4225
+ this.logger.warn({ err, instanceName }, "listModels failed");
4226
+ return [];
4227
+ }
4228
+ }
4229
+ /** `/model` slash handler (admin only). No arg → DC menu; `/model <name>` → apply directly. */
4230
+ async handleModelSlash(data, adapterId) {
4231
+ if (!this.isFleetAdmin(data.userId, adapterId)) {
4232
+ await data.respond(t("admin.required"));
4233
+ return;
4234
+ }
4235
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
4236
+ if (!name) {
4237
+ await data.respond(t("classic.no_agent"));
4238
+ return;
4239
+ }
4240
+ const requested = (typeof data.options?.name === "string" ? data.options.name.trim() : "")
4241
+ || (data.text?.trim() ?? "");
4242
+ if (requested) {
4243
+ await data.respond(await this.applyModel(name, requested));
4244
+ return;
4245
+ }
4246
+ // No arg → menu. Menu is DC-only this round (respondChoices); TG uses `/model <name>`.
4247
+ if (!data.respondChoices) {
4248
+ await data.respond("Usage: /model <name> — e.g. /model sonnet");
4249
+ return;
4250
+ }
4251
+ const options = await this.getModelOptions(name);
4252
+ if (options.length === 0) {
4253
+ await data.respond(`No model list available for ${name}. Type \`/model <name>\` to set one directly.`);
4254
+ return;
4255
+ }
4256
+ const nonce = randomBytes(6).toString("hex");
4257
+ const choices = options.slice(0, 25).map(o => ({
4258
+ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
4259
+ label: o.description ? `${o.label} — ${o.description}` : o.label,
4260
+ }));
4261
+ const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
4262
+ timer.unref?.();
4263
+ this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
4264
+ try {
4265
+ await data.respondChoices(`Choose a model for ${name}:`, choices);
4266
+ }
4267
+ catch (err) {
4268
+ this.pendingModelSelects.delete(nonce);
4269
+ clearTimeout(timer);
4270
+ this.logger.warn({ err, instanceName: name }, "model menu failed");
4271
+ await data.respond("Usage: /model <name> — e.g. /model sonnet");
4272
+ }
4273
+ }
4274
+ /** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
4275
+ async handleModelSelection(data) {
4276
+ if (!data.callbackData.startsWith(MODEL_SELECT_CALLBACK_PREFIX))
4277
+ return false;
4278
+ const match = data.callbackData.match(/^model-select:([0-9a-f]+):(.+)$/);
4279
+ if (!match)
4280
+ return true;
4281
+ const pending = this.pendingModelSelects.get(match[1]);
4282
+ if (!pending)
4283
+ return true;
4284
+ // Only the admin who opened the menu, in the same channel, may consume it.
4285
+ if (data.userId && data.userId !== pending.userId)
4286
+ return true;
4287
+ const cbChannel = data.threadId ?? data.chatId;
4288
+ if (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
4289
+ return true;
4290
+ this.pendingModelSelects.delete(match[1]);
4291
+ clearTimeout(pending.timer);
4292
+ const result = await this.applyModel(pending.instanceName, match[2]);
4293
+ await pending.respond(result).catch(() => { });
4294
+ return true;
4295
+ }
4296
+ /** Apply a model to an instance: runtime paste (claude-code) or persist + restart (others). */
4297
+ async applyModel(instanceName, model) {
4298
+ const backendName = this.backendNameForInstance(instanceName);
4299
+ let strategy = "restart";
4300
+ try {
4301
+ strategy = createBackend(backendName, this.getInstanceDir(instanceName)).getModelSwitchStrategy?.(model) ?? "restart";
4302
+ }
4303
+ catch { /* default restart */ }
4304
+ const warn = isModelCompatible(backendName, model) ? "" : `⚠️ "${model}" doesn't match ${backendName}'s usual pattern — passing through anyway.\n`;
4305
+ if (strategy === "runtime") {
4306
+ if (!this.instanceIpcClients.get(instanceName))
4307
+ return `${warn}❌ ${instanceName} is not running.`;
4308
+ this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
4309
+ return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).`;
4310
+ }
4311
+ // restart: persist the model so the respawned CLI launches with it.
4312
+ let persisted = false;
4313
+ if (this.fleetConfig?.instances[instanceName]) {
4314
+ this.fleetConfig.instances[instanceName].model = model;
4315
+ this.saveFleetConfig();
4316
+ persisted = true;
4317
+ }
4318
+ else if (this.classicChannels?.setModelByInstance(instanceName, model)) {
4319
+ persisted = true;
4320
+ }
4321
+ if (!persisted)
4322
+ return `${warn}❌ Could not set model for ${instanceName}.`;
4323
+ await this.restartSingleInstance(instanceName);
4324
+ return `${warn}✅ Set ${instanceName} to \`${model}\` and restarted.`;
4325
+ }
4186
4326
  /** Read recent chat log for agent context */
4187
4327
  getRecentChatLog(instanceName, maxLines = 10) {
4188
4328
  const logDir = ClassicChannelManager.chatLogDir(instanceName);