@0xmaxma/claude-gateway 1.7.10 → 1.8.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 (173) hide show
  1. package/README.md +167 -41
  2. package/dist/agent/builtin-commands.js +2 -2
  3. package/dist/agent/builtin-commands.js.map +1 -1
  4. package/dist/agent/knowledge/config.d.ts.map +1 -1
  5. package/dist/agent/knowledge/config.js +2 -9
  6. package/dist/agent/knowledge/config.js.map +1 -1
  7. package/dist/agent/model-catalog.d.ts +62 -0
  8. package/dist/agent/model-catalog.d.ts.map +1 -0
  9. package/dist/agent/model-catalog.js +264 -0
  10. package/dist/agent/model-catalog.js.map +1 -0
  11. package/dist/agent/runner.d.ts +76 -0
  12. package/dist/agent/runner.d.ts.map +1 -1
  13. package/dist/agent/runner.js +189 -36
  14. package/dist/agent/runner.js.map +1 -1
  15. package/dist/api/cron-router.d.ts +12 -8
  16. package/dist/api/cron-router.d.ts.map +1 -1
  17. package/dist/api/cron-router.js +82 -16
  18. package/dist/api/cron-router.js.map +1 -1
  19. package/dist/api/gateway-router.d.ts +4 -0
  20. package/dist/api/gateway-router.d.ts.map +1 -1
  21. package/dist/api/gateway-router.js +14 -0
  22. package/dist/api/gateway-router.js.map +1 -1
  23. package/dist/api/manifest-sources.d.ts +15 -0
  24. package/dist/api/manifest-sources.d.ts.map +1 -0
  25. package/dist/api/manifest-sources.js +27 -0
  26. package/dist/api/manifest-sources.js.map +1 -0
  27. package/dist/api/meta-router.d.ts +13 -0
  28. package/dist/api/meta-router.d.ts.map +1 -0
  29. package/dist/api/meta-router.js +26 -0
  30. package/dist/api/meta-router.js.map +1 -0
  31. package/dist/api/packages.d.ts.map +1 -1
  32. package/dist/api/packages.js +18 -198
  33. package/dist/api/packages.js.map +1 -1
  34. package/dist/api/route-registry.d.ts +81 -0
  35. package/dist/api/route-registry.d.ts.map +1 -0
  36. package/dist/api/route-registry.js +44 -0
  37. package/dist/api/route-registry.js.map +1 -0
  38. package/dist/api/router.d.ts.map +1 -1
  39. package/dist/api/router.js +21 -3
  40. package/dist/api/router.js.map +1 -1
  41. package/dist/apps/installer.d.ts +73 -5
  42. package/dist/apps/installer.d.ts.map +1 -1
  43. package/dist/apps/installer.js +237 -22
  44. package/dist/apps/installer.js.map +1 -1
  45. package/dist/cli/args.d.ts +25 -0
  46. package/dist/cli/args.d.ts.map +1 -0
  47. package/dist/cli/args.js +55 -0
  48. package/dist/cli/args.js.map +1 -0
  49. package/dist/cli/colors.d.ts +37 -0
  50. package/dist/cli/colors.d.ts.map +1 -0
  51. package/dist/cli/colors.js +63 -0
  52. package/dist/cli/colors.js.map +1 -0
  53. package/dist/cli/command-names.d.ts +98 -0
  54. package/dist/cli/command-names.d.ts.map +1 -0
  55. package/dist/cli/command-names.js +109 -0
  56. package/dist/cli/command-names.js.map +1 -0
  57. package/dist/cli/commands/agents.d.ts +28 -0
  58. package/dist/cli/commands/agents.d.ts.map +1 -0
  59. package/dist/cli/commands/agents.js +321 -0
  60. package/dist/cli/commands/agents.js.map +1 -0
  61. package/dist/cli/commands/channels.d.ts +17 -0
  62. package/dist/cli/commands/channels.d.ts.map +1 -0
  63. package/dist/cli/commands/channels.js +101 -0
  64. package/dist/cli/commands/channels.js.map +1 -0
  65. package/dist/cli/commands/debug-bundle.d.ts +6 -0
  66. package/dist/cli/commands/debug-bundle.d.ts.map +1 -0
  67. package/dist/cli/commands/debug-bundle.js +220 -0
  68. package/dist/cli/commands/debug-bundle.js.map +1 -0
  69. package/dist/cli/commands/doctor.d.ts +3 -0
  70. package/dist/cli/commands/doctor.d.ts.map +1 -0
  71. package/dist/cli/commands/doctor.js +104 -0
  72. package/dist/cli/commands/doctor.js.map +1 -0
  73. package/dist/cli/commands/gateway.d.ts +12 -0
  74. package/dist/cli/commands/gateway.d.ts.map +1 -0
  75. package/dist/cli/commands/gateway.js +145 -0
  76. package/dist/cli/commands/gateway.js.map +1 -0
  77. package/dist/cli/commands/service.d.ts +33 -0
  78. package/dist/cli/commands/service.d.ts.map +1 -0
  79. package/dist/cli/commands/service.js +513 -0
  80. package/dist/cli/commands/service.js.map +1 -0
  81. package/dist/cli/commands/update.d.ts +16 -0
  82. package/dist/cli/commands/update.d.ts.map +1 -0
  83. package/dist/cli/commands/update.js +149 -0
  84. package/dist/cli/commands/update.js.map +1 -0
  85. package/dist/cli/commands.generated.d.ts +4 -0
  86. package/dist/cli/commands.generated.d.ts.map +1 -0
  87. package/dist/cli/commands.generated.js +220 -0
  88. package/dist/cli/commands.generated.js.map +1 -0
  89. package/dist/cli/health.d.ts +31 -0
  90. package/dist/cli/health.d.ts.map +1 -0
  91. package/dist/cli/health.js +49 -0
  92. package/dist/cli/health.js.map +1 -0
  93. package/dist/cli/http-client.d.ts +140 -0
  94. package/dist/cli/http-client.d.ts.map +1 -0
  95. package/dist/cli/http-client.js +332 -0
  96. package/dist/cli/http-client.js.map +1 -0
  97. package/dist/cli/index.d.ts +27 -0
  98. package/dist/cli/index.d.ts.map +1 -0
  99. package/dist/cli/index.js +372 -0
  100. package/dist/cli/index.js.map +1 -0
  101. package/dist/cli/manager.d.ts +64 -0
  102. package/dist/cli/manager.d.ts.map +1 -0
  103. package/dist/cli/manager.js +181 -0
  104. package/dist/cli/manager.js.map +1 -0
  105. package/dist/cli/output.d.ts +49 -0
  106. package/dist/cli/output.d.ts.map +1 -0
  107. package/dist/cli/output.js +101 -0
  108. package/dist/cli/output.js.map +1 -0
  109. package/dist/cli/prompt.d.ts +29 -0
  110. package/dist/cli/prompt.d.ts.map +1 -0
  111. package/dist/cli/prompt.js +154 -0
  112. package/dist/cli/prompt.js.map +1 -0
  113. package/dist/cli/redact.d.ts +3 -0
  114. package/dist/cli/redact.d.ts.map +1 -0
  115. package/dist/cli/redact.js +33 -0
  116. package/dist/cli/redact.js.map +1 -0
  117. package/dist/cli/types.d.ts +22 -0
  118. package/dist/cli/types.d.ts.map +1 -0
  119. package/dist/cli/types.js +4 -0
  120. package/dist/cli/types.js.map +1 -0
  121. package/dist/config/bootstrap.d.ts +33 -0
  122. package/dist/config/bootstrap.d.ts.map +1 -0
  123. package/dist/config/bootstrap.js +117 -0
  124. package/dist/config/bootstrap.js.map +1 -0
  125. package/dist/config/loader.d.ts.map +1 -1
  126. package/dist/config/loader.js +5 -1
  127. package/dist/config/loader.js.map +1 -1
  128. package/dist/config/watcher.d.ts.map +1 -1
  129. package/dist/config/watcher.js +2 -35
  130. package/dist/config/watcher.js.map +1 -1
  131. package/dist/discord/receiver.d.ts +6 -1
  132. package/dist/discord/receiver.d.ts.map +1 -1
  133. package/dist/discord/receiver.js +9 -1
  134. package/dist/discord/receiver.js.map +1 -1
  135. package/dist/entry.d.ts +3 -0
  136. package/dist/entry.d.ts.map +1 -0
  137. package/dist/entry.js +83 -0
  138. package/dist/entry.js.map +1 -0
  139. package/dist/index.js +160 -46
  140. package/dist/index.js.map +1 -1
  141. package/dist/load-dotenv.d.ts +2 -0
  142. package/dist/load-dotenv.d.ts.map +1 -0
  143. package/dist/load-dotenv.js +68 -0
  144. package/dist/load-dotenv.js.map +1 -0
  145. package/dist/packages/registry.d.ts +50 -0
  146. package/dist/packages/registry.d.ts.map +1 -0
  147. package/dist/packages/registry.js +209 -0
  148. package/dist/packages/registry.js.map +1 -0
  149. package/dist/shutdown-signals.d.ts +47 -0
  150. package/dist/shutdown-signals.d.ts.map +1 -0
  151. package/dist/shutdown-signals.js +62 -0
  152. package/dist/shutdown-signals.js.map +1 -0
  153. package/dist/telegram/receiver.d.ts +6 -1
  154. package/dist/telegram/receiver.d.ts.map +1 -1
  155. package/dist/telegram/receiver.js +9 -1
  156. package/dist/telegram/receiver.js.map +1 -1
  157. package/dist/utils/orphan-receivers.d.ts +90 -0
  158. package/dist/utils/orphan-receivers.d.ts.map +1 -0
  159. package/dist/utils/orphan-receivers.js +218 -0
  160. package/dist/utils/orphan-receivers.js.map +1 -0
  161. package/dist/utils/paths.d.ts +16 -0
  162. package/dist/utils/paths.d.ts.map +1 -0
  163. package/dist/utils/paths.js +60 -0
  164. package/dist/utils/paths.js.map +1 -0
  165. package/dist/utils/stop-child.d.ts +21 -0
  166. package/dist/utils/stop-child.d.ts.map +1 -0
  167. package/dist/utils/stop-child.js +64 -0
  168. package/dist/utils/stop-child.js.map +1 -0
  169. package/lib/pairing.ts +1 -1
  170. package/mcp/tools/agent/handlers.ts +1 -1
  171. package/mcp/tools/discord/receiver-server.ts +4 -0
  172. package/mcp/tools/telegram/receiver-server.ts +6 -4
  173. package/package.json +6 -4
