@0xmaxma/claude-gateway 1.7.10 → 1.8.1

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 (177) 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 +304 -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 +212 -41
  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/session/process.d.ts +14 -5
  150. package/dist/session/process.d.ts.map +1 -1
  151. package/dist/session/process.js +155 -26
  152. package/dist/session/process.js.map +1 -1
  153. package/dist/shutdown-signals.d.ts +47 -0
  154. package/dist/shutdown-signals.d.ts.map +1 -0
  155. package/dist/shutdown-signals.js +62 -0
  156. package/dist/shutdown-signals.js.map +1 -0
  157. package/dist/telegram/receiver.d.ts +6 -1
  158. package/dist/telegram/receiver.d.ts.map +1 -1
  159. package/dist/telegram/receiver.js +9 -1
  160. package/dist/telegram/receiver.js.map +1 -1
  161. package/dist/utils/orphan-receivers.d.ts +90 -0
  162. package/dist/utils/orphan-receivers.d.ts.map +1 -0
  163. package/dist/utils/orphan-receivers.js +218 -0
  164. package/dist/utils/orphan-receivers.js.map +1 -0
  165. package/dist/utils/paths.d.ts +16 -0
  166. package/dist/utils/paths.d.ts.map +1 -0
  167. package/dist/utils/paths.js +60 -0
  168. package/dist/utils/paths.js.map +1 -0
  169. package/dist/utils/stop-child.d.ts +21 -0
  170. package/dist/utils/stop-child.d.ts.map +1 -0
  171. package/dist/utils/stop-child.js +64 -0
  172. package/dist/utils/stop-child.js.map +1 -0
  173. package/lib/pairing.ts +1 -1
  174. package/mcp/tools/agent/handlers.ts +1 -1
  175. package/mcp/tools/discord/receiver-server.ts +4 -0
  176. package/mcp/tools/telegram/receiver-server.ts +152 -29
  177. 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,24 @@ 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
+ const configuredIds = new Set((this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS).map(m => m.id));
695
+ const configured = availableModels.filter(m => configuredIds.has(m.id));
696
+ const live = availableModels.filter(m => !configuredIds.has(m.id));
697
+ respond({
698
+ models: availableModels.map(m => ({ id: m.id, label: m.label })),
699
+ configuredModels: configured.map(m => ({ id: m.id, label: m.label })),
700
+ liveModels: live.map(m => ({ id: m.id, label: m.label })),
701
+ });
702
+ })
703
+ // Without this the request would hang and the picker with it: the
704
+ // receiver is blocked on this response. Every other async branch in
705
+ // this handler answers on rejection too.
706
+ .catch((err) => respond({ success: false, error: String(err) }, 500));
690
707
  return;
691
708
  }
692
709
  // `/cli` webview terminal viewer — create a pairing on behalf of an
@@ -735,15 +752,7 @@ class AgentRunner extends events_1.EventEmitter {
735
752
  respond({ success: false, error: err.message });
736
753
  return;
737
754
  }
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);
755
+ const restarted = await this.restartChannelSessions();
747
756
  respond({ success: true, model: newModel, restarted });
748
757
  return;
749
758
  }
@@ -788,9 +797,7 @@ class AgentRunner extends events_1.EventEmitter {
788
797
  return respond({ success: true, text: 'No active session found.' });
789
798
  }
790
799
  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;
800
+ const contextWindow = await this.contextWindowFor(effectiveModel);
794
801
  const contextTokens = meta.lastInputTokens ?? 0;
795
802
  const usedPct = Math.round((contextTokens / contextWindow) * 100);
796
803
  let msgs;
