@0xmaxma/claude-gateway 1.7.9 → 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 (258) hide show
  1. package/README.md +178 -44
  2. package/dist/agent/builtin-commands.js +2 -2
  3. package/dist/agent/builtin-commands.js.map +1 -1
  4. package/dist/agent/dreaming/accept.d.ts.map +1 -1
  5. package/dist/agent/dreaming/accept.js +4 -1
  6. package/dist/agent/dreaming/accept.js.map +1 -1
  7. package/dist/agent/dreaming/config.d.ts.map +1 -1
  8. package/dist/agent/dreaming/config.js +2 -0
  9. package/dist/agent/dreaming/config.js.map +1 -1
  10. package/dist/agent/dreaming/reviewer.d.ts.map +1 -1
  11. package/dist/agent/dreaming/reviewer.js +9 -2
  12. package/dist/agent/dreaming/reviewer.js.map +1 -1
  13. package/dist/agent/dreaming/staleness.d.ts +7 -1
  14. package/dist/agent/dreaming/staleness.d.ts.map +1 -1
  15. package/dist/agent/dreaming/staleness.js +17 -2
  16. package/dist/agent/dreaming/staleness.js.map +1 -1
  17. package/dist/agent/dreaming/types.d.ts +17 -1
  18. package/dist/agent/dreaming/types.d.ts.map +1 -1
  19. package/dist/agent/knowledge/archive-db.d.ts +43 -0
  20. package/dist/agent/knowledge/archive-db.d.ts.map +1 -1
  21. package/dist/agent/knowledge/archive-db.js +130 -0
  22. package/dist/agent/knowledge/archive-db.js.map +1 -1
  23. package/dist/agent/knowledge/config.d.ts +3 -1
  24. package/dist/agent/knowledge/config.d.ts.map +1 -1
  25. package/dist/agent/knowledge/config.js +32 -10
  26. package/dist/agent/knowledge/config.js.map +1 -1
  27. package/dist/agent/knowledge/index.d.ts +11 -4
  28. package/dist/agent/knowledge/index.d.ts.map +1 -1
  29. package/dist/agent/knowledge/index.js +22 -1
  30. package/dist/agent/knowledge/index.js.map +1 -1
  31. package/dist/agent/knowledge/indexer.d.ts +0 -7
  32. package/dist/agent/knowledge/indexer.d.ts.map +1 -1
  33. package/dist/agent/knowledge/indexer.js +34 -7
  34. package/dist/agent/knowledge/indexer.js.map +1 -1
  35. package/dist/agent/knowledge/lifecycle.d.ts +20 -4
  36. package/dist/agent/knowledge/lifecycle.d.ts.map +1 -1
  37. package/dist/agent/knowledge/lifecycle.js +33 -4
  38. package/dist/agent/knowledge/lifecycle.js.map +1 -1
  39. package/dist/agent/knowledge/reflection.d.ts +114 -0
  40. package/dist/agent/knowledge/reflection.d.ts.map +1 -0
  41. package/dist/agent/knowledge/reflection.js +447 -0
  42. package/dist/agent/knowledge/reflection.js.map +1 -0
  43. package/dist/agent/knowledge/shared-dedup.d.ts +85 -0
  44. package/dist/agent/knowledge/shared-dedup.d.ts.map +1 -0
  45. package/dist/agent/knowledge/shared-dedup.js +187 -0
  46. package/dist/agent/knowledge/shared-dedup.js.map +1 -0
  47. package/dist/agent/knowledge/shared-promote.d.ts +82 -7
  48. package/dist/agent/knowledge/shared-promote.d.ts.map +1 -1
  49. package/dist/agent/knowledge/shared-promote.js +244 -11
  50. package/dist/agent/knowledge/shared-promote.js.map +1 -1
  51. package/dist/agent/knowledge/shared-staleness.d.ts +51 -0
  52. package/dist/agent/knowledge/shared-staleness.d.ts.map +1 -0
  53. package/dist/agent/knowledge/shared-staleness.js +149 -0
  54. package/dist/agent/knowledge/shared-staleness.js.map +1 -0
  55. package/dist/agent/knowledge/shared-writer.d.ts +19 -0
  56. package/dist/agent/knowledge/shared-writer.d.ts.map +1 -1
  57. package/dist/agent/knowledge/shared-writer.js +42 -0
  58. package/dist/agent/knowledge/shared-writer.js.map +1 -1
  59. package/dist/agent/knowledge/types.d.ts +24 -0
  60. package/dist/agent/knowledge/types.d.ts.map +1 -1
  61. package/dist/agent/model-catalog.d.ts +62 -0
  62. package/dist/agent/model-catalog.d.ts.map +1 -0
  63. package/dist/agent/model-catalog.js +264 -0
  64. package/dist/agent/model-catalog.js.map +1 -0
  65. package/dist/agent/runner.d.ts +83 -0
  66. package/dist/agent/runner.d.ts.map +1 -1
  67. package/dist/agent/runner.js +281 -51
  68. package/dist/agent/runner.js.map +1 -1
  69. package/dist/agent/workspace-loader.d.ts +17 -2
  70. package/dist/agent/workspace-loader.d.ts.map +1 -1
  71. package/dist/agent/workspace-loader.js +17 -1
  72. package/dist/agent/workspace-loader.js.map +1 -1
  73. package/dist/api/cron-router.d.ts +12 -8
  74. package/dist/api/cron-router.d.ts.map +1 -1
  75. package/dist/api/cron-router.js +82 -16
  76. package/dist/api/cron-router.js.map +1 -1
  77. package/dist/api/gateway-router.d.ts +4 -0
  78. package/dist/api/gateway-router.d.ts.map +1 -1
  79. package/dist/api/gateway-router.js +16 -1
  80. package/dist/api/gateway-router.js.map +1 -1
  81. package/dist/api/manifest-sources.d.ts +15 -0
  82. package/dist/api/manifest-sources.d.ts.map +1 -0
  83. package/dist/api/manifest-sources.js +27 -0
  84. package/dist/api/manifest-sources.js.map +1 -0
  85. package/dist/api/meta-router.d.ts +13 -0
  86. package/dist/api/meta-router.d.ts.map +1 -0
  87. package/dist/api/meta-router.js +26 -0
  88. package/dist/api/meta-router.js.map +1 -0
  89. package/dist/api/packages.d.ts.map +1 -1
  90. package/dist/api/packages.js +18 -198
  91. package/dist/api/packages.js.map +1 -1
  92. package/dist/api/route-registry.d.ts +81 -0
  93. package/dist/api/route-registry.d.ts.map +1 -0
  94. package/dist/api/route-registry.js +44 -0
  95. package/dist/api/route-registry.js.map +1 -0
  96. package/dist/api/router.d.ts.map +1 -1
  97. package/dist/api/router.js +21 -3
  98. package/dist/api/router.js.map +1 -1
  99. package/dist/apps/compose-generator.js +7 -4
  100. package/dist/apps/compose-generator.js.map +1 -1
  101. package/dist/apps/installer.d.ts +154 -0
  102. package/dist/apps/installer.d.ts.map +1 -1
  103. package/dist/apps/installer.js +657 -54
  104. package/dist/apps/installer.js.map +1 -1
  105. package/dist/cli/args.d.ts +25 -0
  106. package/dist/cli/args.d.ts.map +1 -0
  107. package/dist/cli/args.js +55 -0
  108. package/dist/cli/args.js.map +1 -0
  109. package/dist/cli/colors.d.ts +37 -0
  110. package/dist/cli/colors.d.ts.map +1 -0
  111. package/dist/cli/colors.js +63 -0
  112. package/dist/cli/colors.js.map +1 -0
  113. package/dist/cli/command-names.d.ts +98 -0
  114. package/dist/cli/command-names.d.ts.map +1 -0
  115. package/dist/cli/command-names.js +109 -0
  116. package/dist/cli/command-names.js.map +1 -0
  117. package/dist/cli/commands/agents.d.ts +28 -0
  118. package/dist/cli/commands/agents.d.ts.map +1 -0
  119. package/dist/cli/commands/agents.js +321 -0
  120. package/dist/cli/commands/agents.js.map +1 -0
  121. package/dist/cli/commands/channels.d.ts +17 -0
  122. package/dist/cli/commands/channels.d.ts.map +1 -0
  123. package/dist/cli/commands/channels.js +101 -0
  124. package/dist/cli/commands/channels.js.map +1 -0
  125. package/dist/cli/commands/debug-bundle.d.ts +6 -0
  126. package/dist/cli/commands/debug-bundle.d.ts.map +1 -0
  127. package/dist/cli/commands/debug-bundle.js +220 -0
  128. package/dist/cli/commands/debug-bundle.js.map +1 -0
  129. package/dist/cli/commands/doctor.d.ts +3 -0
  130. package/dist/cli/commands/doctor.d.ts.map +1 -0
  131. package/dist/cli/commands/doctor.js +104 -0
  132. package/dist/cli/commands/doctor.js.map +1 -0
  133. package/dist/cli/commands/gateway.d.ts +12 -0
  134. package/dist/cli/commands/gateway.d.ts.map +1 -0
  135. package/dist/cli/commands/gateway.js +145 -0
  136. package/dist/cli/commands/gateway.js.map +1 -0
  137. package/dist/cli/commands/service.d.ts +33 -0
  138. package/dist/cli/commands/service.d.ts.map +1 -0
  139. package/dist/cli/commands/service.js +513 -0
  140. package/dist/cli/commands/service.js.map +1 -0
  141. package/dist/cli/commands/update.d.ts +16 -0
  142. package/dist/cli/commands/update.d.ts.map +1 -0
  143. package/dist/cli/commands/update.js +149 -0
  144. package/dist/cli/commands/update.js.map +1 -0
  145. package/dist/cli/commands.generated.d.ts +4 -0
  146. package/dist/cli/commands.generated.d.ts.map +1 -0
  147. package/dist/cli/commands.generated.js +220 -0
  148. package/dist/cli/commands.generated.js.map +1 -0
  149. package/dist/cli/health.d.ts +31 -0
  150. package/dist/cli/health.d.ts.map +1 -0
  151. package/dist/cli/health.js +49 -0
  152. package/dist/cli/health.js.map +1 -0
  153. package/dist/cli/http-client.d.ts +140 -0
  154. package/dist/cli/http-client.d.ts.map +1 -0
  155. package/dist/cli/http-client.js +332 -0
  156. package/dist/cli/http-client.js.map +1 -0
  157. package/dist/cli/index.d.ts +27 -0
  158. package/dist/cli/index.d.ts.map +1 -0
  159. package/dist/cli/index.js +372 -0
  160. package/dist/cli/index.js.map +1 -0
  161. package/dist/cli/manager.d.ts +64 -0
  162. package/dist/cli/manager.d.ts.map +1 -0
  163. package/dist/cli/manager.js +181 -0
  164. package/dist/cli/manager.js.map +1 -0
  165. package/dist/cli/output.d.ts +49 -0
  166. package/dist/cli/output.d.ts.map +1 -0
  167. package/dist/cli/output.js +101 -0
  168. package/dist/cli/output.js.map +1 -0
  169. package/dist/cli/prompt.d.ts +29 -0
  170. package/dist/cli/prompt.d.ts.map +1 -0
  171. package/dist/cli/prompt.js +154 -0
  172. package/dist/cli/prompt.js.map +1 -0
  173. package/dist/cli/redact.d.ts +3 -0
  174. package/dist/cli/redact.d.ts.map +1 -0
  175. package/dist/cli/redact.js +33 -0
  176. package/dist/cli/redact.js.map +1 -0
  177. package/dist/cli/types.d.ts +22 -0
  178. package/dist/cli/types.d.ts.map +1 -0
  179. package/dist/cli/types.js +4 -0
  180. package/dist/cli/types.js.map +1 -0
  181. package/dist/config/bootstrap.d.ts +33 -0
  182. package/dist/config/bootstrap.d.ts.map +1 -0
  183. package/dist/config/bootstrap.js +117 -0
  184. package/dist/config/bootstrap.js.map +1 -0
  185. package/dist/config/loader.d.ts.map +1 -1
  186. package/dist/config/loader.js +5 -1
  187. package/dist/config/loader.js.map +1 -1
  188. package/dist/config/watcher.d.ts.map +1 -1
  189. package/dist/config/watcher.js +2 -35
  190. package/dist/config/watcher.js.map +1 -1
  191. package/dist/discord/receiver.d.ts +6 -1
  192. package/dist/discord/receiver.d.ts.map +1 -1
  193. package/dist/discord/receiver.js +9 -1
  194. package/dist/discord/receiver.js.map +1 -1
  195. package/dist/entry.d.ts +3 -0
  196. package/dist/entry.d.ts.map +1 -0
  197. package/dist/entry.js +83 -0
  198. package/dist/entry.js.map +1 -0
  199. package/dist/index.js +197 -47
  200. package/dist/index.js.map +1 -1
  201. package/dist/load-dotenv.d.ts +2 -0
  202. package/dist/load-dotenv.d.ts.map +1 -0
  203. package/dist/load-dotenv.js +68 -0
  204. package/dist/load-dotenv.js.map +1 -0
  205. package/dist/packages/registry.d.ts +50 -0
  206. package/dist/packages/registry.d.ts.map +1 -0
  207. package/dist/packages/registry.js +209 -0
  208. package/dist/packages/registry.js.map +1 -0
  209. package/dist/session/process.d.ts +20 -3
  210. package/dist/session/process.d.ts.map +1 -1
  211. package/dist/session/process.js +120 -5
  212. package/dist/session/process.js.map +1 -1
  213. package/dist/shell/claude-pty-shell.js +146 -27
  214. package/dist/shell/claude-pty-shell.js.map +1 -1
  215. package/dist/shell/draft-phantom.d.ts +49 -0
  216. package/dist/shell/draft-phantom.d.ts.map +1 -0
  217. package/dist/shell/draft-phantom.js +59 -0
  218. package/dist/shell/draft-phantom.js.map +1 -0
  219. package/dist/shell/submit-diag.d.ts +67 -0
  220. package/dist/shell/submit-diag.d.ts.map +1 -0
  221. package/dist/shell/submit-diag.js +69 -0
  222. package/dist/shell/submit-diag.js.map +1 -0
  223. package/dist/shutdown-signals.d.ts +47 -0
  224. package/dist/shutdown-signals.d.ts.map +1 -0
  225. package/dist/shutdown-signals.js +62 -0
  226. package/dist/shutdown-signals.js.map +1 -0
  227. package/dist/telegram/receiver.d.ts +6 -1
  228. package/dist/telegram/receiver.d.ts.map +1 -1
  229. package/dist/telegram/receiver.js +9 -1
  230. package/dist/telegram/receiver.js.map +1 -1
  231. package/dist/types.d.ts +25 -0
  232. package/dist/types.d.ts.map +1 -1
  233. package/dist/utils/orphan-receivers.d.ts +90 -0
  234. package/dist/utils/orphan-receivers.d.ts.map +1 -0
  235. package/dist/utils/orphan-receivers.js +218 -0
  236. package/dist/utils/orphan-receivers.js.map +1 -0
  237. package/dist/utils/paths.d.ts +16 -0
  238. package/dist/utils/paths.d.ts.map +1 -0
  239. package/dist/utils/paths.js +60 -0
  240. package/dist/utils/paths.js.map +1 -0
  241. package/dist/utils/stop-child.d.ts +21 -0
  242. package/dist/utils/stop-child.d.ts.map +1 -0
  243. package/dist/utils/stop-child.js +64 -0
  244. package/dist/utils/stop-child.js.map +1 -0
  245. package/lib/pairing.ts +1 -1
  246. package/mcp/tools/agent/handlers.ts +1 -1
  247. package/mcp/tools/discord/module.ts +23 -15
  248. package/mcp/tools/discord/receiver-server.ts +4 -0
  249. package/mcp/tools/memory/archive-reader.test.ts +25 -1
  250. package/mcp/tools/memory/archive-reader.ts +70 -12
  251. package/mcp/tools/memory/archive-writer.test.ts +168 -0
  252. package/mcp/tools/memory/archive-writer.ts +137 -0
  253. package/mcp/tools/memory/module.test.ts +187 -0
  254. package/mcp/tools/memory/module.ts +192 -6
  255. package/mcp/tools/telegram/module.ts +10 -2
  256. package/mcp/tools/telegram/receiver-server.ts +15 -6
  257. package/mcp/tools/telegram/typing.ts +178 -34
  258. 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;
