@0xmaxma/claude-gateway 1.3.4 → 1.3.5

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 (46) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/runner.d.ts +20 -0
  3. package/dist/agent/runner.d.ts.map +1 -1
  4. package/dist/agent/runner.js +94 -1
  5. package/dist/agent/runner.js.map +1 -1
  6. package/dist/api/gateway-router.d.ts +10 -0
  7. package/dist/api/gateway-router.d.ts.map +1 -1
  8. package/dist/api/gateway-router.js +261 -7
  9. package/dist/api/gateway-router.js.map +1 -1
  10. package/dist/session/process.d.ts +8 -0
  11. package/dist/session/process.d.ts.map +1 -1
  12. package/dist/session/process.js +28 -0
  13. package/dist/session/process.js.map +1 -1
  14. package/dist/shell/claude-pty-shell.js +211 -4
  15. package/dist/shell/claude-pty-shell.js.map +1 -1
  16. package/dist/shell/emitter.d.ts +13 -0
  17. package/dist/shell/emitter.d.ts.map +1 -1
  18. package/dist/shell/emitter.js +16 -0
  19. package/dist/shell/emitter.js.map +1 -1
  20. package/dist/shell/menu-cancel.d.ts +50 -0
  21. package/dist/shell/menu-cancel.d.ts.map +1 -0
  22. package/dist/shell/menu-cancel.js +62 -0
  23. package/dist/shell/menu-cancel.js.map +1 -0
  24. package/dist/shell/pty-serialize.d.ts +23 -0
  25. package/dist/shell/pty-serialize.d.ts.map +1 -0
  26. package/dist/shell/pty-serialize.js +131 -0
  27. package/dist/shell/pty-serialize.js.map +1 -0
  28. package/dist/shell/pty-stream-registry.d.ts +59 -0
  29. package/dist/shell/pty-stream-registry.d.ts.map +1 -0
  30. package/dist/shell/pty-stream-registry.js +279 -0
  31. package/dist/shell/pty-stream-registry.js.map +1 -0
  32. package/dist/shell/screen.d.ts +43 -0
  33. package/dist/shell/screen.d.ts.map +1 -1
  34. package/dist/shell/screen.js +91 -1
  35. package/dist/shell/screen.js.map +1 -1
  36. package/dist/ui/web-ui.d.ts +2 -2
  37. package/dist/ui/web-ui.d.ts.map +1 -1
  38. package/dist/ui/web-ui.js +615 -114
  39. package/dist/ui/web-ui.js.map +1 -1
  40. package/mcp/tools/discord/module.ts +88 -1
  41. package/mcp/tools/discord/outbound.ts +36 -0
  42. package/mcp/tools/discord/types.ts +2 -0
  43. package/mcp/tools/telegram/menu-parser.ts +37 -0
  44. package/mcp/tools/telegram/receiver-server.ts +83 -0
  45. package/mcp/tools/telegram/typing.ts +7 -1
  46. package/package.json +4 -2
package/dist/ui/web-ui.js CHANGED
@@ -3,15 +3,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.generateDashboardHtml = generateDashboardHtml;
4
4
  /**
5
5
  * Generates a self-contained HTML dashboard page for the gateway status UI.
6
- * No external dependencies all CSS and JS is inline.
6
+ * No external dependencies except xterm.js CDN for PTY viewer.
7
7
  */