@@ -1879,7 +1886,7 @@ class AgentRunner extends events_1.EventEmitter {
1879
1886
  /**
1880
1887
  * Dispatch a session management command to the appropriate handler.
1881
1888
  */
1882
- async handleSessionCommand(chatId, content) {
1889
+ async handleSessionCommand(chatId, content, meta = {}) {
1883
1890
  const agentId = this.agentConfig.id;
1884
1891
  if (content.startsWith('/sessions')) {
1885
1892
  await this.handleCommandSessions(agentId, chatId);
@@ -1901,10 +1908,88 @@ class AgentRunner extends events_1.EventEmitter {
1901
1908
  const name = content.replace('/rename', '').trim();
1902
1909
  await this.handleCommandRename(agentId, chatId, name);
1903
1910
  }
1911
+ else if (content.startsWith('/models')) {
1912
+ await this.handleCommandModels(chatId);
1913
+ }
1914
+ else if (content.startsWith('/model')) {
1915
+ await this.handleCommandModel(chatId, content.replace('/model', '').trim(), meta);
1916
+ }
1904
1917
  else if (content.startsWith('/stop')) {
1905
1918
  await this.handleCommandStop(chatId);
1906
1919
  }
1907
1920
  }
1921
+ /**
1922
+ * /models — the model picker for channels without an inline keyboard.
1923
+ *
1924
+ * Telegram never reaches this: its receiver answers `/models` itself with a
1925
+ * button keyboard and does not forward the message. Discord and LINE have no
1926
+ * picker of their own, so they get the list as text plus the command that
1927
+ * selects from it (issue #409).
1928
+ *
1929
+ * A channel listed in BUILTIN_COMMANDS but missing a branch here is worse
1930
+ * than one not listed at all — the gate swallows the message and the user
1931
+ * gets silence instead of the agent's reply. `/model` on Discord was in
1932
+ * exactly that state before this.
1933
+ */
1934
+ async handleCommandModels(chatId) {
1935
+ const models = await this.availableModels();
1936
+ const configuredIds = new Set((this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS).map(m => m.id));
1937
+ const configured = models.filter(m => configuredIds.has(m.id));
1938
+ const live = models.filter(m => !configuredIds.has(m.id));
1939
+ const current = this.agentConfig.claude.model;
1940
+ const lines = [`Current model: ${current}`, '', 'Configured models'];
1941
+ for (const m of configured) {
1942
+ const marker = m.id === current ? '✅ ' : '• ';
1943
+ lines.push(`${marker}${m.label} — \`${m.id}\``);
1944
+ }
1945
+ if (live.length) {
1946
+ lines.push('', 'More models (live)');
1947
+ for (const m of live) {
1948
+ const marker = m.id === current ? '✅ ' : '• ';
1949
+ lines.push(`${marker}${m.label} — \`${m.id}\``);
1950
+ }
1951
+ }
1952
+ lines.push('', 'Switch with /model <id or alias>');
1953
+ this.writeAutoForward(chatId, lines.join('\n'));
1954
+ }
1955
+ /**
1956
+ * /model — show the current model, or switch to one from the catalog.
1957
+ *
1958
+ * An unrecognised argument is refused rather than persisted: unlike the API
1959
+ * (where the caller is authenticated and the provider validates the id), a
1960
+ * typo in a chat message would otherwise be written into config.json and
1961
+ * break the agent's next spawn. Anything the catalog knows — including a
1962
+ * model that exists only upstream and not in the static list — is accepted.
1963
+ */
1964
+ async handleCommandModel(chatId, arg, meta = {}) {
1965
+ const current = this.agentConfig.claude.model;
1966
+ if (!arg) {
1967
+ this.writeAutoForward(chatId, `Current model: ${current}\n\nUse /models to see the list.`);
1968
+ return;
1969
+ }
1970
+ if (!this.isDirectChat(meta)) {
1971
+ this.writeAutoForward(chatId, 'Switching models is only available in a direct message with the bot — '
1972
+ + 'it changes the model for every chat of this agent and restarts them. '
1973
+ + 'Use /models here to see the list.');
1974
+ return;
1975
+ }
1976
+ const models = await this.selectableModels();
1977
+ const wanted = arg.toLowerCase();
1978
+ const match = models.find((m) => m.id.toLowerCase() === wanted || m.alias.toLowerCase() === wanted);
1979
+ if (!match) {
1980
+ this.writeAutoForward(chatId, `Unknown model "${arg}". Use /models to see the list.`);
1981
+ return;
1982
+ }
1983
+ if (match.id === current) {
1984
+ this.writeAutoForward(chatId, `Already using ${match.label} (${match.id}).`);
1985
+ return;
1986
+ }
1987
+ await this.setModel(match.id);
1988
+ // Same restart the Telegram picker's set_model does — the running session
1989
+ // was spawned with the old model and would otherwise keep using it.
1990
+ const restarted = await this.restartChannelSessions();
1991
+ this.writeAutoForward(chatId, `Model set to ${match.label} (${match.id}).${restarted ? ' Restarting the session…' : ''}`);
1992
+ }
1908
1993
  /**
1909
1994
  * /sessions — list all sessions for this chat.
1910
1995
  */
@@ -1933,9 +2018,7 @@ class AgentRunner extends events_1.EventEmitter {
1933
2018
  return;
1934
2019
  }
1935
2020
  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;
2021
+ const contextWindow = await this.contextWindowFor(effectiveModel);
1939
2022
  const contextTokens = meta.lastInputTokens ?? 0;
1940
2023
  const usedPct = Math.round((contextTokens / contextWindow) * 100);
1941
2024
  let msgs;
@@ -2053,9 +2136,7 @@ class AgentRunner extends events_1.EventEmitter {
2053
2136
  const meta = index.sessions.find(s => s.id === sessionId);
2054
2137
  const name = meta?.name ?? 'Session';
2055
2138
  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;
2139
+ const contextWindow = await this.contextWindowFor(compactModel);
2059
2140
  this.writeAutoForward(chatId, `⏳ Compacting session "${name}"...`);
2060
2141
  try {
2061
2142
  const compactor = new compactor_1.SessionCompactor(this.sessionStore);
@@ -2263,9 +2344,9 @@ class AgentRunner extends events_1.EventEmitter {
2263
2344
  // bystander (mirrors the image-size-threshold path).
2264
2345
  // - hasLikelyOutstandingBackgroundWork(): regardless of which tier
2265
2346
  // called restartOrDefer — even today's aggressive default — a
2266
- // session that dispatched a background Agent/Workflow tool_use and
2267
- // then ended its OWN turn looks idle here, but a sub-agent it
2268
- // launched may still be doing real work. SIGKILLing it now would
2347
+ // session that dispatched a background Agent/Workflow/Monitor
2348
+ // tool_use and then ended its OWN turn looks idle here, but that
2349
+ // dispatch may still be doing real work. SIGKILLing it now would
2269
2350
  // silently destroy that in-flight work with no notification or
2270
2351
  // recovery (the incident this guards against: a CLAUDE.md-triggered
2271
2352
  // restart killed a session mid-way through 3 dispatched code-review
@@ -2470,8 +2551,9 @@ class AgentRunner extends events_1.EventEmitter {
2470
2551
  startIdleCleaner() {
2471
2552
  this.idleCleanerTimer = setInterval(async () => {
2472
2553
  for (const [id, proc] of this.sessions) {
2473
- // A parent that just dispatched Agent/Workflow work is CLI-idle but its
2474
- // sub-agent may still need this session to receive the task notification.
2554
+ // A parent that just dispatched Agent/Workflow/Monitor work is CLI-idle
2555
+ // but that dispatch may still need this session to receive its
2556
+ // eventual task notification.
2475
2557
  if (proc.hasLikelyOutstandingBackgroundWork())
2476
2558
  continue;
2477
2559
  if (proc.isIdle(this.idleTimeoutMs)) {
@@ -2571,7 +2653,10 @@ class AgentRunner extends events_1.EventEmitter {
2571
2653
  stopTelegramReceiver() {
2572
2654
  if (!this.receiver)
2573
2655
  return;
2574
- this.receiver.stop();
2656
+ // Hot-remove is a config-change response, not a shutdown: detach immediately
2657
+ // and let teardown finish in the background rather than blocking the caller
2658
+ // for the SIGKILL grace period. stop() never rejects.
2659
+ void this.receiver.stop();
2575
2660
  this.receiver = null;
2576
2661
  this.logger.info('TelegramReceiver stopped', { agentId: this.agentConfig.id });
2577
2662
  }
@@ -2587,7 +2672,10 @@ class AgentRunner extends events_1.EventEmitter {
2587
2672
  stopDiscordReceiver() {
2588
2673
  if (!this.discordReceiver)
2589
2674
  return;
2590
- this.discordReceiver.stop();
2675
+ // Hot-remove is a config-change response, not a shutdown: detach immediately
2676
+ // and let teardown finish in the background rather than blocking the caller
2677
+ // for the SIGKILL grace period. stop() never rejects.
2678
+ void this.discordReceiver.stop();
2591
2679
  this.discordReceiver = null;
2592
2680
  this.logger.info('DiscordReceiver stopped', { agentId: this.agentConfig.id });
2593
2681
  }
@@ -2609,10 +2697,17 @@ class AgentRunner extends events_1.EventEmitter {
2609
2697
  clearTimeout(buf.timer);
2610
2698
  }
2611
2699
  this.channelCoalesce.clear();
2612
- this.receiver?.stop();
2613
- this.discordReceiver?.stop();
2700
+ // Receiver teardown now resolves only once the child has exited, so it must
2701
+ // be awaited or shutdown() can exit the gateway out from under a receiver
2702
+ // that is still shutting down (issue #405). Run it concurrently with session
2703
+ // teardown rather than serially — both are bounded, neither depends on the
2704
+ // other, and shutdown latency is user-visible.
2705
+ const receiversStopped = [this.receiver?.stop(), this.discordReceiver?.stop()];
2614
2706
  this.stopLineReply();
2615
- await Promise.all([...this.sessions.values()].map((s) => s.stop()));
2707
+ await Promise.all([
2708
+ ...receiversStopped,
2709
+ ...[...this.sessions.values()].map((s) => s.stop()),
2710
+ ]);
2616
2711
  this.sessions.clear();
2617
2712
  }
2618
2713
  /**
@@ -3412,9 +3507,7 @@ class AgentRunner extends events_1.EventEmitter {
3412
3507
  responseText = `Session: (unnamed)\nMessages: ${messageCount}\nContext used: 0%\nModel: ${effectiveModel}`;
3413
3508
  }
3414
3509
  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;
3510
+ const contextWindow = await this.contextWindowFor(effectiveModel);
3418
3511
  const contextUsedPct = Math.round(((meta.lastInputTokens ?? 0) / contextWindow) * 100);
3419
3512
  result = { sessionId, sessionName: meta.name, messageCount, archivedCount: meta.archivedCount ?? 0, contextUsedPct, model: effectiveModel };
3420
3513
  responseText = [
@@ -3471,9 +3564,7 @@ class AgentRunner extends events_1.EventEmitter {
3471
3564
  const ch = 'api';
3472
3565
  const activeSession = this.sessions.get(sessionId);
3473
3566
  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;
3567
+ const contextWindow = await this.contextWindowFor(compactEffectiveModel);
3477
3568
  const compactor = new compactor_1.SessionCompactor(this.sessionStore);
3478
3569
  const compactResult = await compactor.compact(agentId, storeChatId, sessionId, compactEffectiveModel, contextWindow, ch);
3479
3570
  await this.sessionStore.updateSessionMeta(agentId, storeChatId, sessionId, {
@@ -3507,6 +3598,88 @@ class AgentRunner extends events_1.EventEmitter {
3507
3598
  persist('assistant', responseText, forcePersist);
3508
3599
  return { result, responseText };
3509
3600
  }
3601
+ /**
3602
+ * Whether an inbound channel message came from a 1:1 chat with the bot.
3603
+ *
3604
+ * Gates the one channel command that reaches past its own chat: `/model <x>`
3605
+ * rewrites `config.json` for the whole agent and restarts every session in
3606
+ * every chat. The channel access gates do not cover that. Discord's, in a
3607
+ * guild, checks the guild, the channel, and a mention — never the user
3608
+ * (`allowFrom` in mcp/tools/discord/access.ts is DM-only), so without this
3609
+ * any member of an allowlisted channel could switch the model out from under
3610
+ * everyone else and kill their in-flight turns. LINE forwards group and room
3611
+ * messages the same way. Telegram's receiver answers `/model` itself behind
3612
+ * a private-chat + allowlist check and does not forward it here.
3613
+ *
3614
+ * Fails closed: a channel that does not report a chat type cannot switch.
3615
+ * Listing models (`/models`, bare `/model`) is unaffected — it reads nothing
3616
+ * and changes nothing.
3617
+ */
3618
+ isDirectChat(meta) {
3619
+ const kind = meta['chat_type'] ?? meta['line_chat_type'] ?? meta['slack_chat_type'];
3620
+ return kind === 'direct' || kind === 'user';
3621
+ }
3622
+ /**
3623
+ * Restart every non-api session so a model change actually takes effect.
3624
+ *
3625
+ * `setModel` only rewrites config — a session process was spawned with the
3626
+ * old model on its command line and keeps using it until it is restarted.
3627
+ * Reporting "model set" without this is a false success: the next turn still
3628
+ * runs on the previous model. Returns whether anything was restarted.
3629
+ */
3630
+ async restartChannelSessions() {
3631
+ const restartPromises = [];
3632
+ for (const [key, session] of this.sessions) {
3633
+ if (session.source !== 'api')
3634
+ restartPromises.push(this.restartProcess(key));
3635
+ }
3636
+ await Promise.all(restartPromises);
3637
+ return restartPromises.length > 0;
3638
+ }
3639
+ /**
3640
+ * The model list this gateway offers: the live catalog when one is reachable,
3641
+ * the configured/static list otherwise (issue #409). Cached upstream, so the
3642
+ * six callers that only want one model's context window pay for at most one
3643
+ * fetch a minute between them.
3644
+ */
3645
+ async availableModels() {
3646
+ const staticModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
3647
+ return (await (0, model_catalog_1.fetchModelCatalog)(staticModels)) ?? staticModels;
3648
+ }
3649
+ /**
3650
+ * Context window for a model id. Resolving this through the live catalog is
3651
+ * what keeps `/session`'s "context used" percentage and `/compact`'s window
3652
+ * honest for a model that exists only upstream — before, any such model fell
3653
+ * through to the 200k default no matter how large its real window.
3654
+ */
3655
+ async contextWindowFor(modelId) {
3656
+ const models = await this.availableModels();
3657
+ // Configured list second, not just as the catalog's fallback: the catalog
3658
+ // *replaces* the list rather than merging with it, so a configured model
3659
+ // the upstream catalog does not list (the local `[1m]` ids are exactly
3660
+ // that shape) would otherwise drop to the 200k default — reporting
3661
+ // /session context use against a fifth of the real window and handing
3662
+ // SessionCompactor a window small enough to compact far too early.
3663
+ return models.find((m) => m.id === modelId)?.contextWindow
3664
+ ?? (this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS).find((m) => m.id === modelId)?.contextWindow
3665
+ ?? model_catalog_1.DEFAULT_CONTEXT_WINDOW;
3666
+ }
3667
+ /**
3668
+ * Models `/model <arg>` may switch to: what the catalog offers, plus the
3669
+ * configured list.
3670
+ *
3671
+ * The union is only for *resolution*, never for the `/models` listing — the
3672
+ * listing has to reflect what upstream actually offers, or a model removed
3673
+ * upstream would linger in the picker forever, which is the bug this all
3674
+ * started from. Without the union a user who switches to a live-only model
3675
+ * is trapped: their previous configured model is no longer nameable.
3676
+ */
3677
+ async selectableModels() {
3678
+ const live = await this.availableModels();
3679
+ const configured = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
3680
+ const ids = new Set(live.map((m) => m.id));
3681
+ return [...live, ...configured.filter((m) => !ids.has(m.id))];
3682
+ }
3510
3683
  async setModel(newModel) {
3511
3684
  // Allow any model string through — BYOK/third-party models (openrouter/* etc.)
3512
3685
  // are validated by the upstream provider, not the local config list.
@@ -3555,9 +3728,7 @@ class AgentRunner extends events_1.EventEmitter {
3555
3728
  if (!meta)
3556
3729
  return null;
3557
3730
  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;
3731
+ const contextWindow = await this.contextWindowFor(effectiveModel);
3561
3732
  const contextUsedPct = Math.round(((meta.lastInputTokens ?? 0) / contextWindow) * 100);
3562
3733
  return {
3563
3734
  sessionId: meta.id,