@@ -1739,16 +1739,23 @@ class AgentRunner extends events_1.EventEmitter {
1739
1739
  // `session_idle` once it is truly done; headless mode only produces
1740
1740
  // one `result` per message, so the timer pattern still applies there.
1741
1741
  if (proc.backend !== 'pty-shell') {
1742
- typingDoneTimer = setTimeout(() => {
1743
- this.writeTypingDone(mapKey);
1744
- typingDoneTimer = null;
1745
- }, TYPING_DONE_DELAY_MS);
1742
+ const waitingForBackgroundWork = proc.retainBackgroundWorkingState();
1743
+ if (!waitingForBackgroundWork) {
1744
+ typingDoneTimer = setTimeout(() => {
1745
+ this.writeTypingDone(mapKey);
1746
+ typingDoneTimer = null;
1747
+ }, TYPING_DONE_DELAY_MS);
1748
+ }
1746
1749
  }
1747
1750
  }
1748
1751
  // PTY shell signals "truly idle" (no active turn, empty queue) with this
1749
1752
  // event. Start the typing-done timer here so the indicator stays alive
1750
1753
  // through multi-turn tool-call sequences and only stops when all work is done.
1751
1754
  if (obj['type'] === 'session_idle' && proc.backend === 'pty-shell') {
1755
+ // A parent can become idle immediately after dispatching background
1756
+ // Agent/Workflow work. Keep the Telegram typing signal and processing
1757
+ // sentinel alive until the task notification starts its follow-up turn.
1758
+ const waitingForBackgroundWork = proc.retainBackgroundWorkingState();
1752
1759
  // Gateway turn queue: on pty-shell this is the true end of the user's
1753
1760
  // turn (the wrapper only emits it when its own `this.turn` is null, so
1754
1761
  // it never fires between sub-turns) — flush the next queued turn.
@@ -1760,10 +1767,12 @@ class AgentRunner extends events_1.EventEmitter {
1760
1767
  }
1761
1768
  // Skill-learning: pty-shell session_idle is the true end-of-user-turn.
1762
1769
  this.skillLearning?.onTurnEnd(mapKey, actualSessionId);
1763
- typingDoneTimer = setTimeout(() => {
1764
- this.writeTypingDone(mapKey);
1765
- typingDoneTimer = null;
1766
- }, TYPING_DONE_DELAY_MS);
1770
+ if (!waitingForBackgroundWork) {
1771
+ typingDoneTimer = setTimeout(() => {
1772
+ this.writeTypingDone(mapKey);
1773
+ typingDoneTimer = null;
1774
+ }, TYPING_DONE_DELAY_MS);
1775
+ }
1767
1776
  }
