@0xmaxma/claude-gateway 1.3.4 → 1.3.6

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 (55) hide show
  1. package/README.md +1 -1
  2. package/config.template.json +9 -7
  3. package/dist/agent/runner.d.ts +20 -0
  4. package/dist/agent/runner.d.ts.map +1 -1
  5. package/dist/agent/runner.js +94 -1
  6. package/dist/agent/runner.js.map +1 -1
  7. package/dist/api/gateway-router.d.ts +14 -0
  8. package/dist/api/gateway-router.d.ts.map +1 -1
  9. package/dist/api/gateway-router.js +279 -7
  10. package/dist/api/gateway-router.js.map +1 -1
  11. package/dist/discord/receiver.d.ts +1 -0
  12. package/dist/discord/receiver.d.ts.map +1 -1
  13. package/dist/discord/receiver.js +7 -1
  14. package/dist/discord/receiver.js.map +1 -1
  15. package/dist/session/process.d.ts +8 -0
  16. package/dist/session/process.d.ts.map +1 -1
  17. package/dist/session/process.js +34 -0
  18. package/dist/session/process.js.map +1 -1
  19. package/dist/shell/claude-pty-shell.js +211 -4
  20. package/dist/shell/claude-pty-shell.js.map +1 -1
  21. package/dist/shell/emitter.d.ts +13 -0
  22. package/dist/shell/emitter.d.ts.map +1 -1
  23. package/dist/shell/emitter.js +16 -0
  24. package/dist/shell/emitter.js.map +1 -1
  25. package/dist/shell/menu-cancel.d.ts +50 -0
  26. package/dist/shell/menu-cancel.d.ts.map +1 -0
  27. package/dist/shell/menu-cancel.js +62 -0
  28. package/dist/shell/menu-cancel.js.map +1 -0
  29. package/dist/shell/pty-serialize.d.ts +23 -0
  30. package/dist/shell/pty-serialize.d.ts.map +1 -0
  31. package/dist/shell/pty-serialize.js +131 -0
  32. package/dist/shell/pty-serialize.js.map +1 -0
  33. package/dist/shell/pty-stream-registry.d.ts +59 -0
  34. package/dist/shell/pty-stream-registry.d.ts.map +1 -0
  35. package/dist/shell/pty-stream-registry.js +279 -0
  36. package/dist/shell/pty-stream-registry.js.map +1 -0
  37. package/dist/shell/screen.d.ts +43 -0
  38. package/dist/shell/screen.d.ts.map +1 -1
  39. package/dist/shell/screen.js +91 -1
  40. package/dist/shell/screen.js.map +1 -1
  41. package/dist/telegram/receiver.d.ts +1 -0
  42. package/dist/telegram/receiver.d.ts.map +1 -1
  43. package/dist/telegram/receiver.js +7 -1
  44. package/dist/telegram/receiver.js.map +1 -1
  45. package/dist/ui/web-ui.d.ts +2 -2
  46. package/dist/ui/web-ui.d.ts.map +1 -1
  47. package/dist/ui/web-ui.js +682 -114
  48. package/dist/ui/web-ui.js.map +1 -1
  49. package/mcp/tools/discord/module.ts +88 -1
  50. package/mcp/tools/discord/outbound.ts +36 -0
  51. package/mcp/tools/discord/types.ts +2 -0
  52. package/mcp/tools/telegram/menu-parser.ts +37 -0
  53. package/mcp/tools/telegram/receiver-server.ts +83 -0
  54. package/mcp/tools/telegram/typing.ts +7 -1
  55. package/package.json +4 -2
@@ -1 +1 @@
1
- {"version":3,"file":"web-ui.js","sourceRoot":"","sources":["../../src/ui/web-ui.ts"],"names":[],"mappings":";;AAIA,sDA0NC;AA9ND;;;GAGG;AACH,SAAgB,qBAAqB;IACnC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAwND,CAAC;AACT,CAAC"}
1
+ {"version":3,"file":"web-ui.js","sourceRoot":"","sources":["../../src/ui/web-ui.ts"],"names":[],"mappings":";;AAIA,sDAkxBC;AAtxBD;;;GAGG;AACH,SAAgB,qBAAqB,CAAC,SAAS,GAAG,EAAE;IAClD,MAAM,SAAS,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAC3E,OAAO;;;;;qCAK4B,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QA0wBtC,CAAC;AACT,CAAC"}
@@ -16,9 +16,10 @@ import type {
16
16
  McpToolResult,
17
17
  ToolVisibility,
18
18
  InboundMessageHandler,
19
+ InboundMessage,
19
20
  ChannelId,
20
21
  } from '../../types';
