@0xmaxma/claude-gateway 1.4.8 → 1.5.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 (41) hide show
  1. package/README.md +25 -2
  2. package/config.template.json +1 -0
  3. package/dist/agent/runner.d.ts +14 -0
  4. package/dist/agent/runner.d.ts.map +1 -1
  5. package/dist/agent/runner.js +59 -0
  6. package/dist/agent/runner.js.map +1 -1
  7. package/dist/api/gateway-router.d.ts +20 -0
  8. package/dist/api/gateway-router.d.ts.map +1 -1
  9. package/dist/api/gateway-router.js +214 -0
  10. package/dist/api/gateway-router.js.map +1 -1
  11. package/dist/api/line-webhook-router.d.ts +5 -0
  12. package/dist/api/line-webhook-router.d.ts.map +1 -1
  13. package/dist/api/line-webhook-router.js +63 -0
  14. package/dist/api/line-webhook-router.js.map +1 -1
  15. package/dist/apps/agent-manager.d.ts.map +1 -1
  16. package/dist/apps/agent-manager.js +14 -0
  17. package/dist/apps/agent-manager.js.map +1 -1
  18. package/dist/cli-viewer/pairing-store.d.ts +117 -0
  19. package/dist/cli-viewer/pairing-store.d.ts.map +1 -0
  20. package/dist/cli-viewer/pairing-store.js +196 -0
  21. package/dist/cli-viewer/pairing-store.js.map +1 -0
  22. package/dist/cli-viewer/telegram-initdata.d.ts +21 -0
  23. package/dist/cli-viewer/telegram-initdata.d.ts.map +1 -0
  24. package/dist/cli-viewer/telegram-initdata.js +74 -0
  25. package/dist/cli-viewer/telegram-initdata.js.map +1 -0
  26. package/dist/cli-viewer/url.d.ts +13 -0
  27. package/dist/cli-viewer/url.d.ts.map +1 -0
  28. package/dist/cli-viewer/url.js +27 -0
  29. package/dist/cli-viewer/url.js.map +1 -0
  30. package/dist/session/process.d.ts.map +1 -1
  31. package/dist/session/process.js +7 -0
  32. package/dist/session/process.js.map +1 -1
  33. package/dist/types.d.ts +10 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/ui/cli-viewer-ui.d.ts +37 -0
  36. package/dist/ui/cli-viewer-ui.d.ts.map +1 -0
  37. package/dist/ui/cli-viewer-ui.js +274 -0
  38. package/dist/ui/cli-viewer-ui.js.map +1 -0
  39. package/mcp/tools/discord/module.ts +99 -1
  40. package/mcp/tools/telegram/receiver-server.ts +41 -1
  41. package/package.json +1 -1
@@ -33,6 +33,18 @@ import type { DiscordMessageContext } from './types';
33
33
 
34
34
  const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;
35
35
 