1768
1777
  }
1769
1778
  catch { /* non-JSON */ }
@@ -1798,6 +1807,11 @@ class AgentRunner extends events_1.EventEmitter {
1798
1807
  messageCountAtSpawn: proc.spawnContext.messageCountAtSpawn,
1799
1808
  }, this.channelFor(mapKey)).catch(() => { });
1800
1809
  }
1810
+ proc.on('backgroundWorkExpired', () => {
1811
+ // The child task never notified its parent within the bounded grace
1812
+ // window; release the Telegram typing signal instead of leaving it live.
1813
+ this.writeTypingDone(mapKey);
1814
+ });
1801
1815
  // Tear down per-chat typing/processing state whenever the subprocess dies.
1802
1816
  // Uses `on` (not `once`): a single SessionProcess can auto-restart its child
1803
1817
  // multiple times over its lifetime, and each death that lacks a final
@@ -1865,7 +1879,7 @@ class AgentRunner extends events_1.EventEmitter {
1865
1879
  /**
1866
1880
  * Dispatch a session management command to the appropriate handler.
1867
1881
  */
1868
- async handleSessionCommand(chatId, content) {
1882
+ async handleSessionCommand(chatId, content, meta = {}) {
1869
1883
  const agentId = this.agentConfig.id;
1870
1884
  if (content.startsWith('/sessions')) {
1871
1885
  await this.handleCommandSessions(agentId, chatId);
@@ -1887,10 +1901,78 @@ class AgentRunner extends events_1.EventEmitter {
1887
1901
  const name = content.replace('/rename', '').trim();
1888
1902
  await this.handleCommandRename(agentId, chatId, name);
1889
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
+ }
1890
1910
  else if (content.startsWith('/stop')) {
1891
1911
  await this.handleCommandStop(chatId);
1892
1912
  }
1893
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
+ }
1894
1976
  /**
1895
1977
  * /sessions — list all sessions for this chat.
1896
1978
  */
@@ -1919,9 +2001,7 @@ class AgentRunner extends events_1.EventEmitter {
1919
2001
  return;
1920
2002
  }
1921
2003
  const effectiveModel = this.agentConfig.claude.model;
1922
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
1923
- const modelConfig = availableModels.find(m => m.id === effectiveModel);
1924
- const contextWindow = modelConfig?.contextWindow ?? 200000;
2004
+ const contextWindow = await this.contextWindowFor(effectiveModel);
1925
2005
  const contextTokens = meta.lastInputTokens ?? 0;
1926
2006
  const usedPct = Math.round((contextTokens / contextWindow) * 100);
1927
2007
  let msgs;
@@ -2039,9 +2119,7 @@ class AgentRunner extends events_1.EventEmitter {
2039
2119
  const meta = index.sessions.find(s => s.id === sessionId);
2040
2120
  const name = meta?.name ?? 'Session';
2041
2121
  const compactModel = this.agentConfig.claude.model;
2042
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
2043
- const modelConfig = availableModels.find(m => m.id === compactModel);
2044
- const contextWindow = modelConfig?.contextWindow ?? 200000;
2122
+ const contextWindow = await this.contextWindowFor(compactModel);
2045
2123
  this.writeAutoForward(chatId, `⏳ Compacting session "${name}"...`);
2046
2124
  try {
2047
2125
  const compactor = new compactor_1.SessionCompactor(this.sessionStore);
@@ -2239,12 +2317,25 @@ class AgentRunner extends events_1.EventEmitter {
2239
2317
  proc.markPendingRestart();
2240
2318
  deferred++;
2241
2319
  }
2242
- else if (deferIdle && proc.source !== 'api') {
2243
- // Lossless deferral for CHANNEL sessions only: leave the idle process
2244
- // running and arm a restart on its next message (mirrors the
2245
- // image-size-threshold path). Do NOT call proc.markPendingRestart() here
2246
- // on an idle process it fires deferredRestartReady immediately and
2247
- // stops the session.
2320
+ else if ((proc.hasLikelyOutstandingBackgroundWork() || deferIdle) && proc.source !== 'api') {
2321
+ // Lossless deferral for CHANNEL sessions: leave the idle process running
2322
+ // and arm a restart on its next message instead of an immediate stop.
2323
+ // Two independent reasons land here, neither gated by skipBusy (which
2324
+ // only governs the isProcessing branch above a session that's already
2325
+ // idle isn't the self-restart-footgun case skipBusy exists for):
2326
+ // - deferIdle: the caller explicitly asked never to SIGKILL an idle
2327
+ // bystander (mirrors the image-size-threshold path).
2328
+ // - hasLikelyOutstandingBackgroundWork(): regardless of which tier
2329
+ // called restartOrDefer — even today's aggressive default — a
2330
+ // session that dispatched a background Agent/Workflow tool_use and
2331
+ // then ended its OWN turn looks idle here, but a sub-agent it
2332
+ // launched may still be doing real work. SIGKILLing it now would
2333
+ // silently destroy that in-flight work with no notification or
2334
+ // recovery (the incident this guards against: a CLAUDE.md-triggered
2335
+ // restart killed a session mid-way through 3 dispatched code-review
2336
+ // sub-agents).
2337
+ // Do NOT call proc.markPendingRestart() here — on an idle process it
2338
+ // fires deferredRestartReady immediately and stops the session.
2248
2339
  //
2249
2340
  // Guard on source: `pendingRestarts` is consumed ONLY in injectTurn (the
2250
2341
  // channel turn path, keyed by chatId). api / __heartbeat__ sessions are
@@ -2296,6 +2387,22 @@ class AgentRunner extends events_1.EventEmitter {
2296
2387
  const stateDir = channel === 'discord' ? '.discord-state' : '.telegram-state';
2297
2388
  return path.join(this.agentConfig.workspace, stateDir, 'typing');
2298
2389
  }
2390
+ /**
2391
+ * Read the live typing-signal file's timestamp as this turn's id. Shared
2392
+ * with the `telegram_reply` MCP tool (a separate process) purely via this
2393
+ * file, so `.forward`/`.replied` markers written around the same turn carry
2394
+ * a matching id — see writeAutoForward()'s dedup comment.
2395
+ */
2396
+ readCurrentTurnId(chatId) {
2397
+ const typingDir = this.getTypingDir(chatId);
2398
+ try {
2399
+ const raw = fs.readFileSync(path.join(typingDir, chatId), 'utf8').trim();
2400
+ return raw || null;
2401
+ }
2402
+ catch {
2403
+ return null;
2404
+ }
2405
+ }
2299
2406
  writeTypingError(chatId, code) {
2300
2407
  const typingDir = this.getTypingDir(chatId);
2301
2408
  try {
@@ -2365,9 +2472,39 @@ class AgentRunner extends events_1.EventEmitter {
2365
2472
  return;
2366
2473
  }
2367
2474
  const typingDir = this.getTypingDir(chatId);
2475
+ const forwardPath = path.join(typingDir, `${chatId}.forward`);
2368
2476
  try {
2369
2477
  fs.mkdirSync(typingDir, { recursive: true });
2370
- fs.writeFileSync(path.join(typingDir, `${chatId}.forward`), JSON.stringify({ text, format }));
2478
+ // Append rather than overwrite: two forward-worthy events for the same
2479
+ // chat before the typing loop drains (e.g. two command replies, or the
2480
+ // race where a queued turn's stop() call drains a prior turn's leftover
2481
+ // state) must both reach the user, not have the second clobber the
2482
+ // first. turnId is the live typing-signal timestamp — the receiver uses
2483
+ // it to scope the `.replied` dedup marker to the turn that wrote it.
2484
+ let entries = [];
2485
+ try {
2486
+ const raw = fs.readFileSync(forwardPath, 'utf8').trim();
2487
+ if (raw) {
2488
+ try {
2489
+ const parsed = JSON.parse(raw);
2490
+ entries = Array.isArray(parsed)
2491
+ ? parsed
2492
+ : [parsed];
2493
+ }
2494
+ catch {
2495
+ entries = [{ text: raw, format: 'text', turnId: null }];
2496
+ }
2497
+ }
2498
+ }
2499
+ catch {
2500
+ // No existing file — fresh queue.
2501
+ }
2502
+ entries.push({ text, format, turnId: this.readCurrentTurnId(chatId) });
2503
+ // Atomic write (tmp + rename): the receiver polls this directory on
2504
+ // every typing tick, so a non-atomic write could be read mid-flush.
2505
+ const tmpPath = `${forwardPath}.tmp`;
2506
+ fs.writeFileSync(tmpPath, JSON.stringify(entries));
2507
+ fs.renameSync(tmpPath, forwardPath);
2371
2508
  }
2372
2509
  catch {
2373
2510
  // Non-fatal
@@ -2397,6 +2534,10 @@ class AgentRunner extends events_1.EventEmitter {
2397
2534
  startIdleCleaner() {
2398
2535
  this.idleCleanerTimer = setInterval(async () => {
2399
2536
  for (const [id, proc] of this.sessions) {
2537
+ // A parent that just dispatched Agent/Workflow work is CLI-idle but its
2538
+ // sub-agent may still need this session to receive the task notification.
2539
+ if (proc.hasLikelyOutstandingBackgroundWork())
2540
+ continue;
2400
2541
  if (proc.isIdle(this.idleTimeoutMs)) {
2401
2542
  this.logger.info('Stopping idle session', { sessionId: id });
2402
2543
  await proc.stop();
@@ -2494,7 +2635,10 @@ class AgentRunner extends events_1.EventEmitter {
2494
2635
  stopTelegramReceiver() {
2495
2636
  if (!this.receiver)
2496
2637
  return;
2497
- 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();
2498
2642
  this.receiver = null;
2499
2643
  this.logger.info('TelegramReceiver stopped', { agentId: this.agentConfig.id });
2500
2644
  }
@@ -2510,7 +2654,10 @@ class AgentRunner extends events_1.EventEmitter {
2510
2654
  stopDiscordReceiver() {
2511
2655
  if (!this.discordReceiver)
2512
2656
  return;
2513
- 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();
2514
2661
  this.discordReceiver = null;
2515
2662
  this.logger.info('DiscordReceiver stopped', { agentId: this.agentConfig.id });
2516
2663
  }
@@ -2532,10 +2679,17 @@ class AgentRunner extends events_1.EventEmitter {
2532
2679
  clearTimeout(buf.timer);
2533
2680
  }
2534
2681
  this.channelCoalesce.clear();
2535
- this.receiver?.stop();
2536
- 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()];
2537
2688
  this.stopLineReply();
2538
- 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
+ ]);
2539
2693
  this.sessions.clear();
2540
2694
  }
2541
2695
  /**
@@ -3335,9 +3489,7 @@ class AgentRunner extends events_1.EventEmitter {
3335
3489
  responseText = `Session: (unnamed)\nMessages: ${messageCount}\nContext used: 0%\nModel: ${effectiveModel}`;
3336
3490
  }
3337
3491
  else {
3338
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
3339
- const modelConfig = availableModels.find((m) => m.id === effectiveModel);
3340
- const contextWindow = modelConfig?.contextWindow ?? 200000;
3492
+ const contextWindow = await this.contextWindowFor(effectiveModel);
3341
3493
  const contextUsedPct = Math.round(((meta.lastInputTokens ?? 0) / contextWindow) * 100);
3342
3494
  result = { sessionId, sessionName: meta.name, messageCount, archivedCount: meta.archivedCount ?? 0, contextUsedPct, model: effectiveModel };
3343
3495
  responseText = [
@@ -3394,9 +3546,7 @@ class AgentRunner extends events_1.EventEmitter {
3394
3546
  const ch = 'api';
3395
3547
  const activeSession = this.sessions.get(sessionId);
3396
3548
  const compactEffectiveModel = opts?.model ?? activeSession?.modelOverride ?? this.agentConfig.claude.model;
3397
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
3398
- const modelConfig = availableModels.find((m) => m.id === compactEffectiveModel);
3399
- const contextWindow = modelConfig?.contextWindow ?? 200000;
3549
+ const contextWindow = await this.contextWindowFor(compactEffectiveModel);
3400
3550
  const compactor = new compactor_1.SessionCompactor(this.sessionStore);
3401
3551
  const compactResult = await compactor.compact(agentId, storeChatId, sessionId, compactEffectiveModel, contextWindow, ch);
3402
3552
  await this.sessionStore.updateSessionMeta(agentId, storeChatId, sessionId, {
@@ -3430,6 +3580,88 @@ class AgentRunner extends events_1.EventEmitter {
3430
3580
  persist('assistant', responseText, forcePersist);
3431
3581
  return { result, responseText };
3432
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
+ }
3433
3665
  async setModel(newModel) {
3434
3666
  // Allow any model string through — BYOK/third-party models (openrouter/* etc.)
3435
3667
  // are validated by the upstream provider, not the local config list.
@@ -3478,9 +3710,7 @@ class AgentRunner extends events_1.EventEmitter {
3478
3710
  if (!meta)
3479
3711
  return null;
3480
3712
  const effectiveModel = this.agentConfig.claude.model;
3481
- const availableModels = this.gatewayConfig.gateway.models ?? exports.DEFAULT_MODELS;
3482
- const modelConfig = availableModels.find((m) => m.id === effectiveModel);
3483
- const contextWindow = modelConfig?.contextWindow ?? 200000;
3713
+ const contextWindow = await this.contextWindowFor(effectiveModel);
3484
3714
  const contextUsedPct = Math.round(((meta.lastInputTokens ?? 0) / contextWindow) * 100);
3485
3715
  return {
3486
3716
  sessionId: meta.id,