21
- import { sendMessage } from './outbound';
22
+ import { sendMessage, buildChoiceComponents } from './outbound';
22
23
  import { loadAccess, saveAccess, gate } from './access';
23
24
  import { maybeCreateThread } from './threading';
24
25
  import { createMessageHandler } from './inbound';
@@ -260,6 +261,74 @@ export class DiscordModule implements ChannelModule {
260
261
  }
261
262
  });
262
263
 
264
+ // Interactive-menu button clicks: custom_id `choice:N`. A click is routed
265
+ // exactly like the user typing "N" (same InboundMessage → handler), so the
266
+ // session's pending-menu handler injects the selection into the PTY.
267
+ // Security mirrors the message path: the access gate must return 'deliver'.
268
+ // discord.js client is typed as `any` (dynamic import) so we narrow locally.
269
+ interface ButtonInteraction {
270
+ isButton?: () => boolean;
271
+ customId?: string;
272
+ guildId?: string | null;
273
+ channelId: string;
274
+ channel?: { isThread?: () => boolean };
275
+ user: { id: string; username: string };
276
+ message?: { id: string };
277
+ client: { user?: { id: string } };
278
+ reply(opts: { content: string; ephemeral: boolean }): Promise<unknown>;
279
+ update(opts: { components: unknown[] }): Promise<unknown>;
280
+ }
281
+ this.client.on('interactionCreate', async (interaction: ButtonInteraction) => {
282
+ try {
283
+ if (!interaction.isButton?.()) return;
284
+ const m = /^choice:(\d+)$/.exec(interaction.customId ?? '');
285
+ if (!m) return;
286
+
287
+ const isDM = !interaction.guildId;
288
+ const isThread = interaction.channel?.isThread?.() ?? false;
289
+ const context: DiscordMessageContext = {
290
+ guildId: interaction.guildId ?? null,
291
+ channelId: interaction.channelId,
292
+ threadId: isThread ? interaction.channelId : null,
293
+ userId: interaction.user.id,
294
+ username: interaction.user.username,
295
+ messageId: interaction.message?.id ?? '',
296
+ isDM,
297
+ isThread,
298
+ };
299
+ const access = loadAccessFn();
300
+ const result = gate(access, context, saveAccessFn, () => randomBytes(3).toString('hex'));
301
+ if (result.action !== 'deliver') {
302
+ await interaction.reply({ content: 'Not authorized.', ephemeral: true }).catch(() => {});
303
+ return;
304
+ }
305
+
306
+ // Disable the buttons (acknowledges the click + prevents double-selection).
307
+ await interaction.update({ components: [] }).catch(() => {});
308
+
309
+ const channelId = interaction.channelId;
310
+ const typingFileDir = path.join(this.stateDir, 'typing');
311
+ try {
312
+ fs.mkdirSync(typingFileDir, { recursive: true });
313
+ fs.writeFileSync(path.join(typingFileDir, channelId), '');
314
+ } catch {}
315
+
316
+ const inbound: InboundMessage = {
317
+ channel: 'discord',
318
+ accountId: interaction.client.user?.id ?? 'discord',
319
+ senderId: interaction.user.id,
320
+ chatId: channelId,
321
+ chatType: isDM ? 'direct' : 'group',
322
+ text: m[1],
323
+ messageId: interaction.message?.id ?? '',
324
+ ts: Date.now(),
325
+ };
326
+ await handler(inbound);
327
+ } catch (err) {
328
+ process.stderr.write(`discord: interaction handler error: ${err}\n`);
329
+ }
330
+ });
331
+
263
332
  const approvedDir = path.join(this.stateDir, 'approved');
264
333
  const approvedInterval = setInterval(() => { void this.checkApprovals(approvedDir); }, 5000);
265
334
  approvedInterval.unref();