@@ -51,6 +51,7 @@ const slack_client_1 = require("../api/slack-client");
51
51
  const markdown_1 = require("../telegram/markdown");
52
52
  const skills_1 = require("../skills");
53
53
  const builtin_commands_1 = require("./builtin-commands");
54
+ const model_catalog_1 = require("./model-catalog");
54
55
  const safe_mode_1 = require("./safe-mode");
55
56
  const recovery_executor_1 = require("./recovery-executor");
56
57
  const recovery_policy_1 = require("./recovery-policy");
@@ -434,7 +435,7 @@ class AgentRunner extends events_1.EventEmitter {
434
435
  // Check if this is a built-in channel command
435
436
  const trimmedContent = content.trim();
436
437
  if ((0, builtin_commands_1.isBuiltinCommand)(trimmedContent, channelSource)) {
437
- this.handleSessionCommand(chatId, trimmedContent)
438
+ this.handleSessionCommand(chatId, trimmedContent, meta)
438
439
  .then(() => this.writeTypingDone(chatId))
439
440
  .catch((err) => {
440
441
  this.logger.error('Session command failed', { error: err.message });
@@ -685,8 +686,17 @@ class AgentRunner extends events_1.EventEmitter {
685
686
  return;
686
687
  }
687
688
  if (command === 'get_models') {
688
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
689
- respond({ models: availableModels.map(m => ({ id: m.id, label: m.label })) });
689
+ // Live catalog first, static list as the fallback (issue #409). The
690
+ // picker is a UI action, so a slow or unreachable catalog must not hang
691
+ // it — fetchModelCatalog is bounded and never rejects.
692
+ void this.availableModels()
693
+ .then((availableModels) => {
694
+ respond({ models: availableModels.map(m => ({ id: m.id, label: m.label })) });
695
+ })
696
+ // Without this the request would hang and the picker with it: the
697
+ // receiver is blocked on this response. Every other async branch in
698
+ // this handler answers on rejection too.
699
+ .catch((err) => respond({ success: false, error: String(err) }, 500));
690
700
  return;
691
701
  }
692
702
  // `/cli` webview terminal viewer — create a pairing on behalf of an
@@ -735,15 +745,7 @@ class AgentRunner extends events_1.EventEmitter {
735
745
  respond({ success: false, error: err.message });
736
746
  return;
737
747
  }
738
- let restarted = false;
739
- const restartPromises = [];
740
- for (const [key, session] of this.sessions) {
741
- if (session.source !== 'api') {
742
- restarted = true;
743
- restartPromises.push(this.restartProcess(key));
744
- }
745
- }
746
- await Promise.all(restartPromises);
748
+ const restarted = await this.restartChannelSessions();
747
749
  respond({ success: true, model: newModel, restarted });
748
750
  return;
749
751
  }
@@ -788,9 +790,7 @@ class AgentRunner extends events_1.EventEmitter {
788
790
  return respond({ success: true, text: 'No active session found.' });
789
791
  }
790
792
  const effectiveModel = this.agentConfig.claude.model;
791
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
792
- const modelConfig = availableModels.find(m => m.id === effectiveModel);
793
- const contextWindow = modelConfig?.contextWindow ?? 200000;
793
+ const contextWindow = await this.contextWindowFor(effectiveModel);
794
794
  const contextTokens = meta.lastInputTokens ?? 0;
795
795
  const usedPct = Math.round((contextTokens / contextWindow) * 100);
796
796
  let msgs;
@@ -1879,7 +1879,7 @@ class AgentRunner extends events_1.EventEmitter {
1879
1879
  /**
1880
1880
  * Dispatch a session management command to the appropriate handler.
1881
1881
  */
1882
- async handleSessionCommand(chatId, content) {
1882
+ async handleSessionCommand(chatId, content, meta = {}) {
1883
1883
  const agentId = this.agentConfig.id;
1884
1884
  if (content.startsWith('/sessions')) {
1885
1885
  await this.handleCommandSessions(agentId, chatId);
@@ -1901,10 +1901,78 @@ class AgentRunner extends events_1.EventEmitter {
1901
1901
  const name = content.replace('/rename', '').trim();
1902
1902
  await this.handleCommandRename(agentId, chatId, name);
1903
1903
  }
1904
+ else if (content.startsWith('/models')) {
1905
+ await this.handleCommandModels(chatId);
1906
+ }
1907
+ else if (content.startsWith('/model')) {
1908
+ await this.handleCommandModel(chatId, content.replace('/model', '').trim(), meta);
1909
+ }
1904
1910
  else if (content.startsWith('/stop')) {
1905
1911
  await this.handleCommandStop(chatId);
1906
1912
  }
1907
1913
  }
1914
+ /**
1915
+ * /models — the model picker for channels without an inline keyboard.
1916
+ *
1917
+ * Telegram never reaches this: its receiver answers `/models` itself with a
1918
+ * button keyboard and does not forward the message. Discord and LINE have no
1919
+ * picker of their own, so they get the list as text plus the command that
1920
+ * selects from it (issue #409).
1921
+ *
1922
+ * A channel listed in BUILTIN_COMMANDS but missing a branch here is worse
1923
+ * than one not listed at all — the gate swallows the message and the user
1924
+ * gets silence instead of the agent's reply. `/model` on Discord was in
1925
+ * exactly that state before this.
1926
+ */
1927
+ async handleCommandModels(chatId) {
1928
+ const models = await this.availableModels();
1929
+ const current = this.agentConfig.claude.model;
1930
+ const lines = [`Current model: ${current}`, ''];
1931
+ for (const m of models) {
1932
+ const marker = m.id === current ? '✅ ' : '• ';
1933
+ lines.push(`${marker}${m.label} — \`${m.id}\``);
1934
+ }
1935
+ lines.push('', 'Switch with /model <id or alias>');
1936
+ this.writeAutoForward(chatId, lines.join('\n'));
1937
+ }
1938
+ /**
1939
+ * /model — show the current model, or switch to one from the catalog.
1940
+ *
1941
+ * An unrecognised argument is refused rather than persisted: unlike the API
1942
+ * (where the caller is authenticated and the provider validates the id), a
1943
+ * typo in a chat message would otherwise be written into config.json and
1944
+ * break the agent's next spawn. Anything the catalog knows — including a
1945
+ * model that exists only upstream and not in the static list — is accepted.
1946
+ */
1947
+ async handleCommandModel(chatId, arg, meta = {}) {
1948
+ const current = this.agentConfig.claude.model;
1949
+ if (!arg) {
1950
+ this.writeAutoForward(chatId, `Current model: ${current}\n\nUse /models to see the list.`);
1951
+ return;
1952
+ }
1953
+ if (!this.isDirectChat(meta)) {
1954
+ this.writeAutoForward(chatId, 'Switching models is only available in a direct message with the bot — '
1955
+ + 'it changes the model for every chat of this agent and restarts them. '
1956
+ + 'Use /models here to see the list.');
1957
+ return;
1958
+ }
1959
+ const models = await this.selectableModels();
1960
+ const wanted = arg.toLowerCase();
1961
+ const match = models.find((m) => m.id.toLowerCase() === wanted || m.alias.toLowerCase() === wanted);
1962
+ if (!match) {
1963
+ this.writeAutoForward(chatId, `Unknown model "${arg}". Use /models to see the list.`);
1964
+ return;
1965
+ }
1966
+ if (match.id === current) {
1967
+ this.writeAutoForward(chatId, `Already using ${match.label} (${match.id}).`);
1968
+ return;
1969
+ }
1970
+ await this.setModel(match.id);
1971
+ // Same restart the Telegram picker's set_model does — the running session
1972
+ // was spawned with the old model and would otherwise keep using it.
1973
+ const restarted = await this.restartChannelSessions();
1974
+ this.writeAutoForward(chatId, `Model set to ${match.label} (${match.id}).${restarted ? ' Restarting the session…' : ''}`);
1975
+ }
1908
1976
  /**
1909
1977
  * /sessions — list all sessions for this chat.
1910
1978
  */
@@ -1933,9 +2001,7 @@ class AgentRunner extends events_1.EventEmitter {
1933
2001
  return;
1934
2002
  }
1935
2003
  const effectiveModel = this.agentConfig.claude.model;
1936
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
1937
- const modelConfig = availableModels.find(m => m.id === effectiveModel);
1938
- const contextWindow = modelConfig?.contextWindow ?? 200000;
2004
+ const contextWindow = await this.contextWindowFor(effectiveModel);
1939
2005
  const contextTokens = meta.lastInputTokens ?? 0;
1940
2006
  const usedPct = Math.round((contextTokens / contextWindow) * 100);
1941
2007
  let msgs;
@@ -2053,9 +2119,7 @@ class AgentRunner extends events_1.EventEmitter {
2053
2119
  const meta = index.sessions.find(s => s.id === sessionId);
2054
2120
  const name = meta?.name ?? 'Session';
2055
2121
  const compactModel = this.agentConfig.claude.model;
2056
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
2057
- const modelConfig = availableModels.find(m => m.id === compactModel);
2058
- const contextWindow = modelConfig?.contextWindow ?? 200000;
2122
+ const contextWindow = await this.contextWindowFor(compactModel);
2059
2123
  this.writeAutoForward(chatId, `⏳ Compacting session "${name}"...`);
2060
2124
  try {
2061
2125
  const compactor = new compactor_1.SessionCompactor(this.sessionStore);
@@ -2571,7 +2635,10 @@ class AgentRunner extends events_1.EventEmitter {
2571
2635
  stopTelegramReceiver() {
2572
2636
  if (!this.receiver)
2573
2637
  return;
2574
- this.receiver.stop();
2638
+ // Hot-remove is a config-change response, not a shutdown: detach immediately
2639
+ // and let teardown finish in the background rather than blocking the caller
2640
+ // for the SIGKILL grace period. stop() never rejects.
2641
+ void this.receiver.stop();
2575
2642
  this.receiver = null;
2576
2643
  this.logger.info('TelegramReceiver stopped', { agentId: this.agentConfig.id });
2577
2644
  }
@@ -2587,7 +2654,10 @@ class AgentRunner extends events_1.EventEmitter {
2587
2654
  stopDiscordReceiver() {
2588
2655
  if (!this.discordReceiver)
2589
2656
  return;
2590
- this.discordReceiver.stop();
2657
+ // Hot-remove is a config-change response, not a shutdown: detach immediately
2658
+ // and let teardown finish in the background rather than blocking the caller
2659
+ // for the SIGKILL grace period. stop() never rejects.
2660
+ void this.discordReceiver.stop();
2591
2661
  this.discordReceiver = null;
2592
2662
  this.logger.info('DiscordReceiver stopped', { agentId: this.agentConfig.id });
2593
2663
  }
@@ -2609,10 +2679,17 @@ class AgentRunner extends events_1.EventEmitter {
2609
2679
  clearTimeout(buf.timer);
2610
2680
  }
2611
2681
  this.channelCoalesce.clear();
2612
- this.receiver?.stop();
2613
- this.discordReceiver?.stop();
2682
+ // Receiver teardown now resolves only once the child has exited, so it must
2683
+ // be awaited or shutdown() can exit the gateway out from under a receiver
2684
+ // that is still shutting down (issue #405). Run it concurrently with session
2685
+ // teardown rather than serially — both are bounded, neither depends on the
2686
+ // other, and shutdown latency is user-visible.
2687
+ const receiversStopped = [this.receiver?.stop(), this.discordReceiver?.stop()];
2614
2688
  this.stopLineReply();
2615
- await Promise.all([...this.sessions.values()].map((s) => s.stop()));
2689
+ await Promise.all([
2690
+ ...receiversStopped,
2691
+ ...[...this.sessions.values()].map((s) => s.stop()),
2692
+ ]);
2616
2693
  this.sessions.clear();
2617
2694
  }
2618
2695
  /**
@@ -3412,9 +3489,7 @@ class AgentRunner extends events_1.EventEmitter {
3412
3489
  responseText = `Session: (unnamed)\nMessages: ${messageCount}\nContext used: 0%\nModel: ${effectiveModel}`;
3413
3490
  }
3414
3491
  else {
3415
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
3416
- const modelConfig = availableModels.find((m) => m.id === effectiveModel);
3417
- const contextWindow = modelConfig?.contextWindow ?? 200000;
3492
+ const contextWindow = await this.contextWindowFor(effectiveModel);
3418
3493
  const contextUsedPct = Math.round(((meta.lastInputTokens ?? 0) / contextWindow) * 100);
3419
3494
  result = { sessionId, sessionName: meta.name, messageCount, archivedCount: meta.archivedCount ?? 0, contextUsedPct, model: effectiveModel };
3420
3495
  responseText = [
@@ -3471,9 +3546,7 @@ class AgentRunner extends events_1.EventEmitter {
3471
3546
  const ch = 'api';
3472
3547
  const activeSession = this.sessions.get(sessionId);
3473
3548
  const compactEffectiveModel = opts?.model ?? activeSession?.modelOverride ?? this.agentConfig.claude.model;
3474
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
3475
- const modelConfig = availableModels.find((m) => m.id === compactEffectiveModel);
3476
- const contextWindow = modelConfig?.contextWindow ?? 200000;
3549
+ const contextWindow = await this.contextWindowFor(compactEffectiveModel);
3477
3550
  const compactor = new compactor_1.SessionCompactor(this.sessionStore);
3478
3551
  const compactResult = await compactor.compact(agentId, storeChatId, sessionId, compactEffectiveModel, contextWindow, ch);
3479
3552
  await this.sessionStore.updateSessionMeta(agentId, storeChatId, sessionId, {
@@ -3507,6 +3580,88 @@ class AgentRunner extends events_1.EventEmitter {
3507
3580
  persist('assistant', responseText, forcePersist);
3508
3581
  return { result, responseText };
3509
3582
  }
3583
+ /**
3584
+ * Whether an inbound channel message came from a 1:1 chat with the bot.
3585
+ *
3586
+ * Gates the one channel command that reaches past its own chat: `/model <x>`
3587
+ * rewrites `config.json` for the whole agent and restarts every session in
3588
+ * every chat. The channel access gates do not cover that. Discord's, in a
3589
+ * guild, checks the guild, the channel, and a mention — never the user
3590
+ * (`allowFrom` in mcp/tools/discord/access.ts is DM-only), so without this
3591
+ * any member of an allowlisted channel could switch the model out from under
3592
+ * everyone else and kill their in-flight turns. LINE forwards group and room
3593
+ * messages the same way. Telegram's receiver answers `/model` itself behind
3594
+ * a private-chat + allowlist check and does not forward it here.
3595
+ *
3596
+ * Fails closed: a channel that does not report a chat type cannot switch.
3597
+ * Listing models (`/models`, bare `/model`) is unaffected — it reads nothing
3598
+ * and changes nothing.
3599
+ */
3600
+ isDirectChat(meta) {
3601
+ const kind = meta['chat_type'] ?? meta['line_chat_type'] ?? meta['slack_chat_type'];
3602
+ return kind === 'direct' || kind === 'user';
3603
+ }
3604
+ /**
3605
+ * Restart every non-api session so a model change actually takes effect.
3606
+ *
3607
+ * `setModel` only rewrites config — a session process was spawned with the
3608
+ * old model on its command line and keeps using it until it is restarted.
3609
+ * Reporting "model set" without this is a false success: the next turn still
3610
+ * runs on the previous model. Returns whether anything was restarted.
3611
+ */
3612
+ async restartChannelSessions() {
3613
+ const restartPromises = [];
3614
+ for (const [key, session] of this.sessions) {
3615
+ if (session.source !== 'api')
3616
+ restartPromises.push(this.restartProcess(key));
3617
+ }
3618
+ await Promise.all(restartPromises);
3619
+ return restartPromises.length > 0;
3620
+ }
3621
+ /**
3622
+ * The model list this gateway offers: the live catalog when one is reachable,
3623
+ * the configured/static list otherwise (issue #409). Cached upstream, so the
3624
+ * six callers that only want one model's context window pay for at most one
3625
+ * fetch a minute between them.
3626
+ */
3627
+ async availableModels() {
3628
+ const staticModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
3629
+ return (await (0, model_catalog_1.fetchModelCatalog)(staticModels)) ?? staticModels;
3630
+ }
3631
+ /**
3632
+ * Context window for a model id. Resolving this through the live catalog is
3633
+ * what keeps `/session`'s "context used" percentage and `/compact`'s window
3634
+ * honest for a model that exists only upstream — before, any such model fell
3635
+ * through to the 200k default no matter how large its real window.
3636
+ */
3637
+ async contextWindowFor(modelId) {
3638
+ const models = await this.availableModels();
3639
+ // Configured list second, not just as the catalog's fallback: the catalog
3640
+ // *replaces* the list rather than merging with it, so a configured model
3641
+ // the upstream catalog does not list (the local `[1m]` ids are exactly
3642
+ // that shape) would otherwise drop to the 200k default — reporting
3643
+ // /session context use against a fifth of the real window and handing
3644
+ // SessionCompactor a window small enough to compact far too early.
3645
+ return models.find((m) => m.id === modelId)?.contextWindow
3646
+ ?? (this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS).find((m) => m.id === modelId)?.contextWindow
3647
+ ?? model_catalog_1.DEFAULT_CONTEXT_WINDOW;
3648
+ }
3649
+ /**
3650
+ * Models `/model <arg>` may switch to: what the catalog offers, plus the
3651
+ * configured list.
3652
+ *
3653
+ * The union is only for *resolution*, never for the `/models` listing — the
3654
+ * listing has to reflect what upstream actually offers, or a model removed
3655
+ * upstream would linger in the picker forever, which is the bug this all
3656
+ * started from. Without the union a user who switches to a live-only model
3657
+ * is trapped: their previous configured model is no longer nameable.
3658
+ */
3659
+ async selectableModels() {
3660
+ const live = await this.availableModels();
3661
+ const configured = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
3662
+ const ids = new Set(live.map((m) => m.id));
3663
+ return [...live, ...configured.filter((m) => !ids.has(m.id))];
3664
+ }
3510
3665
  async setModel(newModel) {
3511
3666
  // Allow any model string through — BYOK/third-party models (openrouter/* etc.)
3512
3667
  // are validated by the upstream provider, not the local config list.
@@ -3555,9 +3710,7 @@ class AgentRunner extends events_1.EventEmitter {
3555
3710
  if (!meta)
3556
3711
  return null;
3557
3712
  const effectiveModel = this.agentConfig.claude.model;
3558
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
3559
- const modelConfig = availableModels.find((m) => m.id === effectiveModel);
3560
- const contextWindow = modelConfig?.contextWindow ?? 200000;
3713
+ const contextWindow = await this.contextWindowFor(effectiveModel);
3561
3714
  const contextUsedPct = Math.round(((meta.lastInputTokens ?? 0) / contextWindow) * 100);
3562
3715
  return {
3563
3716
  sessionId: meta.id,