8
- function generateDashboardHtml() {
8
+ function generateDashboardHtml(dashToken = '') {
9
+ const safeToken = dashToken.replace(/&/g, '&').replace(/"/g, '"');
9
10
  return `<!DOCTYPE html>
10
11
  <html lang="en">
11
12
  <head>
12
13
  <meta charset="UTF-8">
13
14
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
14
- <title>Claude Gateway Status</title>
15
+ <meta name="dash-token" content="${safeToken}">
16
+ <title>Claude Gateway</title>
17
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.min.css"
18
+ integrity="sha384-eDYu/eBZQNhtqTaA7Wl3XighXKxm/9VYF+Chh3hQS+UUlKQIJ14hK2imKu4n99aR" crossorigin="anonymous"/>
15
19
  <style>
16
20
  * { box-sizing: border-box; margin: 0; padding: 0; }
17
21
  body {
@@ -20,8 +24,17 @@ function generateDashboardHtml() {
20
24
  color: #e2e8f0;
21
25
  padding: 24px;
22
26
  }
23
- h1 { color: #63b3ed; font-size: 1.5rem; margin-bottom: 8px; }
24
- .meta { color: #718096; font-size: 0.85rem; margin-bottom: 24px; }
27
+ h1 { font-size: 1.5rem; margin-bottom: 8px; }
28
+ .rainbow {
29
+ background: linear-gradient(90deg, #ff0080, #ff8c00, #ffe600, #00d26a, #00b4ff, #a855f7, #ff0080);
30
+ background-size: 200% auto;
31
+ -webkit-background-clip: text;
32
+ -webkit-text-fill-color: transparent;
33
+ background-clip: text;
34
+ animation: rainbow-shift 3s linear infinite;
35
+ }
36
+ @keyframes rainbow-shift { to { background-position: 200% center; } }
37
+ .meta { color: #718096; font-size: 0.85rem; margin-bottom: 16px; }
25
38
  .meta span { color: #a0aec0; }
26
39
  h2 { color: #90cdf4; font-size: 1.1rem; margin: 20px 0 10px; }
27
40
  table {
@@ -45,7 +58,8 @@ function generateDashboardHtml() {
45
58
  padding: 8px 12px;
46
59
  border-bottom: 1px solid #1a202c;
47
60
  }
48
- tr:hover td { background: #1a202c; }
61
+ tr.session-row td { background: #0f1117; color: #cbd5e0; font-size: 0.82rem; }
62
+ tr.session-row:hover td { background: #1a202c; }
49
63
  .badge {
50
64
  display: inline-block;
51
65
  padding: 2px 8px;
@@ -56,72 +70,216 @@ function generateDashboardHtml() {
56
70
  .badge-green { background: #22543d; color: #68d391; }
57
71
  .badge-red { background: #742a2a; color: #fc8181; }
58
72
  .badge-gray { background: #2d3748; color: #a0aec0; }
73
+ .badge-blue { background: #1a365d; color: #63b3ed; }
74
+ .badge-purple { background: #44337a; color: #b794f4; }
75
+ /* Per-model badge colors — each model family gets a distinct hue. */
76
+ .badge-opus { background: #5a3a1a; color: #f6ad55; }
77
+ .badge-sonnet { background: #1a4a52; color: #4fd1c5; }
78
+ .badge-haiku { background: #22543d; color: #68d391; }
79
+ .badge-fable { background: #553052; color: #f687b3; }
80
+ .badge-model { background: #2d3748; color: #cbd5e0; }
59
81
  .ts { color: #718096; font-size: 0.8rem; }
60
- #refresh-indicator {
61
- float: right;
82
+ #refresh-indicator { float: right; font-size: 0.75rem; color: #4a5568; }
83
+ .error { color: #fc8181; font-size: 0.85rem; margin-top: 8px; }
84
+ .btn-stream {
85
+ background: #44337a;
86
+ color: #d6bcfa;
87
+ border: 1px solid #6b46c1;
88
+ border-radius: 4px;
89
+ padding: 2px 8px;
62
90
  font-size: 0.75rem;
63
- color: #4a5568;
91
+ cursor: pointer;
92
+ }
93
+ .btn-stream:hover { background: #6b46c1; color: #faf5ff; }
94
+ .pty-viewer {
95
+ display: none;
96
+ margin-top: 24px;
97
+ border: 1px solid #2d3748;
98
+ border-radius: 6px;
99
+ overflow-x: hidden;
100
+ }
101
+ .pty-viewer-header {
102
+ background: #1a202c;
103
+ padding: 8px 12px;
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: space-between;
107
+ font-size: 0.85rem;
108
+ color: #a0aec0;
109
+ }
110
+ .pty-viewer-header .agent-label { color: #63b3ed; font-weight: 600; }
111
+ .pty-viewer-header .session-label { color: #718096; font-family: monospace; font-size: 0.78rem; }
112
+ .pty-close, .pty-refresh {
113
+ background: none;
114
+ border: none;
115
+ color: #718096;
116
+ cursor: pointer;
117
+ font-size: 1rem;
118
+ padding: 0 4px;
119
+ }
120
+ .pty-close:hover { color: #fc8181; }
121
+ .pty-refresh:hover { color: #63b3ed; }
122
+ /* Fixed-size terminal viewport — the server PTY runs at 200x50, so the
123
+ viewer must NOT resize to the panel (that mismatch is what garbles the
124
+ output). We render at the native size and pan horizontally if the 200-col
125
+ width overflows the panel. The Claude TUI uses the alternate screen buffer
126
+ (\x1b[?1049h), which has no scrollback by design — so there is nothing to
127
+ scroll vertically and we hide the (non-functional) vertical scrollbar. */
128
+ #pty-terminal {
129
+ padding: 8px;
130
+ background: #0d1117;
131
+ overflow-x: auto;
132
+ overflow-y: hidden;
133
+ border-radius: 6px;
134
+ }
135
+ /* No scrollback in alt-screen mode → suppress xterm's vertical scrollbar. */
136
+ #pty-terminal .xterm-viewport { overflow-y: hidden !important; }
137
+ .proc-tree {
138
+ font-family: monospace;
139
+ font-size: 0.82rem;
140
+ background: #0d1117;
141
+ border: 1px solid #2d3748;
142
+ border-radius: 6px;
143
+ padding: 12px 16px;
144
+ white-space: pre-wrap;
145
+ word-break: break-word;
146
+ color: #a0aec0;
147
+ }
148
+ .proc-tree .proc-orchestrator { color: #63b3ed; }
149
+ .proc-tree .proc-pty { color: #68d391; }
150
+ .proc-tree .proc-claude { color: #f6e05e; }
151
+ .proc-tree .proc-mcp { color: #b794f4; }
152
+ .proc-tree .proc-receiver { color: #76e4f7; }
153
+ .proc-tree .proc-orphan { color: #fc8181; }
154
+ .proc-tree .proc-label { color: #718096; }
155
+ .proc-tree .proc-summary { color: #f6e05e; font-weight: 600; }
156
+ .session-id {
157
+ font-family: monospace;
158
+ font-size: 0.75rem;
159
+ color: #a0aec0;
160
+ word-break: break-all;
161
+ }
162
+ /* Agent status badges bar */
163
+ .agents-bar {
164
+ display: flex;
165
+ flex-wrap: wrap;
166
+ gap: 8px;
167
+ margin-bottom: 20px;
168
+ }
169
+ .agent-badge {
170
+ display: inline-flex;
171
+ align-items: center;
172
+ gap: 6px;
173
+ background: #1a202c;
174
+ border: 1px solid #2d3748;
175
+ border-radius: 6px;
176
+ padding: 4px 12px;
177
+ font-size: 0.8rem;
178
+ }
179
+ .agent-badge .agent-name { color: #90cdf4; font-weight: 600; }
180
+ .agent-badge .dot-green { color: #68d391; }
181
+ .agent-badge .dot-red { color: #fc8181; }
182
+
183
+ /* Top row: Processes 70% | Agents 30%. Collapses to a single column on
184
+ narrow screens (see media query below). */
185
+ .top-grid {
186
+ display: grid;
187
+ grid-template-columns: 7fr 3fr;
188
+ gap: 24px;
189
+ align-items: start;
190
+ }
191
+ /* The Sessions table has 10 columns — too wide for phones. Wrap it so it
192
+ scrolls horizontally instead of breaking the layout. */
193
+ .table-wrap { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
194
+ .table-wrap table { min-width: 820px; }
195
+
196
+ /* ── Responsive breakpoints ──────────────────────────────────────────── */
197
+ @media (max-width: 900px) {
198
+ .top-grid { grid-template-columns: 1fr; gap: 16px; }
199
+ }
200
+ @media (max-width: 640px) {
201
+ body { padding: 14px; }
202
+ h1 { font-size: 1.2rem; }
203
+ h2 { font-size: 1rem; }
204
+ .meta { font-size: 0.78rem; }
205
+ #refresh-indicator { float: none; display: block; margin-top: 4px; }
206
+ .proc-tree { font-size: 0.72rem; padding: 10px 12px; }
207
+ /* On phones allow horizontal pan only — no vertical clipping. */
64
208
  }
65
- .error { color: #fc8181; font-size: 0.85rem; margin-top: 8px; }
66
209
  </style>
67
210
  </head>
68
211
  <body>
69
- <h1>Claude Gateway <span id="refresh-indicator">refreshing...</span></h1>
212
+ <h1><span class="rainbow">Claude Gateway</span> <span id="gateway-version" style="font-size:0.75rem;color:#718096;"></span> <span id="refresh-indicator">refreshing...</span></h1>
70
213
  <div class="meta">
71
- Uptime: <span id="uptime">—</span> &nbsp;|&nbsp;
72
- Started: <span id="started-at">—</span> &nbsp;|&nbsp;
73
- Last updated: <span id="last-updated">—</span>
214
+ Uptime: <span id="uptime">&mdash;</span> &nbsp;|&nbsp;
215
+ Started: <span id="started-at">&mdash;</span> &nbsp;|&nbsp;
216
+ Last updated: <span id="last-updated">&mdash;</span>
74
217
  </div>
75
218
 
76
- <h2>Agents</h2>
77
- <table id="agents-table">
78
- <thead>
79
- <tr>
80
- <th>ID</th>
81
- <th>Status</th>
82
- <th>Received</th>
83
- <th>Sent</th>
84
- <th>Last Activity</th>
85
- </tr>
86
- </thead>
87
- <tbody id="agents-tbody">
88
- <tr><td colspan="5" class="ts">Loading...</td></tr>
89
- </tbody>
90
- </table>
91
-
92
- <h2>Heartbeat Tasks</h2>
93
- <table id="heartbeat-table">
94
- <thead>
95
- <tr>
96
- <th>Agent</th>
97
- <th>Task</th>
98
- <th>Last Run</th>
99
- <th>Result</th>
100
- </tr>
101
- </thead>
102
- <tbody id="heartbeat-tbody">
103
- <tr><td colspan="4" class="ts">Loading...</td></tr>
104
- </tbody>
105
- </table>
106
-
107
- <h2>Recent Sessions (last 5 per agent)</h2>
108
- <table id="sessions-table">
109
- <thead>
110
- <tr>
111
- <th>Agent</th>
112
- <th>Chat ID</th>
113
- <th>Messages</th>
114
- <th>Last Activity</th>
115
- </tr>
116
- </thead>
117
- <tbody id="sessions-tbody">
118
- <tr><td colspan="4" class="ts">Loading...</td></tr>
119
- </tbody>
120
- </table>
219
+ <!-- Row: Processes 70% | Agent badges 30% (collapses on narrow screens) -->
220
+ <div class="top-grid">
221
+ <div>
222
+ <h2>Processes</h2>
223
+ <div class="proc-tree" id="proc-tree">Loading...</div>
224
+ </div>
225
+ <div>
226
+ <h2>Agents</h2>
227
+ <div class="agents-bar" id="agents-bar"></div>
228
+ </div>
229
+ </div>
230
+
231
+ <!-- PTY viewer — full width so the native 200-col terminal has room.
232
+ Placed above the Sessions table so the live mirror is the first thing
233
+ in view when streaming. -->
234
+ <div class="pty-viewer" id="pty-viewer">
235
+ <div class="pty-viewer-header">
236
+ <span>Shell Process Viewer &mdash; <span class="agent-label" id="pty-agent-label"></span><span class="session-label" id="pty-session-label"></span></span>
237
+ <span>
238
+ <button class="pty-refresh" id="pty-refresh-btn" title="Refresh (reconnect &amp; redraw)">&#x21ba;</button>
239
+ <button class="pty-close" id="pty-close-btn" title="Close">&#x2715;</button>
240
+ </span>
241
+ </div>
242
+ <div id="pty-terminal"></div>
243
+ </div>
244
+
245
+ <!-- Sessions — full width (session-centric, flat list) -->
246
+ <h2>Sessions</h2>
247
+ <div class="table-wrap">
248
+ <table id="sessions-table">
249
+ <thead>
250
+ <tr>
251
+ <th>Agent</th>
252
+ <th>Session ID</th>
253
+ <th>Chat ID</th>
254
+ <th>Source</th>
255
+ <th>Mode</th>
256
+ <th>Model</th>
257
+ <th>Tokens</th>
258
+ <th>Status</th>
259
+ <th>Uptime</th>
260
+ <th>Spawned</th>
261
+ <th>Shell</th>
262
+ </tr>
263
+ </thead>
264
+ <tbody id="sessions-tbody">
265
+ <tr><td colspan="11" class="ts">Loading...</td></tr>
266
+ </tbody>
267
+ </table>
268
+ </div>
121
269
 
122
270
  <div id="error-msg" class="error" style="display:none;"></div>
123
271
 
272
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/lib/xterm.min.js"
273
+ integrity="sha384-pELe6ZHtFxFcuYBq3gMkqvmnNIqUWnAYjBG5gThqQQCjWp8PJ/65MLK4lMIfEK1e" crossorigin="anonymous"></script>
124
274
  <script>
275
+ // Read short-lived dashboard token from meta tag (10 min, server-issued at page load).
276
+ // The raw API key is never embedded in HTML — only this scoped, expiring token is.
277
+ const DASHBOARD_API_KEY = document.querySelector('meta[name="dash-token"]') ? document.querySelector('meta[name="dash-token"]').getAttribute('content') : '';
278
+
279
+ // Must match the server PTY size (src/shell/screen.ts ScreenModel defaults).
280
+ const PTY_COLS = 200;
281
+ const PTY_ROWS = 50;
282
+
125
283
  function fmtUptime(seconds) {
126
284
  const h = Math.floor(seconds / 3600);
127
285
  const m = Math.floor((seconds % 3600) / 60);
@@ -131,86 +289,282 @@ function generateDashboardHtml() {
131
289
  return s + 's';
132
290
  }
133
291
 
292
+ // Spawned timestamp, formatted like "6/14/2026, 10:32:31 PM" (en-US, 12h).
134
293
  function fmtTs(ts) {
135
- if (!ts) return '<span class="ts">—</span>';
294
+ if (!ts) return '<span class="ts">&mdash;</span>';
136
295
  try {
137
296
  const d = new Date(ts);
138
- return '<span class="ts">' + d.toLocaleTimeString() + ' ' + d.toLocaleDateString() + '</span>';
297
+ return '<span class="ts">' + d.toLocaleString('en-US', {
298
+ year: 'numeric', month: 'numeric', day: 'numeric',
299
+ hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true
300
+ }) + '</span>';
139
301
  } catch(e) { return ts; }
140
302
  }
141
303
 
142
- function badge(running) {
143
- return running
144
- ? '<span class="badge badge-green">running</span>'
145
- : '<span class="badge badge-red">stopped</span>';
304
+ function basePath() {
305
+ const p = window.location.pathname;
306
+ if (p.endsWith('/dashboard')) return p.slice(0, -10);
307
+ if (p.endsWith('/dashboard/')) return p.slice(0, -11);
308
+ return p.endsWith('/') ? p.slice(0, -1) : p;
309
+ }
310
+
311
+ function apiUrl(path) {
312
+ return basePath() + path;
313
+ }
314
+
315
+ async function wsPtyUrl(agentId, sessionId) {
316
+ // Exchange the API key for a short-lived ticket so the key never appears in
317
+ // the WS URL (which would expose it in server logs and browser history).
318
+ // Streams are per-session, so the session id is always part of the request.
319
+ const base = basePath() + '/api/v1/agents/' + encodeURIComponent(agentId) + '/pty-stream';
320
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
321
+ if (!DASHBOARD_API_KEY) {
322
+ return proto + '//' + window.location.host + base + '?session=' + encodeURIComponent(sessionId);
323
+ }
324
+ const r = await fetch(apiUrl('/api/v1/pty-stream-ticket'), {
325
+ method: 'POST',
326
+ headers: { 'Content-Type': 'application/json', 'X-Dash-Token': DASHBOARD_API_KEY },
327
+ body: JSON.stringify({ agentId, sessionId }),
328
+ });
329
+ const { ticket } = await r.json();
330
+ return proto + '//' + window.location.host + base + '?ticket=' + ticket;
331
+ }
332
+
333
+ // ── PTY Viewer ───────────────────────────────────────────────────────────
334
+ let term = null;
335
+ let ptyWs = null;
336
+ let currentPtyAgent = null;
337
+ let currentPtySession = null;
338
+ // Streaming UTF-8 decoder. The PTY stream carries raw UTF-8 bytes (box-drawing
339
+ // chars, spinner braille, emoji). Decoding them as latin1 mangles every
340
+ // multi-byte char into noise — decode as UTF-8 with {stream:true} so sequences
341
+ // split across WebSocket frames are reassembled instead of corrupted.
342
+ let utf8Decoder = null;
343
+
344
+ // The agent's TUI enables mouse tracking (DECSET 1000/1002/1003/1006...).
345
+ // While those modes are active, xterm.js forwards wheel/click events to the
346
+ // app as mouse escapes, which can leave stray report bytes in the view. This
347
+ // is a view-only mirror (disableStdin) so mouse reporting is useless here —
348
+ // strip the set/reset sequences to keep the mirror clean.
349
+ function stripMouseModes(s) {
350
+ return s.replace(/\\x1b\\[\\?(1000|1001|1002|1003|1004|1005|1006|1015|1016)[hl]/g, '');
351
+ }
352
+
353
+ async function openPtyViewer(agentId, sessionId) {
354
+ // Compare the SESSION, not the agent: one agent can have several sessions,
355
+ // each its own stream. Guarding on agent alone made switching between two
356
+ // sessions of the same agent a no-op (the viewer never reconnected).
357
+ if (currentPtySession === sessionId && ptyWs && ptyWs.readyState === WebSocket.OPEN) return;
358
+ closePtyViewer();
359
+
360
+ currentPtyAgent = agentId;
361
+ currentPtySession = sessionId;
362
+ document.getElementById('pty-agent-label').textContent = agentId;
363
+ // Append the session id after the agent name, e.g. "claude-founder · 3c01897c…".
364
+ document.getElementById('pty-session-label').textContent = sessionId ? ' \\u00b7 ' + sessionId : '';
365
+ document.getElementById('pty-viewer').style.display = 'block';
366
+
367
+ if (!term) {
368
+ term = new Terminal({
369
+ theme: { background: '#0d1117', foreground: '#e2e8f0', cursor: '#63b3ed' },
370
+ fontSize: 11,
371
+ lineHeight: 1.0,
372
+ letterSpacing: 0,
373
+ fontFamily: '"JetBrains Mono", "Cascadia Code", Menlo, Monaco, Consolas, "Courier New", monospace',
374
+ fontWeight: 400,
375
+ fontWeightBold: 600,
376
+ // Fixed dimensions matching the server PTY — do NOT auto-fit, the
377
+ // size mismatch is what makes the output unreadable.
378
+ cols: PTY_COLS,
379
+ rows: PTY_ROWS,
380
+ // Alt-screen TUI has no scrollback (the live mirror only shows the
381
+ // current screen), so don't retain any — this also removes the
382
+ // non-functional vertical scrollbar.
383
+ scrollback: 0,
384
+ // View-only mirror of the agent's TUI.
385
+ disableStdin: true,
386
+ cursorBlink: false,
387
+ convertEol: false,
388
+ });
389
+ term.open(document.getElementById('pty-terminal'));
390
+ // After open(), xterm has measured cell height. Pin the container height
391
+ // so all PTY_ROWS are always visible — prevents the alt-screen bottom rows
392
+ // (cost bar, status line) from being clipped by parent overflow or viewport.
393
+ requestAnimationFrame(function() {
394
+ const screen = document.querySelector('#pty-terminal .xterm-screen');
395
+ if (screen) {
396
+ const h = screen.offsetHeight;
397
+ if (h > 0) document.getElementById('pty-terminal').style.minHeight = h + 'px';
398
+ }
399
+ });
400
+ } else {
401
+ term.reset();
402
+ }
403
+
404
+ // Fresh decoder per session so a leftover partial byte from a previous
405
+ // viewing can't corrupt the first character of this stream.
406
+ utf8Decoder = new TextDecoder('utf-8');
407
+
408
+ const url = await wsPtyUrl(agentId, sessionId);
409
+ ptyWs = new WebSocket(url);
410
+ ptyWs.binaryType = 'arraybuffer';
411
+
412
+ ptyWs.onmessage = function(ev) {
413
+ const data = ev.data instanceof ArrayBuffer
414
+ ? utf8Decoder.decode(ev.data, { stream: true })
415
+ : ev.data;
416
+ term.write(stripMouseModes(data));
417
+ };
418
+ ptyWs.onclose = function(ev) {
419
+ if (term) term.writeln('\\r\\n\\x1b[33m[disconnected: ' + (ev.reason || 'closed') + ']\\x1b[0m');
420
+ };
421
+ ptyWs.onerror = function() { if (term) term.writeln('\\r\\n\\x1b[31m[connection error]\\x1b[0m'); };
422
+ }
423
+
424
+ function closePtyViewer() {
425
+ if (ptyWs) { ptyWs.close(); ptyWs = null; }
426
+ currentPtyAgent = null;
427
+ currentPtySession = null;
428
+ document.getElementById('pty-viewer').style.display = 'none';
146
429
  }
147
430
 
148
- function resultBadge(suppressed, rateLimited) {
149
- if (rateLimited) return '<span class="badge badge-gray">rate-limited</span>';
150
- if (suppressed) return '<span class="badge badge-gray">suppressed</span>';
151
- return '<span class="badge badge-green">sent</span>';
431
+ // Refresh: force a clean reconnect of the CURRENT session. The server replays
432
+ // a freshly-serialized screen frame on subscribe, so this redraws from a clean
433
+ // xterm state — a manual escape hatch if the live stream ever drifts.
434
+ async function refreshPtyViewer() {
435
+ const agentId = currentPtyAgent;
436
+ const sessionId = currentPtySession;
437
+ if (!agentId) return;
438
+ closePtyViewer();
439
+ await openPtyViewer(agentId, sessionId);
152
440
  }
153
441
 
442
+ document.getElementById('pty-close-btn').addEventListener('click', closePtyViewer);
443
+ document.getElementById('pty-refresh-btn').addEventListener('click', function() { void refreshPtyViewer(); });
444
+
445
+ // Event delegation for Live buttons (avoids inline onclick + HTML injection)
446
+ document.getElementById('sessions-tbody').addEventListener('click', function(e) {
447
+ const btn = e.target.closest('.btn-stream');
448
+ if (btn) void openPtyViewer(btn.getAttribute('data-agent-id'), btn.getAttribute('data-session-id'));
449
+ });
450
+
451
+ function escHtml(s) {
452
+ return String(s)
453
+ .replace(/&/g, '&amp;')
454
+ .replace(/</g, '&lt;')
455
+ .replace(/>/g, '&gt;')
456
+ .replace(/"/g, '&quot;');
457
+ }
458
+
459
+ function modeBadge(mode) {
460
+ if (mode === 'pty-shell') return '<span class="badge badge-blue">wrap-shell</span>';
461
+ if (mode === 'headless') return '<span class="badge badge-purple">headless</span>';
462
+ return '<span class="ts">' + escHtml(mode || '?') + '</span>';
463
+ }
464
+
465
+ // Source badge with a per-channel color: telegram=blue, discord=purple,
466
+ // api=gray. Unknown sources fall back to gray.
467
+ function sourceBadge(source) {
468
+ const s = String(source || '?').toLowerCase();
469
+ if (s === 'telegram') return '<span class="badge badge-blue">telegram</span>';
470
+ if (s === 'discord') return '<span class="badge badge-purple">discord</span>';
471
+ if (s === 'api') return '<span class="badge badge-gray">api</span>';
472
+ return '<span class="badge badge-gray">' + escHtml(source || '?') + '</span>';
473
+ }
474
+
475
+ // Prettify a model id for display: drop the "claude-" prefix and any
476
+ // trailing date stamp, e.g. claude-haiku-4-5-20251001 -> haiku-4-5.
477
+ // Each model family gets a distinct badge color; full id kept in the tooltip.
478
+ function fmtModel(m) {
479
+ if (!m) return '<span class="ts">&mdash;</span>';
480
+ const id = String(m);
481
+ const label = id.replace(/^claude-/, '').replace(/-\\d{8}$/, '');
482
+ let cls = 'badge-model';
483
+ if (/opus/i.test(id)) cls = 'badge-opus';
484
+ else if (/sonnet/i.test(id)) cls = 'badge-sonnet';
485
+ else if (/haiku/i.test(id)) cls = 'badge-haiku';
486
+ else if (/fable/i.test(id)) cls = 'badge-fable';
487
+ return '<span class="badge ' + cls + '" title="' + escHtml(id) + '">' + escHtml(label) + '</span>';
488
+ }
489
+
490
+ // Format a context-window token count compactly: 1234 -> "1.2k", 45000 -> "45k".
491
+ // Full value is kept in the tooltip. 0/unknown renders as a dash.
492
+ function fmtTokens(n) {
493
+ const v = Number(n) || 0;
494
+ if (v <= 0) return '<span class="ts">&mdash;</span>';
495
+ const label = v >= 1000 ? (v / 1000).toFixed(v >= 10000 ? 0 : 1) + 'k' : String(v);
496
+ return '<span title="' + v.toLocaleString() + ' tokens">' + label + '</span>';
497
+ }
498
+
499
+ // ── Status Refresh ────────────────────────────────────────────────────────
154
500
  async function refresh() {
155
501
  document.getElementById('refresh-indicator').textContent = 'refreshing...';
156
502
  try {
157
- const res = await fetch('/status');
503
+ const res = await fetch(apiUrl('/status'));
158
504
  if (!res.ok) throw new Error('HTTP ' + res.status);
159
505
  const data = await res.json();
160
506
 
161
507
  document.getElementById('uptime').textContent = fmtUptime(data.uptime || 0);
162
508
  document.getElementById('started-at').textContent = data.startedAt
163
- ? new Date(data.startedAt).toLocaleString() : '';
509
+ ? new Date(data.startedAt).toLocaleString() : '\\u2014';
164
510
  document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
511
+ if (data.version) document.getElementById('gateway-version').textContent = 'v' + data.version;
165
512
  document.getElementById('error-msg').style.display = 'none';
166
513
 
167
- // Agents table
168
- const agentRows = (data.agents || []).map(function(a) {
169
- return '<tr>' +
170
- '<td>' + a.id + '</td>' +
171
- '<td>' + badge(a.isRunning) + '</td>' +
172
- '<td>' + (a.messagesReceived || 0) + '</td>' +
173
- '<td>' + (a.messagesSent || 0) + '</td>' +
174
- '<td>' + fmtTs(a.lastActivityAt) + '</td>' +
175
- '</tr>';
176
- });
177
- document.getElementById('agents-tbody').innerHTML =
178
- agentRows.length ? agentRows.join('') : '<tr><td colspan="5" class="ts">No agents</td></tr>';
179
-
180
- // Heartbeat table
181
- const hbRows = [];
182
- (data.agents || []).forEach(function(a) {
183
- const lastResults = (a.heartbeat && a.heartbeat.lastResults) || [];
184
- lastResults.forEach(function(r) {
185
- if (!r) return;
186
- hbRows.push('<tr>' +
187
- '<td>' + a.id + '</td>' +
188
- '<td>' + r.taskName + '</td>' +
189
- '<td>' + fmtTs(r.ts) + '</td>' +
190
- '<td>' + resultBadge(r.suppressed, r.rateLimited) + '</td>' +
191
- '</tr>');
192
- });
514
+ const agents = data.agents || [];
515
+
516
+ // Agent badges bar.
517
+ // Green = available. An agent with a channel receiver (telegram/discord)
518
+ // is green only while its receiver is running; API-only agents have no
519
+ // receiver, so they are always green as long as the gateway loaded them.
520
+ // Red = a channel agent whose receiver is down (genuinely stopped).
521
+ const badges = agents.map(function(a) {
522
+ const ok = a.hasChannel ? a.isRunning : true;
523
+ const dot = ok ? '<span class="dot-green">&#x25CF;</span>' : '<span class="dot-red">&#x25CF;</span>';
524
+ return '<span class="agent-badge">' + dot + ' <span class="agent-name">' + escHtml(a.id) + '</span></span>';
193
525
  });
194
- document.getElementById('heartbeat-tbody').innerHTML =
195
- hbRows.length ? hbRows.join('') : '<tr><td colspan="4" class="ts">No heartbeat data yet</td></tr>';
196
-
197
- // Sessions table
198
- const sessRows = [];
199
- (data.agents || []).forEach(function(a) {
200
- const sessions = (a.sessions || []).slice(0, 5);
201
- sessions.forEach(function(s) {
202
- sessRows.push('<tr>' +
203
- '<td>' + a.id + '</td>' +
204
- '<td>' + s.chatId + '</td>' +
205
- '<td>' + (s.messageCount || 0) + '</td>' +
206
- '<td>' + fmtTs(s.lastActivity) + '</td>' +
207
- '</tr>');
526
+ document.getElementById('agents-bar').innerHTML = badges.join('') || '<span class="ts">No agents</span>';
527
+
528
+ // Sessions table — flat, session-centric. One row per real session across
529
+ // all agents; agents with no session do not produce a row.
530
+ const rows = [];
531
+ agents.forEach(function(a) {
532
+ (a.sessions || []).forEach(function(s) {
533
+ const statusBadge = s.isRunning
534
+ ? '<span class="badge badge-green">running</span>'
535
+ : '<span class="badge badge-gray">stopped</span>';
536
+ const uptime = s.isRunning ? fmtUptime(s.uptimeSec || 0) : '<span class="ts">&mdash;</span>';
537
+ const sessId = s.sessionId
538
+ ? '<span class="session-id">' + escHtml(s.sessionId) + '</span>'
539
+ : '<span class="ts">&mdash;</span>';
540
+ const chatCell = s.chatId
541
+ ? '<span class="session-id">' + escHtml(String(s.chatId)) + '</span>'
542
+ : '<span class="ts">&mdash;</span>';
543
+ const liveBtn = (s.hasPtyStream && s.isRunning && s.mode === 'pty-shell')
544
+ ? '<button class="btn-stream" data-agent-id="' + escHtml(a.id) + '" data-session-id="' + escHtml(s.sessionId || '') + '">💻 View</button>'
545
+ : '<span class="ts">&mdash;</span>';
546
+ rows.push(
547
+ '<tr class="session-row">' +
548
+ '<td><span style="color:#90cdf4;font-weight:600;">' + escHtml(a.id) + '</span></td>' +
549
+ '<td>' + sessId + '</td>' +
550
+ '<td>' + chatCell + '</td>' +
551
+ '<td>' + sourceBadge(s.source) + '</td>' +
552
+ '<td>' + modeBadge(s.mode) + '</td>' +
553
+ '<td>' + fmtModel(s.model) + '</td>' +
554
+ '<td>' + fmtTokens(s.tokens) + '</td>' +
555
+ '<td>' + statusBadge + '</td>' +
556
+ '<td>' + uptime + '</td>' +
557
+ '<td>' + fmtTs(s.spawnedAt ? new Date(s.spawnedAt).toISOString() : null) + '</td>' +
558
+ '<td>' + liveBtn + '</td>' +
559
+ '</tr>'
560
+ );
208
561
  });
209
562
  });
563
+
210
564
  document.getElementById('sessions-tbody').innerHTML =
211
- sessRows.length ? sessRows.join('') : '<tr><td colspan="4" class="ts">No sessions yet</td></tr>';
565
+ rows.length ? rows.join('') : '<tr><td colspan="11" class="ts">No active sessions</td></tr>';
212
566
 
213
- document.getElementById('refresh-indicator').textContent = 'auto-refresh 5s';
567
+ document.getElementById('refresh-indicator').textContent = 'auto-refresh 3s';
214
568
  } catch(e) {
215
569
  document.getElementById('error-msg').textContent = 'Error fetching status: ' + e.message;
216
570
  document.getElementById('error-msg').style.display = 'block';
@@ -218,8 +572,155 @@ function generateDashboardHtml() {
218
572
  }
219
573
  }
220
574
 
575
+ // ── Process Tree ─────────────────────────────────────────────────────────
576
+ async function refreshProcesses() {
577
+ try {
578
+ const res = await fetch(apiUrl('/processes'));
579
+ if (!res.ok) return;
580
+ const data = await res.json();
581
+ renderProcessTree(data.processes || []);
582
+ } catch(e) {
583
+ document.getElementById('proc-tree').textContent = 'Error: ' + e.message;
584
+ }
585
+ }
586
+
587
+ function renderProcessTree(procs) {
588
+ if (!procs.length) {
589
+ document.getElementById('proc-tree').textContent = '— no gateway processes found —';
590
+ return;
591
+ }
592
+
593
+ const pidMap = {};
594
+ procs.forEach(function(p) { pidMap[p.pid] = p; });
595
+
596
+ // Aggregate resource usage across the whole gateway process tree.
597
+ // %CPU is ps's lifetime average per process (summed → total load share);
598
+ // RSS is summed and may slightly over-count shared pages, but is a good
599
+ // proxy for "memory used by the gateway".
600
+ let totalCpu = 0, totalRssKb = 0;
601
+ procs.forEach(function(p) {
602
+ totalCpu += Number(p.cpu) || 0;
603
+ totalRssKb += Number(p.rssKb) || 0;
604
+ });
605
+ const totalMemMb = totalRssKb / 1024;
606
+ const memStr = totalMemMb >= 1024
607
+ ? (totalMemMb / 1024).toFixed(2) + ' GB'
608
+ : totalMemMb.toFixed(0) + ' MB';
609
+
610
+ function cat(p) {
611
+ const a = p.args;
612
+ if (a.includes('node') && a.includes('dist/index')) return 'orchestrator';
613
+ if (a.includes('claude-pty-shell')) return 'pty';
614
+ if (a.includes('bun') && a.includes('mcp/server')) return 'mcp';
615
+ if (a.includes('bun') && a.includes('telegram') && a.includes('receiver')) return 'telegram';
616
+ if (a.includes('bun') && a.includes('discord') && a.includes('receiver')) return 'discord';
617
+ if (a.includes('--mcp-config') && (a.includes('--session-id') || a.includes('--print'))) {
618
+ if (a.includes('--session-id')) {
619
+ const parent = pidMap[p.ppid];
620
+ return (parent && cat(parent) === 'pty') ? 'claude-pty' : 'claude-headless';
621
+ }
622
+ return 'claude-headless';
623
+ }
624
+ return 'other';
625
+ }
626
+
627
+ // Show full command lines (no truncation) — text wraps inside the box.
628
+ function full(args) {
629
+ return escHtml(args);
630
+ }
631
+
632
+ function sessionId(args) {
633
+ const m = args.match(/--session-id\\s+(\\S+)/);
634
+ return m ? escHtml(m[1]) : '?';
635
+ }
636
+
637
+ function agentName(args) {
638
+ const m = args.match(/agents\\/([^/]+)\\/workspace/);
639
+ return m ? m[1] : '?';
640
+ }
641
+
642
+ const lines = [];
643
+ // First line: total resource usage across the whole gateway tree.
644
+ lines.push(
645
+ '<span class="proc-summary">' +
646
+ '\\u2211 ' + procs.length + ' procs' +
647
+ ' \\u00b7 CPU ' + totalCpu.toFixed(1) + '%' +
648
+ ' \\u00b7 MEM ' + memStr +
649
+ '</span>'
650
+ );
651
+ lines.push('');
652
+ const orchestrator = procs.find(function(p) { return cat(p) === 'orchestrator'; });
653
+ const ptys = procs.filter(function(p) { return cat(p) === 'pty'; });
654
+ const headless = procs.filter(function(p) { return cat(p) === 'claude-headless'; });
655
+ const telegramReceivers = procs.filter(function(p) { return cat(p) === 'telegram'; });
656
+ const discordReceivers = procs.filter(function(p) { return cat(p) === 'discord'; });
657
+ const mcpServers = procs.filter(function(p) { return cat(p) === 'mcp'; });
658
+
659
+ const gatewayPids = new Set(procs.map(function(p) { return p.pid; }));
660
+ const orphans = procs.filter(function(p) {
661
+ const c = cat(p);
662
+ return (c === 'claude-pty' || c === 'claude-headless' || c === 'pty' || c === 'mcp')
663
+ && !gatewayPids.has(p.ppid)
664
+ && p.pid !== (orchestrator && orchestrator.pid);
665
+ });
666
+
667
+ if (orchestrator) {
668
+ lines.push('<span class="proc-orchestrator">Orchestrator</span>');
669
+ lines.push(' PID ' + orchestrator.pid + ' <span class="proc-orchestrator">' + full(orchestrator.args) + '</span>');
670
+ lines.push('');
671
+ }
672
+
673
+ const sessionCount = ptys.length + headless.length;
674
+ lines.push('<span class="proc-label">Sessions (' + sessionCount + ')</span>');
675
+
676
+ ptys.forEach(function(pty) {
677
+ const agent = agentName(pty.args);
678
+ lines.push(' PID ' + pty.pid + ' <span class="proc-pty">wrap-shell</span> [' + agent + ']');
679
+ const claudeChild = procs.find(function(p) { return p.ppid === pty.pid && cat(p) === 'claude-pty'; });
680
+ if (claudeChild) {
681
+ lines.push(' \\u2514\\u2500 PID ' + claudeChild.pid + ' <span class="proc-claude">claude ' + sessionId(claudeChild.args) + '</span>');
682
+ const mcp = mcpServers.find(function(p) { return p.ppid === claudeChild.pid; });
683
+ if (mcp) {
684
+ lines.push(' \\u2514\\u2500 PID ' + mcp.pid + ' <span class="proc-mcp">mcp</span>');
685
+ }
686
+ }
687
+ });
688
+
689
+ headless.forEach(function(cl) {
690
+ const agent = agentName(cl.args);
691
+ lines.push(' PID ' + cl.pid + ' <span class="proc-claude">claude --print</span>' + (agent !== '?' ? ' [' + agent + ']' : ''));
692
+ const mcp = mcpServers.find(function(p) { return p.ppid === cl.pid; });
693
+ if (mcp) {
694
+ lines.push(' \\u2514\\u2500 PID ' + mcp.pid + ' <span class="proc-mcp">mcp</span>');
695
+ }
696
+ });
697
+
698
+ if (sessionCount === 0) lines.push(' <span class="ts">\\u2014 none \\u2014</span>');
699
+ lines.push('');
700
+
701
+ lines.push('<span class="proc-label">Receivers</span>');
702
+ if (telegramReceivers.length) lines.push(' Telegram \\u00d7' + telegramReceivers.length);
703
+ if (discordReceivers.length) lines.push(' Discord \\u00d7' + discordReceivers.length);
704
+ if (!telegramReceivers.length && !discordReceivers.length) lines.push(' <span class="ts">\\u2014 none \\u2014</span>');
705
+ lines.push('');
706
+
707
+ lines.push('<span class="proc-label">Orphans</span>');
708
+ if (orphans.length) {
709
+ orphans.forEach(function(p) {
710
+ lines.push(' \\u26a0 PID ' + p.pid + ' <span class="proc-orphan">' + full(p.args) + '</span>');
711
+ });
712
+ } else {
713
+ lines.push(' <span class="ts">none \\u2705</span>');
714
+ }
715
+
716
+ document.getElementById('proc-tree').innerHTML = lines.join('\\n');
717
+ }
718
+
221
719
  refresh();
222
- setInterval(refresh, 5000);
720
+ refreshProcesses();
721
+ setInterval(refresh, 3000);
722
+ // Process tree (with CPU/mem) is heavier (spawns ps) — refresh a bit slower.
723
+ setInterval(refreshProcesses, 6000);
223
724
  </script>
224
725
  </body>
225
726
  </html>`;