@0xmaxma/claude-gateway 1.4.9 → 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 (38) hide show
  1. package/README.md +24 -1
  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/types.d.ts +10 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/ui/cli-viewer-ui.d.ts +37 -0
  33. package/dist/ui/cli-viewer-ui.d.ts.map +1 -0
  34. package/dist/ui/cli-viewer-ui.js +274 -0
  35. package/dist/ui/cli-viewer-ui.js.map +1 -0
  36. package/mcp/tools/discord/module.ts +99 -1
  37. package/mcp/tools/telegram/receiver-server.ts +41 -1
  38. package/package.json +1 -1
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Slim, agent-scoped webview for the `/cli` command. This is deliberately NOT
3
+ * the admin dashboard (`web-ui.ts`): the dashboard exposes every agent, the
4
+ * process tree, and cross-agent PTY injection behind an admin key. The `/cli`
5
+ * viewer shows exactly ONE agent's interactive (pty-shell) session, unlocked by
6
+ * a chat approval or a verified Telegram initData — never an admin credential.
7
+ *
8
+ * Three pages:
9
+ * - device page: "waiting for approval" (Discord/LINE) or auto-initData
10
+ * submit (Telegram), polling until the browser is granted an access session.
11
+ * - viewer page: xterm.js attached to the agent's live PTY over the existing
12
+ * ticket→WebSocket path.
13
+ * - message page: terminal states (expired, opened elsewhere, denied).
14
+ *
15
+ * All API/WebSocket URLs are built relative to `basePath` (the path portion of
16
+ * gateway.publicUrl, e.g. "/gateway" behind an ingress prefix) so the viewer
17
+ * works whether the gateway is mounted at the origin root or under a prefix.
18
+ */
19
+ /** A plain centered message page for terminal/error states. */
20
+ export declare function generateCliMessagePage(title: string, message: string, tone?: 'err' | 'ok' | 'muted'): string;
21
+ export interface CliDevicePageOpts {
22
+ pairingId: string;
23
+ agentId: string;
24
+ code: string;
25
+ channel: 'telegram' | 'discord' | 'line';
26
+ basePath: string;
27
+ }
28
+ /** "Waiting for approval" device page (or Telegram initData auto-submit). */
29
+ export declare function generateCliDevicePage(opts: CliDevicePageOpts): string;
30
+ export interface CliViewerPageOpts {
31
+ pairingId: string;
32
+ agentId: string;
33
+ basePath: string;
34
+ }
35
+ /** The xterm.js viewer, scoped to a single agent's PTY sessions. */
36
+ export declare function generateCliViewerPage(opts: CliViewerPageOpts): string;
37
+ //# sourceMappingURL=cli-viewer-ui.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-viewer-ui.d.ts","sourceRoot":"","sources":["../../src/ui/cli-viewer-ui.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAyDH,+DAA+D;AAC/D,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,KAAK,GAAG,IAAI,GAAG,OAAiB,GAAG,MAAM,CAMrH;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,UAAU,GAAG,SAAS,GAAG,MAAM,CAAC;IACzC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,6EAA6E;AAC7E,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CA4DrE;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,oEAAoE;AACpE,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CA+HrE"}
@@ -0,0 +1,274 @@
1
+ "use strict";
2
+ /**
3
+ * Slim, agent-scoped webview for the `/cli` command. This is deliberately NOT
4
+ * the admin dashboard (`web-ui.ts`): the dashboard exposes every agent, the
5
+ * process tree, and cross-agent PTY injection behind an admin key. The `/cli`
6
+ * viewer shows exactly ONE agent's interactive (pty-shell) session, unlocked by
7
+ * a chat approval or a verified Telegram initData — never an admin credential.
8
+ *
9
+ * Three pages:
10
+ * - device page: "waiting for approval" (Discord/LINE) or auto-initData
11
+ * submit (Telegram), polling until the browser is granted an access session.
12
+ * - viewer page: xterm.js attached to the agent's live PTY over the existing
13
+ * ticket→WebSocket path.
14
+ * - message page: terminal states (expired, opened elsewhere, denied).
15
+ *
16
+ * All API/WebSocket URLs are built relative to `basePath` (the path portion of
17
+ * gateway.publicUrl, e.g. "/gateway" behind an ingress prefix) so the viewer
18
+ * works whether the gateway is mounted at the origin root or under a prefix.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.generateCliMessagePage = generateCliMessagePage;
22
+ exports.generateCliDevicePage = generateCliDevicePage;
23
+ exports.generateCliViewerPage = generateCliViewerPage;
24
+ const XTERM_CSS = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.min.css';
25
+ const XTERM_JS = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/lib/xterm.min.js';
26
+ const TG_WEBAPP_JS = 'https://telegram.org/js/telegram-web-app.js';
27
+ function htmlEscape(s) {
28
+ return s
29
+ .replace(/&/g, '&')
30
+ .replace(/</g, '&lt;')
31
+ .replace(/>/g, '&gt;')
32
+ .replace(/"/g, '&quot;')
33
+ .replace(/'/g, '&#39;');
34
+ }
35
+ const BASE_STYLE = `
36
+ :root { color-scheme: dark; }
37
+ * { box-sizing: border-box; }
38
+ body {
39
+ margin: 0; background: #0b0e14; color: #c8d3f5;
40
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
41
+ height: 100vh; display: flex; flex-direction: column;
42
+ }
43
+ .center { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; }
44
+ .card {
45
+ max-width: 460px; width: 100%; text-align: center;
46
+ background: #131722; border: 1px solid #232a3b; border-radius: 14px; padding: 28px 24px;
47
+ }
48
+ h1 { font-size: 17px; margin: 0 0 6px; font-weight: 600; }
49
+ .muted { color: #7a88a8; font-size: 13px; line-height: 1.5; }
50
+ .agent { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #82aaff; }
51
+ .code {
52
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
53
+ font-size: 40px; letter-spacing: 10px; font-weight: 700; color: #c3e88d;
54
+ margin: 18px 0 8px; padding-left: 10px;
55
+ }
56
+ .spinner {
57
+ width: 26px; height: 26px; margin: 18px auto 0;
58
+ border: 3px solid #232a3b; border-top-color: #82aaff; border-radius: 50%;
59
+ animation: spin 0.9s linear infinite;
60
+ }
61
+ @keyframes spin { to { transform: rotate(360deg); } }
62
+ .err { color: #ff757f; }
63
+ .ok { color: #c3e88d; }
64
+ `;
65
+ /** Shared page shell. `body` is raw HTML; `head` optional extra tags. */
66
+ function page(title, style, body, head = '') {
67
+ return `<!doctype html><html lang="en"><head>
68
+ <meta charset="utf-8">
69
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
70
+ <title>${htmlEscape(title)}</title>
71
+ ${head}
72
+ <style>${style}</style>
73
+ </head><body>${body}</body></html>`;
74
+ }
75
+ /** A plain centered message page for terminal/error states. */
76
+ function generateCliMessagePage(title, message, tone = 'muted') {
77
+ return page(title, BASE_STYLE, `
78
+ <div class="center"><div class="card">
79
+ <h1>${htmlEscape(title)}</h1>
80
+ <p class="${tone === 'muted' ? 'muted' : tone}">${htmlEscape(message)}</p>
81
+ </div></div>`);
82
+ }
83
+ /** "Waiting for approval" device page (or Telegram initData auto-submit). */
84
+ function generateCliDevicePage(opts) {
85
+ const cfg = JSON.stringify({
86
+ pairingId: opts.pairingId,
87
+ basePath: opts.basePath,
88
+ channel: opts.channel,
89
+ });
90
+ const isTelegram = opts.channel === 'telegram';
91
+ const head = isTelegram ? `<script src="${TG_WEBAPP_JS}"></script>` : '';
92
+ const body = `
93
+ <div class="center"><div class="card">
94
+ <h1>Terminal viewer</h1>
95
+ <p class="muted">Agent <span class="agent">${htmlEscape(opts.agentId)}</span></p>
96
+ ${isTelegram
97
+ ? `<p class="muted" id="msg">Verifying with Telegram…</p><div class="spinner" id="spin"></div>`
98
+ : `<div class="code">${htmlEscape(opts.code)}</div>
99
+ <p class="muted">Confirm this code matches your chat, then tap <b>Approve</b> there.</p>
100
+ <p class="muted" id="msg">Waiting for approval…</p><div class="spinner" id="spin"></div>`}
101
+ </div></div>
102
+ <script>
103
+ (function(){
104
+ var CFG = ${cfg};
105
+ var msg = document.getElementById('msg');
106
+ var spin = document.getElementById('spin');
107
+ function base(p){ return CFG.basePath + '/cli/' + CFG.pairingId + p; }
108
+ function fail(t){ if(msg){msg.textContent=t; msg.className='err';} if(spin) spin.style.display='none'; }
109
+ function done(){ if(msg){msg.textContent='Approved — opening…'; msg.className='ok';} location.replace(base('/view')); }
110
+
111
+ if (CFG.channel === 'telegram') {
112
+ var tg = window.Telegram && window.Telegram.WebApp;
113
+ var initData = tg && tg.initData;
114
+ if (tg && tg.ready) { try { tg.ready(); tg.expand && tg.expand(); } catch(e){} }
115
+ if (!initData) { fail('Please open this from the Telegram button.'); return; }
116
+ fetch(base('/tg-init'), {
117
+ method:'POST', headers:{'Content-Type':'application/json'},
118
+ credentials:'same-origin', body: JSON.stringify({ initData: initData })
119
+ }).then(function(r){ return r.ok ? r.json() : r.json().then(function(e){ throw new Error(e.error||('HTTP '+r.status)); }); })
120
+ .then(function(d){ if(d.status==='ready') done(); else fail('Could not verify.'); })
121
+ .catch(function(e){ fail(e.message || 'Verification failed.'); });
122
+ return;
123
+ }
124
+
125
+ // Discord / LINE: poll until the chat approval lands.
126
+ var tries = 0;
127
+ function poll(){
128
+ if (tries++ > 160) { fail('Timed out. Send /cli again.'); return; }
129
+ fetch(base('/status'), { credentials:'same-origin' })
130
+ .then(function(r){ return r.json(); })
131
+ .then(function(d){
132
+ if (d.status==='ready') return done();
133
+ if (d.status==='denied') return fail('Request denied.');
134
+ if (d.status==='gone') return fail('Link expired. Send /cli again.');
135
+ if (d.status==='foreign') return fail('This link was opened in another browser.');
136
+ setTimeout(poll, 1500);
137
+ })
138
+ .catch(function(){ setTimeout(poll, 2000); });
139
+ }
140
+ poll();
141
+ })();
142
+ </script>`;
143
+ return page('Terminal viewer', BASE_STYLE, body, head);
144
+ }
145
+ /** The xterm.js viewer, scoped to a single agent's PTY sessions. */
146
+ function generateCliViewerPage(opts) {
147
+ const cfg = JSON.stringify({
148
+ pairingId: opts.pairingId,
149
+ agentId: opts.agentId,
150
+ basePath: opts.basePath,
151
+ });
152
+ const style = `${BASE_STYLE}
153
+ body { background:#000; }
154
+ header {
155
+ display:flex; align-items:center; gap:10px; padding:8px 12px;
156
+ background:#0b0e14; border-bottom:1px solid #232a3b; font-size:13px; flex:0 0 auto;
157
+ }
158
+ header .agent { font-weight:600; }
159
+ header .status { color:#7a88a8; margin-left:auto; }
160
+ select, button {
161
+ background:#131722; color:#c8d3f5; border:1px solid #2b3450; border-radius:8px;
162
+ padding:6px 10px; font-size:13px; cursor:pointer;
163
+ }
164
+ button.on { background:#1f6feb; border-color:#1f6feb; color:#fff; }
165
+ #term { flex:1; min-height:0; padding:6px; }
166
+ .hint { color:#7a88a8; padding:16px; font-size:13px; }
167
+ `;
168
+ const body = `
169
+ <header>
170
+ <span class="agent">${htmlEscape(opts.agentId)}</span>
171
+ <select id="sess" title="Session" style="display:none"></select>
172
+ <button id="toggle" title="Toggle input">🔒 Read-only</button>
173
+ <span class="status" id="status">connecting…</span>
174
+ </header>
175
+ <div id="term"></div>
176
+ <link rel="stylesheet" href="${XTERM_CSS}">
177
+ <script src="${XTERM_JS}"></script>
178
+ <script>
179
+ (function(){
180
+ var CFG = ${cfg};
181
+ function api(p){ return CFG.basePath + '/cli/' + CFG.pairingId + p; }
182
+ var statusEl = document.getElementById('status');
183
+ var sel = document.getElementById('sess');
184
+ var toggleBtn = document.getElementById('toggle');
185
+ function setStatus(t){ statusEl.textContent = t; }
186
+
187
+ var term = new Terminal({
188
+ cols: 200, rows: 50, scrollback: 0, disableStdin: true, convertEol: false,
189
+ fontSize: 12, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
190
+ theme: { background: '#000000' }
191
+ });
192
+ term.open(document.getElementById('term'));
193
+
194
+ var ws = null, inputMode = false, onDataDisposable = null, currentSession = null, reconnectT = null;
195
+
196
+ function setInputMode(on){
197
+ inputMode = on;
198
+ term.options.disableStdin = !on;
199
+ toggleBtn.className = on ? 'on' : '';
200
+ toggleBtn.textContent = on ? '⌨️ Input ON' : '🔒 Read-only';
201
+ if (onDataDisposable) { onDataDisposable.dispose(); onDataDisposable = null; }
202
+ if (on) { onDataDisposable = term.onData(function(d){ if (ws && ws.readyState===1) ws.send(d); }); }
203
+ }
204
+ toggleBtn.addEventListener('click', function(){ setInputMode(!inputMode); });
205
+
206
+ function wsUrl(ticket){
207
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
208
+ return proto + '//' + location.host + CFG.basePath +
209
+ '/api/v1/agents/' + encodeURIComponent(CFG.agentId) + '/pty-stream?ticket=' + encodeURIComponent(ticket);
210
+ }
211
+
212
+ function connect(sessionId){
213
+ currentSession = sessionId;
214
+ if (ws) { try { ws.close(); } catch(e){} ws = null; }
215
+ setStatus('authorizing…');
216
+ fetch(api('/pty-ticket'), {
217
+ method:'POST', headers:{'Content-Type':'application/json'},
218
+ credentials:'same-origin', body: JSON.stringify({ sessionId: sessionId })
219
+ }).then(function(r){
220
+ if (r.status===401){ setStatus('session expired — send /cli again'); throw new Error('unauth'); }
221
+ return r.json();
222
+ }).then(function(d){
223
+ if (!d.ticket) throw new Error('no ticket');
224
+ ws = new WebSocket(wsUrl(d.ticket));
225
+ ws.binaryType = 'arraybuffer';
226
+ var dec = new TextDecoder();
227
+ ws.onopen = function(){ setStatus('live'); };
228
+ ws.onmessage = function(ev){
229
+ var data = typeof ev.data === 'string' ? ev.data : dec.decode(ev.data, { stream:true });
230
+ term.write(data);
231
+ };
232
+ ws.onclose = function(ev){
233
+ setStatus('disconnected');
234
+ if (ev.code === 4404) { setStatus('session ended'); return; }
235
+ if (sessionId === currentSession) { clearTimeout(reconnectT); reconnectT = setTimeout(function(){ connect(sessionId); }, 2000); }
236
+ };
237
+ ws.onerror = function(){ try { ws.close(); } catch(e){} };
238
+ }).catch(function(){ /* status already set */ });
239
+ }
240
+
241
+ function loadSessions(){
242
+ setStatus('finding session…');
243
+ fetch(api('/sessions'), { credentials:'same-origin' })
244
+ .then(function(r){ if(r.status===401){ setStatus('session expired'); throw new Error('unauth'); } return r.json(); })
245
+ .then(function(d){
246
+ var list = (d && d.sessions) || [];
247
+ if (!list.length) {
248
+ setStatus('');
249
+ document.getElementById('term').innerHTML = '<div class="hint">No interactive session to view. The agent must be running with headless=false to expose a live terminal.</div>';
250
+ return;
251
+ }
252
+ if (list.length > 1) {
253
+ sel.style.display = '';
254
+ sel.innerHTML = '';
255
+ list.forEach(function(s){
256
+ var o = document.createElement('option');
257
+ o.value = s.sessionId;
258
+ o.textContent = (s.source||'session') + ' · ' + s.sessionId.slice(0,8);
259
+ sel.appendChild(o);
260
+ });
261
+ sel.addEventListener('change', function(){ connect(sel.value); });
262
+ }
263
+ setInputMode(false);
264
+ connect(list[0].sessionId);
265
+ })
266
+ .catch(function(){ /* status set */ });
267
+ }
268
+
269
+ loadSessions();
270
+ })();
271
+ </script>`;
272
+ return page('Terminal viewer', style, body);
273
+ }
274
+ //# sourceMappingURL=cli-viewer-ui.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-viewer-ui.js","sourceRoot":"","sources":["../../src/ui/cli-viewer-ui.ts"],"names":[],"mappings":"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"}
@@ -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.9",
3
+ "version": "1.5.0",
4
4
  "description": "Multi-agent gateway for Claude",
5
5
  "repository": {
6
6
  "type": "git",