36
+ /** AgentRunner callback base (origin of CLAUDE_CHANNEL_CALLBACK, "" when unset).
37
+ * Used to mint/approve `/cli` pairings via the runner, the same bridge the
38
+ * Telegram receiver uses for /models. */
39
+ function cliCallbackBase(): string {
40
+ try {
41
+ const u = new URL(process.env.CLAUDE_CHANNEL_CALLBACK ?? '');
42
+ return `${u.protocol}//${u.host}`;
43
+ } catch {
44
+ return '';
45
+ }
46
+ }
47
+
36
48
  export class DiscordModule implements ChannelModule {
37
49
  id = 'discord' as ChannelId;
38
50
  toolVisibility: ToolVisibility = 'current-channel';
@@ -281,6 +293,45 @@ export class DiscordModule implements ChannelModule {
281
293
  }
282
294
 
283
295
  // action === 'deliver'
296
+ // `/cli` opens the live terminal viewer: mint a pairing (link + Approve
297
+ // button) instead of forwarding the text to the agent. Only reached after
298
+ // gate() authorized this user, so it inherits the channel's access control.
299
+ const cliText = (msg.content ?? '').replace(/<@!?\d+>/g, '').trim().toLowerCase();
300
+ if (cliText === '/cli') {
301
+ const base = cliCallbackBase();
302
+ try {
303
+ const res = await fetch(base + '/command', {
304
+ method: 'POST',
305
+ headers: { 'Content-Type': 'application/json' },
306
+ body: JSON.stringify({ command: 'cli_pair', payload: { channel: 'discord', user_id: context.userId } }),
307
+ });
308
+ const data = (await res.json()) as { success?: boolean; url?: string; code?: string; pairingId?: string; error?: string };
309
+ if (!data.success || !data.url || !data.pairingId) {
310
+ await msg.channel.send(
311
+ data.error === 'not_configured'
312
+ ? 'Terminal viewer is not configured. Set gateway.publicUrl in config.json first.'
313
+ : 'Could not open the terminal viewer right now.',
314
+ ).catch(() => {});
315
+ return;
316
+ }
317
+ const components = [{
318
+ type: 1,
319
+ components: [
320
+ { type: 2, style: 5, label: '\u{1F5A5} Open terminal', url: data.url },
321
+ { type: 2, style: 2, label: `Approve ${data.code}`, custom_id: `cli:approve:${data.pairingId}` },
322
+ { type: 2, style: 4, label: 'Deny', custom_id: `cli:deny:${data.pairingId}` },
323
+ ],
324
+ }];
325
+ await msg.channel.send({
326
+ content: 'Live terminal viewer — open the link, confirm the code matches, then tap **Approve** to unlock (read-only by default).',
327
+ components,
328
+ }).catch(() => {});
329
+ } catch {
330
+ await msg.channel.send('Could not open the terminal viewer right now.').catch(() => {});
331
+ }
332
+ return;
333
+ }
334
+
284
335
  // Start typing indicator before handing off to runner
285
336
  const channelId = context.channelId;
286
337
  const typingFileDir = path.join(this.stateDir, 'typing');
@@ -347,12 +398,59 @@ export class DiscordModule implements ChannelModule {
347
398
  message?: { id: string };
348
399
  client: { user?: { id: string } };
349
400
  reply(opts: { content: string; ephemeral: boolean }): Promise<unknown>;
350
- update(opts: { components: unknown[] }): Promise<unknown>;
401
+ update(opts: { components: unknown[]; content?: string }): Promise<unknown>;
351
402
  }
352
403
  this.client.on('interactionCreate', async (interaction: ButtonInteraction) => {
353
404
  try {
354
405
  if (!interaction.isButton?.()) return;
355
406
 
407
+ // `/cli` approve/deny — unlock (or reject) a pending terminal-viewer
408
+ // pairing. Re-runs the access gate on the tapping user, then relays the
409
+ // decision to the runner. The pairing itself checks the user id matches.
410
+ const cliM = /^cli:(approve|deny):([0-9a-f]{36})$/.exec(interaction.customId ?? '');
411
+ if (cliM) {
412
+ const isDM = !interaction.guildId;
413
+ const isThread = interaction.channel?.isThread?.() ?? false;
414
+ const context: DiscordMessageContext = {
415
+ guildId: interaction.guildId ?? null,
416
+ channelId: interaction.channelId,
417
+ threadId: isThread ? interaction.channelId : null,
418
+ userId: interaction.user.id,
419
+ username: interaction.user.username,
420
+ messageId: interaction.message?.id ?? '',
421
+ isDM,
422
+ isThread,
423
+ mentionsBot: true,
424
+ };
425
+ const access = loadAccessFn();
426
+ const result = gate(access, context, saveAccessFn, () => randomBytes(3).toString('hex'));
427
+ if (result.action !== 'deliver') {
428
+ await interaction.reply({ content: 'Not authorized.', ephemeral: true }).catch(() => {});
429
+ return;
430
+ }
431
+ const deny = cliM[1] === 'deny';
432
+ const base = cliCallbackBase();
433
+ let ok = false;
434
+ try {
435
+ const res = await fetch(base + '/command', {
436
+ method: 'POST',
437
+ headers: { 'Content-Type': 'application/json' },
438
+ body: JSON.stringify({
439
+ command: 'cli_approve',
440
+ payload: { channel: 'discord', pairing_id: cliM[2], user_id: interaction.user.id, deny },
441
+ }),
442
+ });
443
+ ok = !!((await res.json()) as { success?: boolean }).success;
444
+ } catch {}
445
+ const content = deny
446
+ ? '\u{1F6AB} Denied.'
447
+ : ok
448
+ ? '✅ Approved — return to the browser.'
449
+ : '⚠️ Could not approve (the link may have expired). Send /cli again.';
450
+ await interaction.update({ components: [], content }).catch(() => {});
451
+ return;
452
+ }
453
+
356
454
  // Cancel button: send ESC sentinel to dismiss the pending menu cleanly.
357
455
  if ((interaction.customId ?? '') === 'menu:cancel') {
358
456
  const isDM = !interaction.guildId;
@@ -881,6 +881,7 @@ const BOT_COMMANDS = [
881
881
  { command: 'restart', description: 'Graceful restart session' },
882
882
  { command: 'model', description: 'Show current AI model' },
883
883
  { command: 'models', description: 'Switch AI model' },
884
+ { command: 'cli', description: 'Open the live terminal viewer' },
884
885
  { command: 'start', description: 'Welcome and setup guide' },
885
886
  { command: 'status', description: 'Check your pairing status' },
886
887
  { command: 'help', description: 'What this bot can do' },
@@ -1146,7 +1147,8 @@ bot.command('help', async ctx => {
1146
1147
  `/restart — graceful restart session\n\n` +
1147
1148
  `*Agent*\n` +
1148
1149
  `/model — show current AI model\n` +
1149
- `/models — switch AI model\n\n` +
1150
+ `/models — switch AI model\n` +
1151
+ `/cli — open the live terminal viewer\n\n` +
1150
1152
  `*Account*\n` +
1151
1153
  `/start — pairing instructions\n` +
1152
1154
  `/status — check your pairing state`,
@@ -1238,6 +1240,44 @@ bot.command('models', async ctx => {
1238
1240
  }
1239
1241
  })
1240
1242
 
1243
+ // /cli — open the live terminal viewer as a Telegram Mini App. The Mini App
1244
+ // page reads Telegram's signed initData and the gateway verifies it (HMAC with
1245
+ // this bot's token), so nothing secret rides in the URL. Private chat + allowlist
1246
+ // only, matching every other command.
1247
+ bot.command('cli', async ctx => {
1248
+ if (ctx.chat?.type !== 'private') return
1249
+ const access = loadAccess()
1250
+ if (!access.allowFrom.includes(String(ctx.from!.id))) return
1251
+ if (!CALLBACK_URL_BASE) return
1252
+
1253
+ try {
1254
+ const res = await fetch(CALLBACK_URL_BASE + '/command', {
1255
+ method: 'POST',
1256
+ headers: { 'Content-Type': 'application/json' },
1257
+ body: JSON.stringify({
1258
+ command: 'cli_pair',
1259
+ payload: { channel: 'telegram', user_id: String(ctx.from!.id) },
1260
+ }),
1261
+ })
1262
+ const data = (await res.json()) as { success?: boolean; url?: string; error?: string }
1263
+ if (!data.success || !data.url) {
1264
+ if (data.error === 'not_configured') {
1265
+ await ctx.reply('Terminal viewer is not configured. Set gateway.publicUrl in config.json first.')
1266
+ } else {
1267
+ await ctx.reply('Could not open the terminal viewer right now.')
1268
+ }
1269
+ return
1270
+ }
1271
+ // web_app button launches the Mini App; Telegram requires an HTTPS URL.
1272
+ const keyboard = new InlineKeyboard().webApp('\u{1F5A5} Open terminal', data.url)
1273
+ await ctx.reply('Live terminal viewer (read-only by default — toggle input inside):', {
1274
+ reply_markup: keyboard,
1275
+ })
1276
+ } catch {
1277
+ await ctx.reply('Could not open the terminal viewer right now.')
1278
+ }
1279
+ })
1280
+
1241
1281
  // /compact — show compact confirmation keyboard (receiver mode only)
1242
1282
  bot.command('compact', async ctx => {
1243
1283
  if (ctx.chat?.type !== 'private') return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmaxma/claude-gateway",
3
- "version": "1.4.8",
3
+ "version": "1.5.0",
4
4
  "description": "Multi-agent gateway for Claude",
5
5
  "repository": {
6
6
  "type": "git",