@@ -288,6 +357,24 @@ export class DiscordModule implements ChannelModule {
288
357
  try { files = fs.readdirSync(typingDir); } catch { return; }
289
358
 
290
359
  for (const file of files) {
360
+ // Interactive menu: render the question with Secondary buttons. Each click
361
+ // routes back as a normal "N" message (see the interactionCreate handler).
362
+ if (file.endsWith('.menu')) {
363
+ const filePath = path.join(typingDir, file);
364
+ const channelId = file.slice(0, -'.menu'.length);
365
+ try {
366
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8').trim()) as {
367
+ text: string;
368
+ options: Array<{ label: string }>;
369
+ };
370
+ fs.rmSync(filePath, { force: true });
371
+ if (parsed.text && Array.isArray(parsed.options) && parsed.options.length) {
372
+ const channel = await this.client.channels.fetch(channelId);
373
+ await sendMessage(channel, parsed.text, { components: buildChoiceComponents(parsed.options) });
374
+ }
375
+ } catch { /* non-fatal — result text carries the numbered list as fallback */ }
376
+ continue;
377
+ }
291
378
  if (!file.endsWith('.forward')) continue;
292
379
  const filePath = path.join(typingDir, file);
293
380
  const channelId = file.slice(0, -'.forward'.length);
@@ -17,10 +17,23 @@ export async function sendMessage(
17
17
  replyTo?: string;
18
18
  files?: string[];
19
19
  useEmbed?: boolean;
20
+ components?: unknown[];
20
21
  } = {},
21
22
  ): Promise<SentMessage[]> {
22
23
  const sent: SentMessage[] = [];
23
24
 
25
+ // Interactive menu: send the text with button rows attached to the first message.
26
+ if (options.components && options.components.length) {
27
+ const chunks = chunkText(text, MAX_MESSAGE_LENGTH);
28
+ for (let i = 0; i < chunks.length; i++) {
29
+ const last = i === chunks.length - 1;
30
+ const sendOpts: Parameters<SendableChannel['send']>[0] = { content: chunks[i] };
31
+ if (last) sendOpts.components = options.components;
32
+ sent.push(await channel.send(sendOpts));
33
+ }
34
+ return sent;
35
+ }
36
+
24
37
  if (text.length > MAX_MESSAGE_LENGTH && options.useEmbed) {
25
38
  const embedText = text.slice(0, MAX_EMBED_DESCRIPTION);
26
39
  const embed: EmbedData = { description: embedText };
@@ -55,6 +68,29 @@ export async function sendMessage(
55
68
  return sent;
56
69
  }
57
70
 
71
+ /**
72
+ * Build Discord action-row components for an interactive menu. Each option
73
+ * becomes a Secondary button labelled "N. label" whose custom_id is `choice:N`
74
+ * (≤5 buttons per row, ≤5 rows). Uses raw component JSON to keep this module
75
+ * free of a discord.js import (matching the rest of the file).
76
+ */
77
+ export function buildChoiceComponents(options: Array<{ label: string }>): unknown[] {
78
+ const rows: unknown[] = [];
79
+ for (let i = 0; i < options.length && rows.length < 5; i += 5) {
80
+ const buttons = options.slice(i, i + 5).map((o, j) => {
81
+ const n = i + j + 1;
82
+ return {
83
+ type: 2, // Button
84
+ style: 2, // Secondary
85
+ label: `${n}. ${o.label}`.slice(0, 80),
86
+ custom_id: `choice:${n}`,
87
+ };
88
+ });
89
+ rows.push({ type: 1, components: buttons }); // ActionRow
90
+ }
91
+ return rows;
92
+ }
93
+
58
94
  export function chunkText(text: string, limit: number): string[] {
59
95
  if (text.length <= limit) return [text];
60
96
  const chunks: string[] = [];
@@ -65,6 +65,8 @@ export type SendOptions = {
65
65
  embeds?: EmbedData[];
66
66
  files?: FileAttachment[];
67
67
  reply?: { messageReference: string };
68
+ /** Raw Discord message components (action rows of buttons), passed through to channel.send. */
69
+ components?: unknown[];
68
70
  };
69
71
 
70
72
  export type SentMessage = { id: string };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pure parsing logic for .menu files written by AgentRunner.writeMenuForward.
3
+ * Kept in a separate module so tests can import it without pulling in the full
4
+ * bot runtime (receiver-server.ts imports grammy + all its dependencies).
5
+ */
6
+
7
+ export type InlineKeyboardButton = { text: string; callback_data: string };
8
+
9
+ export type MenuMessage = {
10
+ text: string;
11
+ inline_keyboard: Array<Array<InlineKeyboardButton>>;
12
+ };
13
+
14
+ /**
15
+ * Parse raw .menu file content into a Telegram sendMessage payload.
16
+ * Returns null if the content is malformed or missing required fields.
17
+ */
18
+ export function parseMenuFileContent(raw: string): MenuMessage | null {
19
+ let parsed: { text?: unknown; options?: unknown };
20
+ try {
21
+ parsed = JSON.parse(raw) as { text?: unknown; options?: unknown };
22
+ } catch {
23
+ return null;
24
+ }
25
+ const text = typeof parsed.text === 'string' ? parsed.text : '';
26
+ const options = Array.isArray(parsed.options)
27
+ ? (parsed.options as Array<{ label?: unknown }>)
28
+ .map(o => (typeof o?.label === 'string' ? o.label : ''))
29
+ .filter((l): l is string => l.length > 0)
30
+ : [];
31
+ if (!text || options.length === 0) return null;
32
+ const inline_keyboard = options.map((label, i) => [{
33
+ text: `${i + 1}. ${label}`.slice(0, 60),
34
+ callback_data: `choice:${i + 1}`,
35
+ }]);
36
+ return { text, inline_keyboard };
37
+ }
@@ -690,6 +690,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
690
690
  const SEND_ONLY = process.env.TELEGRAM_SEND_ONLY === 'true'
691
691
  const RECEIVER_MODE = process.env.TELEGRAM_RECEIVER_MODE === 'true'
692
692
 
693
+ import { parseMenuFileContent } from './menu-parser'
694
+
693
695
  const BOT_COMMANDS = [
694
696
  { command: 'session', description: 'Show current session info' },
695
697
  { command: 'sessions', description: 'Manage conversation sessions' },
@@ -1157,6 +1159,45 @@ bot.command('clear', async ctx => {
1157
1159
  bot.on('callback_query:data', async ctx => {
1158
1160
  const data = ctx.callbackQuery.data
1159
1161
 
1162
+ // Handle interactive-menu choice: choice:<N>. A tap is routed back exactly like
1163
+ // the user typing "N" — same content + meta as the text path — so the session's
1164
+ // pending-menu handler injects the selection into the PTY. Security mirrors the
1165
+ // text path: the tapper must be in allowFrom.
1166
+ const choiceMatch = /^choice:(\d+)$/.exec(data)
1167
+ if (choiceMatch) {
1168
+ const access = loadAccess()
1169
+ if (!access.allowFrom.includes(String(ctx.from.id))) {
1170
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
1171
+ return
1172
+ }
1173
+ const chat_id = String(ctx.callbackQuery.message?.chat.id ?? ctx.from.id)
1174
+ const choice = choiceMatch[1]
1175
+ // Remove the keyboard immediately to prevent double-selection.
1176
+ await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {})
1177
+ await ctx.answerCallbackQuery({ text: `✓ ${choice}` }).catch(() => {})
1178
+ const callbackUrl = process.env.CLAUDE_CHANNEL_CALLBACK
1179
+ if (callbackUrl) {
1180
+ const channelParams = {
1181
+ content: choice,
1182
+ meta: {
1183
+ chat_id,
1184
+ user: ctx.from.username ?? String(ctx.from.id),
1185
+ user_id: String(ctx.from.id),
1186
+ ts: new Date().toISOString(),
1187
+ },
1188
+ }
1189
+ fetch(callbackUrl, {
1190
+ method: 'POST',
1191
+ headers: { 'Content-Type': 'application/json' },
1192
+ body: JSON.stringify(channelParams),
1193
+ }).catch(err => {
1194
+ process.stderr.write(`telegram channel: choice callback POST failed: ${err}\n`)
1195
+ })
1196
+ if (RECEIVER_MODE) typingManager.start(chat_id)
1197
+ }
1198
+ return
1199
+ }
1200
+
1160
1201
  // Handle model selection callback: model:<model_id>
1161
1202
  const modelMatch = /^model:(.+)$/.exec(data)
1162
1203
  if (modelMatch) {
@@ -1633,6 +1674,48 @@ if (RECEIVER_MODE) {
1633
1674
  process.on('SIGTERM', shutdown)
1634
1675
  process.on('SIGINT', shutdown)
1635
1676
 
1677
+ // Interactive-menu delivery. The AgentRunner drops a `<chatId>.menu` file in
1678
+ // TYPING_DIR whenever the PTY session blocks on an interactive select menu
1679
+ // (AskUserQuestion / plan approval). We render it as inline buttons here.
1680
+ //
1681
+ // This MUST be independent of the typing-indicator lifecycle: a menu can be
1682
+ // emitted after typing has already been torn down (e.g. the agent called the
1683
+ // reply tool earlier in the same turn, or the turn ended), and the old
1684
+ // stop()-coupled drain would early-return on a missing typing state, leaving
1685
+ // the .menu file orphaned and the user staring at a silent chat. A standalone
1686
+ // poller — mirroring the Discord side — guarantees every menu reaches the chat.
1687
+ function drainMenuFiles(): void {
1688
+ let files: string[]
1689
+ try {
1690
+ files = readdirSync(TYPING_DIR)
1691
+ } catch {
1692
+ return
1693
+ }
1694
+ for (const name of files) {
1695
+ if (!name.endsWith('.menu')) continue
1696
+ const chatId = name.slice(0, -'.menu'.length)
1697
+ const menuPath = join(TYPING_DIR, name)
1698
+ let raw: string
1699
+ try {
1700
+ raw = readFileSync(menuPath, 'utf8').trim()
1701
+ } catch {
1702
+ // Malformed or mid-write (the runner writes atomically, so this is rare) —
1703
+ // drop it so the poller doesn't spin on the same bad file forever.
1704
+ rmSync(menuPath, { force: true })
1705
+ continue
1706
+ }
1707
+ // Remove BEFORE sending: a slow or failing send must not let the next tick
1708
+ // re-deliver the same menu (which would stack duplicate button messages).
1709
+ rmSync(menuPath, { force: true })
1710
+ const msg = parseMenuFileContent(raw)
1711
+ if (!msg) continue
1712
+ void bot.api.sendMessage(chatId, msg.text, { reply_markup: { inline_keyboard: msg.inline_keyboard } }).catch(err => {
1713
+ process.stderr.write(`telegram channel (receiver): failed to send menu to ${chatId}: ${err}\n`)
1714
+ })
1715
+ }
1716
+ }
1717
+ setInterval(drainMenuFiles, 1000).unref()
1718
+
1636
1719
  void (async () => {
1637
1720
  for (let attempt = 1; ; attempt++) {
1638
1721
  try {
@@ -96,7 +96,7 @@ export interface WorkingState {
96
96
 
97
97
  export interface BotApi {
98
98
  sendChatAction(chatId: string, action: 'typing'): Promise<unknown>
99
- sendMessage(chatId: string, text: string, opts?: { parse_mode?: 'MarkdownV2' | 'HTML' | 'Markdown' }): Promise<{ message_id: number }>
99
+ sendMessage(chatId: string, text: string, opts?: { parse_mode?: 'MarkdownV2' | 'HTML' | 'Markdown'; reply_markup?: unknown }): Promise<{ message_id: number }>
100
100
  editMessageText(chatId: string, msgId: number, text: string): Promise<unknown>
101
101
  deleteMessage(chatId: string, msgId: number): Promise<unknown>
102
102
  setMessageReaction(chatId: string, msgId: number, emoji: string): Promise<unknown>
@@ -212,6 +212,12 @@ export function createWorkingStateManager(
212
212
  } catch {}
213
213
  fsApi.rmSync(forwardPath, { force: true })
214
214
  }
215
+ // NOTE: interactive-menu (.menu) delivery is handled by an independent poller
216
+ // in receiver-server.ts (drainMenuFiles), NOT here. A menu can be emitted
217
+ // after the typing state has already been torn down (reply tool called earlier
218
+ // in the turn, or the turn ended), in which case this stop() never runs for it
219
+ // and the .menu file would be orphaned. The standalone poller has no such
220
+ // coupling, so it reliably delivers every menu. Do not re-add a drain here.
215
221
  fsApi.rmSync(repliedPath, { force: true })
216
222
  // Read signal file timestamp before deleting — process.ts overwrites it with a newer
217
223
  // timestamp when a queued turn is injected; if newer than this turn's startedAt it means
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmaxma/claude-gateway",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "Multi-agent gateway for Claude",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,10 +47,12 @@
47
47
  "lru-cache": "^10.0.0",
48
48
  "node-cron": "^3.0.0",
49
49
  "node-pty": "^1.1.0",
50
- "p-queue": "^6.6.2"
50
+ "p-queue": "^6.6.2",
51
+ "ws": "^8.21.0"
51
52
  },
52
53
  "devDependencies": {
53
54
  "@types/express": "^4.17.0",
55
+ "@types/ws": "^8.18.1",
54
56
  "@types/jest": "^29.0.0",
55
57
  "@types/js-yaml": "^4.0.0",
56
58
  "@types/node": "^22.19.18",