@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
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>
217
+ </div>
218
+
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>
74
229
  </div>
75
230
 
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>
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,348 @@ 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
+ // Auto-reconnect state for the PTY viewer. The dashboard tab can lose its
339
+ // WebSocket to a gateway restart, an idle timeout, or a transient network
340
+ // blip; rather than make the user click View again, reconnect with backoff.
341
+ let ptyReconnectTimer = null;
342
+ let ptyReconnectAttempts = 0;
343
+ const PTY_RECONNECT_MAX_MS = 10000;
344
+ // Absolute cap so a session that ends without a clean 4404 (e.g. the gateway
345
+ // is gone for good) doesn't retry every 10s forever. After this many failed
346
+ // attempts in a row we give up and tell the user to click View again.
347
+ const PTY_RECONNECT_MAX_ATTEMPTS = 10;
348
+ // Streaming UTF-8 decoder. The PTY stream carries raw UTF-8 bytes (box-drawing
349
+ // chars, spinner braille, emoji). Decoding them as latin1 mangles every
350
+ // multi-byte char into noise — decode as UTF-8 with {stream:true} so sequences
351
+ // split across WebSocket frames are reassembled instead of corrupted.
352
+ let utf8Decoder = null;
353
+
354
+ // The agent's TUI enables mouse tracking (DECSET 1000/1002/1003/1006...).
355
+ // While those modes are active, xterm.js forwards wheel/click events to the
356
+ // app as mouse escapes, which can leave stray report bytes in the view. This
357
+ // is a view-only mirror (disableStdin) so mouse reporting is useless here —
358
+ // strip the set/reset sequences to keep the mirror clean.
359
+ function stripMouseModes(s) {
360
+ return s.replace(/\\x1b\\[\\?(1000|1001|1002|1003|1004|1005|1006|1015|1016)[hl]/g, '');
361
+ }
362
+
363
+ async function openPtyViewer(agentId, sessionId) {
364
+ // Compare the SESSION, not the agent: one agent can have several sessions,
365
+ // each its own stream. Guarding on agent alone made switching between two
366
+ // sessions of the same agent a no-op (the viewer never reconnected).
367
+ if (currentPtySession === sessionId && ptyWs && ptyWs.readyState === WebSocket.OPEN) return;
368
+ closePtyViewer();
369
+
370
+ currentPtyAgent = agentId;
371
+ currentPtySession = sessionId;
372
+ document.getElementById('pty-agent-label').textContent = agentId;
373
+ // Append the session id after the agent name, e.g. "claude-founder · 3c01897c…".
374
+ document.getElementById('pty-session-label').textContent = sessionId ? ' \\u00b7 ' + sessionId : '';
375
+ document.getElementById('pty-viewer').style.display = 'block';
376
+
377
+ if (!term) {
378
+ term = new Terminal({
379
+ theme: { background: '#0d1117', foreground: '#e2e8f0', cursor: '#63b3ed' },
380
+ fontSize: 11,
381
+ lineHeight: 1.0,
382
+ letterSpacing: 0,
383
+ fontFamily: '"JetBrains Mono", "Cascadia Code", Menlo, Monaco, Consolas, "Courier New", monospace',
384
+ fontWeight: 400,
385
+ fontWeightBold: 600,
386
+ // Fixed dimensions matching the server PTY — do NOT auto-fit, the
387
+ // size mismatch is what makes the output unreadable.
388
+ cols: PTY_COLS,
389
+ rows: PTY_ROWS,
390
+ // Alt-screen TUI has no scrollback (the live mirror only shows the
391
+ // current screen), so don't retain any — this also removes the
392
+ // non-functional vertical scrollbar.
393
+ scrollback: 0,
394
+ // View-only mirror of the agent's TUI.
395
+ disableStdin: true,
396
+ cursorBlink: false,
397
+ convertEol: false,
398
+ });
399
+ term.open(document.getElementById('pty-terminal'));
400
+ // After open(), xterm has measured cell height. Pin the container height
401
+ // so all PTY_ROWS are always visible — prevents the alt-screen bottom rows
402
+ // (cost bar, status line) from being clipped by parent overflow or viewport.
403
+ requestAnimationFrame(function() {
404
+ const screen = document.querySelector('#pty-terminal .xterm-screen');
405
+ if (screen) {
406
+ const h = screen.offsetHeight;
407
+ if (h > 0) document.getElementById('pty-terminal').style.minHeight = h + 'px';
408
+ }
409
+ });
410
+ } else {
411
+ term.reset();
412
+ }
413
+
414
+ await connectPtyWs(agentId, sessionId);
415
+ }
416
+
417
+ // (Re)establish the WebSocket for the session the viewer is currently showing.
418
+ // Split out from openPtyViewer so auto-reconnect can re-run just this part
419
+ // without tearing down the terminal or flickering the panel.
420
+ async function connectPtyWs(agentId, sessionId) {
421
+ // Guard against a stale reconnect firing after the user closed/switched.
422
+ if (currentPtySession !== sessionId) return;
423
+
424
+ // Fresh decoder per (re)connect so a leftover partial byte can't corrupt
425
+ // the first character of the freshly-replayed screen frame.
426
+ utf8Decoder = new TextDecoder('utf-8');
427
+
428
+ let url;
429
+ try {
430
+ url = await wsPtyUrl(agentId, sessionId);
431
+ } catch (e) {
432
+ // Ticket fetch failed (gateway momentarily unreachable) — retry.
433
+ schedulePtyReconnect(agentId, sessionId);
434
+ return;
435
+ }
436
+ // The session may have been closed/switched while awaiting the ticket.
437
+ if (currentPtySession !== sessionId) return;
438
+
439
+ ptyWs = new WebSocket(url);
440
+ ptyWs.binaryType = 'arraybuffer';
441
+
442
+ ptyWs.onopen = function() { ptyReconnectAttempts = 0; };
443
+ ptyWs.onmessage = function(ev) {
444
+ const data = ev.data instanceof ArrayBuffer
445
+ ? utf8Decoder.decode(ev.data, { stream: true })
446
+ : ev.data;
447
+ term.write(stripMouseModes(data));
448
+ };
449
+ ptyWs.onclose = function(ev) {
450
+ ptyWs = null;
451
+ // Viewer was closed or switched to another session — stop here.
452
+ if (currentPtySession !== sessionId) return;
453
+ // 4404 = the session is no longer running in PTY mode (it ended). No
454
+ // point reconnecting; the server will never accept this stream again.
455
+ if (ev.code === 4404) {
456
+ if (term) term.writeln('\\r\\n\\x1b[33m[session ended]\\x1b[0m');
457
+ return;
458
+ }
459
+ schedulePtyReconnect(agentId, sessionId);
460
+ };
461
+ // onerror is always followed by onclose, which owns the reconnect logic.
462
+ ptyWs.onerror = function() {};
463
+ }
464
+
465
+ // Reconnect with capped exponential backoff (1s -> 10s). A single
466
+ // "reconnecting" notice is shown per disconnect burst; the server replays a
467
+ // clean frame on resubscribe, so the view redraws itself once we are back.
468
+ function schedulePtyReconnect(agentId, sessionId) {
469
+ if (ptyReconnectTimer) return; // already pending
470
+ if (currentPtySession !== sessionId) return; // viewer no longer wants this
471
+ if (ptyReconnectAttempts >= PTY_RECONNECT_MAX_ATTEMPTS) {
472
+ if (term) term.writeln('\\r\\n\\x1b[31m[disconnected — click View to retry]\\x1b[0m');
473
+ return;
474
+ }
475
+ if (ptyReconnectAttempts === 0 && term) {
476
+ term.writeln('\\r\\n\\x1b[33m[reconnecting\\u2026]\\x1b[0m');
477
+ }
478
+ const delay = Math.min(1000 * Math.pow(2, ptyReconnectAttempts), PTY_RECONNECT_MAX_MS);
479
+ ptyReconnectAttempts++;
480
+ ptyReconnectTimer = setTimeout(function() {
481
+ ptyReconnectTimer = null;
482
+ void connectPtyWs(agentId, sessionId);
483
+ }, delay);
484
+ }
485
+
486
+ function closePtyViewer() {
487
+ // Cancel any pending reconnect and suppress the handlers on the socket we
488
+ // are about to close intentionally, so it does not schedule a new one.
489
+ if (ptyReconnectTimer) { clearTimeout(ptyReconnectTimer); ptyReconnectTimer = null; }
490
+ ptyReconnectAttempts = 0;
491
+ if (ptyWs) { ptyWs.onclose = null; ptyWs.onerror = null; ptyWs.close(); ptyWs = null; }
492
+ currentPtyAgent = null;
493
+ currentPtySession = null;
494
+ document.getElementById('pty-viewer').style.display = 'none';
495
+ }
496
+
497
+ // Refresh: force a clean reconnect of the CURRENT session. The server replays
498
+ // a freshly-serialized screen frame on subscribe, so this redraws from a clean
499
+ // xterm state — a manual escape hatch if the live stream ever drifts.
500
+ async function refreshPtyViewer() {
501
+ const agentId = currentPtyAgent;
502
+ const sessionId = currentPtySession;
503
+ if (!agentId) return;
504
+ closePtyViewer();
505
+ await openPtyViewer(agentId, sessionId);
146
506
  }
147
507
 
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>';
508
+ document.getElementById('pty-close-btn').addEventListener('click', closePtyViewer);
509
+ document.getElementById('pty-refresh-btn').addEventListener('click', function() { void refreshPtyViewer(); });
510
+
511
+ // Event delegation for Live buttons (avoids inline onclick + HTML injection)
512
+ document.getElementById('sessions-tbody').addEventListener('click', function(e) {
513
+ const btn = e.target.closest('.btn-stream');
514
+ if (btn) void openPtyViewer(btn.getAttribute('data-agent-id'), btn.getAttribute('data-session-id'));
515
+ });
516
+
517
+ function escHtml(s) {
518
+ return String(s)
519
+ .replace(/&/g, '&amp;')
520
+ .replace(/</g, '&lt;')
521
+ .replace(/>/g, '&gt;')
522
+ .replace(/"/g, '&quot;');
523
+ }
524
+
525
+ function modeBadge(mode) {
526
+ if (mode === 'pty-shell') return '<span class="badge badge-blue">wrap-shell</span>';
527
+ if (mode === 'headless') return '<span class="badge badge-purple">headless</span>';
528
+ return '<span class="ts">' + escHtml(mode || '?') + '</span>';
152
529
  }
153
530
 
531
+ // Source badge with a per-channel color: telegram=blue, discord=purple,
532
+ // api=gray. Unknown sources fall back to gray.
533
+ function sourceBadge(source) {
534
+ const s = String(source || '?').toLowerCase();
535
+ if (s === 'telegram') return '<span class="badge badge-blue">telegram</span>';
536
+ if (s === 'discord') return '<span class="badge badge-purple">discord</span>';
537
+ if (s === 'api') return '<span class="badge badge-gray">api</span>';
538
+ return '<span class="badge badge-gray">' + escHtml(source || '?') + '</span>';
539
+ }
540
+
541
+ // Prettify a model id for display: drop the "claude-" prefix and any
542
+ // trailing date stamp, e.g. claude-haiku-4-5-20251001 -> haiku-4-5.
543
+ // Each model family gets a distinct badge color; full id kept in the tooltip.
544
+ function fmtModel(m) {
545
+ if (!m) return '<span class="ts">&mdash;</span>';
546
+ const id = String(m);
547
+ const label = id.replace(/^claude-/, '').replace(/-\\d{8}$/, '');
548
+ let cls = 'badge-model';
549
+ if (/opus/i.test(id)) cls = 'badge-opus';
550
+ else if (/sonnet/i.test(id)) cls = 'badge-sonnet';
551
+ else if (/haiku/i.test(id)) cls = 'badge-haiku';
552
+ else if (/fable/i.test(id)) cls = 'badge-fable';
553
+ return '<span class="badge ' + cls + '" title="' + escHtml(id) + '">' + escHtml(label) + '</span>';
554
+ }
555
+
556
+ // Format a context-window token count compactly: 1234 -> "1.2k", 45000 -> "45k".
557
+ // Full value is kept in the tooltip. 0/unknown renders as a dash.
558
+ function fmtTokens(n) {
559
+ const v = Number(n) || 0;
560
+ if (v <= 0) return '<span class="ts">&mdash;</span>';
561
+ const label = v >= 1000 ? (v / 1000).toFixed(v >= 10000 ? 0 : 1) + 'k' : String(v);
562
+ return '<span title="' + v.toLocaleString() + ' tokens">' + label + '</span>';
563
+ }
564
+
565
+ // ── Status Refresh ────────────────────────────────────────────────────────
154
566
  async function refresh() {
155
567
  document.getElementById('refresh-indicator').textContent = 'refreshing...';
156
568
  try {
157
- const res = await fetch('/status');
569
+ const res = await fetch(apiUrl('/status'));
158
570
  if (!res.ok) throw new Error('HTTP ' + res.status);
159
571
  const data = await res.json();
160
572
 
161
573
  document.getElementById('uptime').textContent = fmtUptime(data.uptime || 0);
162
574
  document.getElementById('started-at').textContent = data.startedAt
163
- ? new Date(data.startedAt).toLocaleString() : '';
575
+ ? new Date(data.startedAt).toLocaleString() : '\\u2014';
164
576
  document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
577
+ if (data.version) document.getElementById('gateway-version').textContent = 'v' + data.version;
165
578
  document.getElementById('error-msg').style.display = 'none';
166
579
 
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
- });
580
+ const agents = data.agents || [];
581
+
582
+ // Agent badges bar.
583
+ // Green = available. An agent with a channel receiver (telegram/discord)
584
+ // is green only while its receiver is running; API-only agents have no
585
+ // receiver, so they are always green as long as the gateway loaded them.
586
+ // Red = a channel agent whose receiver is down (genuinely stopped).
587
+ const badges = agents.map(function(a) {
588
+ const ok = a.hasChannel ? a.isRunning : true;
589
+ const dot = ok ? '<span class="dot-green">&#x25CF;</span>' : '<span class="dot-red">&#x25CF;</span>';
590
+ return '<span class="agent-badge">' + dot + ' <span class="agent-name">' + escHtml(a.id) + '</span></span>';
193
591
  });
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>');
592
+ document.getElementById('agents-bar').innerHTML = badges.join('') || '<span class="ts">No agents</span>';
593
+
594
+ // Sessions table — flat, session-centric. One row per real session across
595
+ // all agents; agents with no session do not produce a row.
596
+ const rows = [];
597
+ agents.forEach(function(a) {
598
+ (a.sessions || []).forEach(function(s) {
599
+ const statusBadge = s.isRunning
600
+ ? '<span class="badge badge-green">running</span>'
601
+ : '<span class="badge badge-gray">stopped</span>';
602
+ const uptime = s.isRunning ? fmtUptime(s.uptimeSec || 0) : '<span class="ts">&mdash;</span>';
603
+ const sessId = s.sessionId
604
+ ? '<span class="session-id">' + escHtml(s.sessionId) + '</span>'
605
+ : '<span class="ts">&mdash;</span>';
606
+ const chatCell = s.chatId
607
+ ? '<span class="session-id">' + escHtml(String(s.chatId)) + '</span>'
608
+ : '<span class="ts">&mdash;</span>';
609
+ const liveBtn = (s.hasPtyStream && s.isRunning && s.mode === 'pty-shell')
610
+ ? '<button class="btn-stream" data-agent-id="' + escHtml(a.id) + '" data-session-id="' + escHtml(s.sessionId || '') + '">💻 View</button>'
611
+ : '<span class="ts">&mdash;</span>';
612
+ rows.push(
613
+ '<tr class="session-row">' +
614
+ '<td><span style="color:#90cdf4;font-weight:600;">' + escHtml(a.id) + '</span></td>' +
615
+ '<td>' + sessId + '</td>' +
616
+ '<td>' + chatCell + '</td>' +
617
+ '<td>' + sourceBadge(s.source) + '</td>' +
618
+ '<td>' + modeBadge(s.mode) + '</td>' +
619
+ '<td>' + fmtModel(s.model) + '</td>' +
620
+ '<td>' + fmtTokens(s.tokens) + '</td>' +
621
+ '<td>' + statusBadge + '</td>' +
622
+ '<td>' + uptime + '</td>' +
623
+ '<td>' + fmtTs(s.spawnedAt ? new Date(s.spawnedAt).toISOString() : null) + '</td>' +
624
+ '<td>' + liveBtn + '</td>' +
625
+ '</tr>'
626
+ );
208
627
  });
209
628
  });
629
+
210
630
  document.getElementById('sessions-tbody').innerHTML =
211
- sessRows.length ? sessRows.join('') : '<tr><td colspan="4" class="ts">No sessions yet</td></tr>';
631
+ rows.length ? rows.join('') : '<tr><td colspan="11" class="ts">No active sessions</td></tr>';
212
632
 
213
- document.getElementById('refresh-indicator').textContent = 'auto-refresh 5s';
633
+ document.getElementById('refresh-indicator').textContent = 'auto-refresh 3s';
214
634
  } catch(e) {
215
635
  document.getElementById('error-msg').textContent = 'Error fetching status: ' + e.message;
216
636
  document.getElementById('error-msg').style.display = 'block';
@@ -218,8 +638,156 @@ function generateDashboardHtml() {
218
638
  }
219
639
  }
220
640
 
641
+ // ── Process Tree ─────────────────────────────────────────────────────────
642
+ async function refreshProcesses() {
643
+ try {
644
+ const res = await fetch(apiUrl('/processes'));
645
+ if (!res.ok) return;
646
+ const data = await res.json();
647
+ renderProcessTree(data.processes || [], data.numCpus || 1);
648
+ } catch(e) {
649
+ document.getElementById('proc-tree').textContent = 'Error: ' + e.message;
650
+ }
651
+ }
652
+
653
+ function renderProcessTree(procs, numCpus) {
654
+ if (!procs.length) {
655
+ document.getElementById('proc-tree').textContent = '— no gateway processes found —';
656
+ return;
657
+ }
658
+
659
+ const pidMap = {};
660
+ procs.forEach(function(p) { pidMap[p.pid] = p; });
661
+
662
+ // Aggregate resource usage across the whole gateway process tree.
663
+ // ps %cpu is per-core (100% = 1 core), so divide by numCpus to get
664
+ // a normalized 0–100% load figure across all available cores.
665
+ // RSS is summed and may slightly over-count shared pages.
666
+ let rawCpuSum = 0, totalRssKb = 0;
667
+ procs.forEach(function(p) {
668
+ rawCpuSum += Number(p.cpu) || 0;
669
+ totalRssKb += Number(p.rssKb) || 0;
670
+ });
671
+ const totalCpu = rawCpuSum / (numCpus || 1);
672
+ const totalMemMb = totalRssKb / 1024;
673
+ const memStr = totalMemMb >= 1024
674
+ ? (totalMemMb / 1024).toFixed(2) + ' GB'
675
+ : totalMemMb.toFixed(0) + ' MB';
676
+
677
+ function cat(p) {
678
+ const a = p.args;
679
+ if (a.includes('node') && a.includes('dist/index')) return 'orchestrator';
680
+ if (a.includes('claude-pty-shell')) return 'pty';
681
+ if (a.includes('bun') && a.includes('mcp/server')) return 'mcp';
682
+ if (a.includes('bun') && a.includes('telegram') && a.includes('receiver')) return 'telegram';
683
+ if (a.includes('bun') && a.includes('discord') && a.includes('receiver')) return 'discord';
684
+ if (a.includes('--mcp-config') && (a.includes('--session-id') || a.includes('--print'))) {
685
+ if (a.includes('--session-id')) {
686
+ const parent = pidMap[p.ppid];
687
+ return (parent && cat(parent) === 'pty') ? 'claude-pty' : 'claude-headless';
688
+ }
689
+ return 'claude-headless';
690
+ }
691
+ return 'other';
692
+ }
693
+
694
+ // Show full command lines (no truncation) — text wraps inside the box.
695
+ function full(args) {
696
+ return escHtml(args);
697
+ }
698
+
699
+ function sessionId(args) {
700
+ const m = args.match(/--session-id\\s+(\\S+)/);
701
+ return m ? escHtml(m[1]) : '?';
702
+ }
703
+
704
+ function agentName(args) {
705
+ const m = args.match(/agents\\/([^/]+)\\/workspace/);
706
+ return m ? m[1] : '?';
707
+ }
708
+
709
+ const lines = [];
710
+ // First line: total resource usage across the whole gateway tree.
711
+ lines.push(
712
+ '<span class="proc-summary">' +
713
+ '\\u2211 ' + procs.length + ' procs' +
714
+ ' \\u00b7 CPU ' + totalCpu.toFixed(1) + '%' +
715
+ ' \\u00b7 MEM ' + memStr +
716
+ '</span>'
717
+ );
718
+ lines.push('');
719
+ const orchestrator = procs.find(function(p) { return cat(p) === 'orchestrator'; });
720
+ const ptys = procs.filter(function(p) { return cat(p) === 'pty'; });
721
+ const headless = procs.filter(function(p) { return cat(p) === 'claude-headless'; });
722
+ const telegramReceivers = procs.filter(function(p) { return cat(p) === 'telegram'; });
723
+ const discordReceivers = procs.filter(function(p) { return cat(p) === 'discord'; });
724
+ const mcpServers = procs.filter(function(p) { return cat(p) === 'mcp'; });
725
+
726
+ const gatewayPids = new Set(procs.map(function(p) { return p.pid; }));
727
+ const orphans = procs.filter(function(p) {
728
+ const c = cat(p);
729
+ return (c === 'claude-pty' || c === 'claude-headless' || c === 'pty' || c === 'mcp')
730
+ && !gatewayPids.has(p.ppid)
731
+ && p.pid !== (orchestrator && orchestrator.pid);
732
+ });
733
+
734
+ if (orchestrator) {
735
+ lines.push('<span class="proc-orchestrator">Orchestrator</span>');
736
+ lines.push(' PID ' + orchestrator.pid + ' <span class="proc-orchestrator">' + full(orchestrator.args) + '</span>');
737
+ lines.push('');
738
+ }
739
+
740
+ const sessionCount = ptys.length + headless.length;
741
+ lines.push('<span class="proc-label">Sessions (' + sessionCount + ')</span>');
742
+
743
+ ptys.forEach(function(pty) {
744
+ const agent = agentName(pty.args);
745
+ lines.push(' PID ' + pty.pid + ' <span class="proc-pty">wrap-shell</span> [' + agent + ']');
746
+ const claudeChild = procs.find(function(p) { return p.ppid === pty.pid && cat(p) === 'claude-pty'; });
747
+ if (claudeChild) {
748
+ lines.push(' \\u2514\\u2500 PID ' + claudeChild.pid + ' <span class="proc-claude">claude ' + sessionId(claudeChild.args) + '</span>');
749
+ const mcp = mcpServers.find(function(p) { return p.ppid === claudeChild.pid; });
750
+ if (mcp) {
751
+ lines.push(' \\u2514\\u2500 PID ' + mcp.pid + ' <span class="proc-mcp">mcp</span>');
752
+ }
753
+ }
754
+ });
755
+
756
+ headless.forEach(function(cl) {
757
+ const agent = agentName(cl.args);
758
+ lines.push(' PID ' + cl.pid + ' <span class="proc-claude">claude --print</span>' + (agent !== '?' ? ' [' + agent + ']' : ''));
759
+ const mcp = mcpServers.find(function(p) { return p.ppid === cl.pid; });
760
+ if (mcp) {
761
+ lines.push(' \\u2514\\u2500 PID ' + mcp.pid + ' <span class="proc-mcp">mcp</span>');
762
+ }
763
+ });
764
+
765
+ if (sessionCount === 0) lines.push(' <span class="ts">\\u2014 none \\u2014</span>');
766
+ lines.push('');
767
+
768
+ lines.push('<span class="proc-label">Receivers</span>');
769
+ if (telegramReceivers.length) lines.push(' Telegram \\u00d7' + telegramReceivers.length);
770
+ if (discordReceivers.length) lines.push(' Discord \\u00d7' + discordReceivers.length);
771
+ if (!telegramReceivers.length && !discordReceivers.length) lines.push(' <span class="ts">\\u2014 none \\u2014</span>');
772
+ lines.push('');
773
+
774
+ lines.push('<span class="proc-label">Orphans</span>');
775
+ if (orphans.length) {
776
+ orphans.forEach(function(p) {
777
+ lines.push(' \\u26a0 PID ' + p.pid + ' <span class="proc-orphan">' + full(p.args) + '</span>');
778
+ });
779
+ } else {
780
+ lines.push(' <span class="ts">none \\u2705</span>');
781
+ }
782
+
783
+ document.getElementById('proc-tree').innerHTML = lines.join('\\n');
784
+ }
785
+
221
786
  refresh();
222
- setInterval(refresh, 5000);
787
+ refreshProcesses();
788
+ setInterval(refresh, 3000);
789
+ // Process tree (with CPU/mem) is heavier (spawns ps) — refresh a bit slower.
790
+ setInterval(refreshProcesses, 6000);
223
791
  </script>
224
792
  </body>
225
793
  </html>`;