instar 0.7.51 → 0.7.53

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.
@@ -0,0 +1,843 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Instar Dashboard</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
8
+ <style>
9
+ :root {
10
+ --bg: #0a0a0a;
11
+ --bg-panel: #111;
12
+ --bg-hover: #1a1a1a;
13
+ --bg-active: #1e2d1e;
14
+ --border: #222;
15
+ --text: #ccc;
16
+ --text-dim: #666;
17
+ --text-bright: #eee;
18
+ --accent: #4ade80;
19
+ --accent-dim: #22c55e;
20
+ --orange: #f59e0b;
21
+ --red: #ef4444;
22
+ --blue: #3b82f6;
23
+ }
24
+
25
+ * { margin: 0; padding: 0; box-sizing: border-box; }
26
+
27
+ body {
28
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
29
+ background: var(--bg);
30
+ color: var(--text);
31
+ height: 100vh;
32
+ overflow: hidden;
33
+ }
34
+
35
+ /* Layout */
36
+ .app {
37
+ display: grid;
38
+ grid-template-rows: auto 1fr;
39
+ grid-template-columns: 280px 1fr;
40
+ height: 100vh;
41
+ }
42
+
43
+ /* Header */
44
+ .header {
45
+ grid-column: 1 / -1;
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: space-between;
49
+ padding: 12px 20px;
50
+ border-bottom: 1px solid var(--border);
51
+ background: var(--bg-panel);
52
+ }
53
+
54
+ .header-left {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 12px;
58
+ }
59
+
60
+ .header h1 {
61
+ font-size: 16px;
62
+ font-weight: 600;
63
+ color: var(--text-bright);
64
+ letter-spacing: -0.02em;
65
+ }
66
+
67
+ .header .logo {
68
+ font-size: 20px;
69
+ }
70
+
71
+ .status-badge {
72
+ display: inline-flex;
73
+ align-items: center;
74
+ gap: 6px;
75
+ font-size: 12px;
76
+ padding: 3px 10px;
77
+ border-radius: 12px;
78
+ background: #0f2f0f;
79
+ color: var(--accent);
80
+ border: 1px solid #1a3a1a;
81
+ }
82
+
83
+ .status-badge .dot {
84
+ width: 6px;
85
+ height: 6px;
86
+ border-radius: 50%;
87
+ background: var(--accent);
88
+ animation: pulse 2s infinite;
89
+ }
90
+
91
+ .status-badge.disconnected {
92
+ background: #2f0f0f;
93
+ color: var(--red);
94
+ border-color: #3a1a1a;
95
+ }
96
+
97
+ .status-badge.disconnected .dot {
98
+ background: var(--red);
99
+ animation: none;
100
+ }
101
+
102
+ @keyframes pulse {
103
+ 0%, 100% { opacity: 1; }
104
+ 50% { opacity: 0.4; }
105
+ }
106
+
107
+ /* Sidebar */
108
+ .sidebar {
109
+ border-right: 1px solid var(--border);
110
+ background: var(--bg-panel);
111
+ overflow-y: auto;
112
+ display: flex;
113
+ flex-direction: column;
114
+ }
115
+
116
+ .sidebar-header {
117
+ padding: 16px;
118
+ border-bottom: 1px solid var(--border);
119
+ display: flex;
120
+ align-items: center;
121
+ justify-content: space-between;
122
+ }
123
+
124
+ .sidebar-header h2 {
125
+ font-size: 13px;
126
+ font-weight: 600;
127
+ color: var(--text-dim);
128
+ text-transform: uppercase;
129
+ letter-spacing: 0.05em;
130
+ }
131
+
132
+ .session-count {
133
+ font-size: 11px;
134
+ padding: 2px 8px;
135
+ border-radius: 10px;
136
+ background: var(--border);
137
+ color: var(--text-dim);
138
+ }
139
+
140
+ .session-list {
141
+ flex: 1;
142
+ overflow-y: auto;
143
+ padding: 8px;
144
+ }
145
+
146
+ .session-item {
147
+ padding: 10px 12px;
148
+ border-radius: 8px;
149
+ cursor: pointer;
150
+ margin-bottom: 2px;
151
+ transition: background 0.15s;
152
+ }
153
+
154
+ .session-item:hover {
155
+ background: var(--bg-hover);
156
+ }
157
+
158
+ .session-item.active {
159
+ background: var(--bg-active);
160
+ border: 1px solid #2a4a2a;
161
+ }
162
+
163
+ .session-name {
164
+ font-size: 13px;
165
+ font-weight: 500;
166
+ color: var(--text-bright);
167
+ margin-bottom: 4px;
168
+ white-space: nowrap;
169
+ overflow: hidden;
170
+ text-overflow: ellipsis;
171
+ }
172
+
173
+ .session-meta {
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 8px;
177
+ font-size: 11px;
178
+ color: var(--text-dim);
179
+ }
180
+
181
+ .session-meta .model-badge {
182
+ padding: 1px 6px;
183
+ border-radius: 4px;
184
+ font-size: 10px;
185
+ font-weight: 600;
186
+ text-transform: uppercase;
187
+ }
188
+
189
+ .model-badge.opus { background: #2d1b4e; color: #c084fc; }
190
+ .model-badge.sonnet { background: #1b2e4e; color: #60a5fa; }
191
+ .model-badge.haiku { background: #1b3e3e; color: #5eead4; }
192
+
193
+ .empty-state {
194
+ display: flex;
195
+ flex-direction: column;
196
+ align-items: center;
197
+ justify-content: center;
198
+ padding: 40px 20px;
199
+ text-align: center;
200
+ color: var(--text-dim);
201
+ }
202
+
203
+ .empty-state .icon { font-size: 32px; margin-bottom: 12px; opacity: 0.5; }
204
+ .empty-state p { font-size: 13px; line-height: 1.5; }
205
+
206
+ /* Main panel */
207
+ .main {
208
+ display: flex;
209
+ flex-direction: column;
210
+ overflow: hidden;
211
+ background: var(--bg);
212
+ }
213
+
214
+ .terminal-header {
215
+ display: flex;
216
+ align-items: center;
217
+ justify-content: space-between;
218
+ padding: 10px 16px;
219
+ border-bottom: 1px solid var(--border);
220
+ background: var(--bg-panel);
221
+ min-height: 44px;
222
+ }
223
+
224
+ .terminal-header .session-info {
225
+ display: flex;
226
+ align-items: center;
227
+ gap: 10px;
228
+ }
229
+
230
+ .terminal-header .session-info h3 {
231
+ font-size: 14px;
232
+ font-weight: 500;
233
+ color: var(--text-bright);
234
+ }
235
+
236
+ .terminal-actions {
237
+ display: flex;
238
+ gap: 6px;
239
+ }
240
+
241
+ .action-btn {
242
+ padding: 4px 10px;
243
+ border-radius: 6px;
244
+ border: 1px solid var(--border);
245
+ background: var(--bg);
246
+ color: var(--text);
247
+ font-size: 12px;
248
+ cursor: pointer;
249
+ transition: all 0.15s;
250
+ font-family: monospace;
251
+ }
252
+
253
+ .action-btn:hover {
254
+ background: var(--bg-hover);
255
+ border-color: #333;
256
+ }
257
+
258
+ .action-btn.danger { border-color: #3a1a1a; color: var(--red); }
259
+ .action-btn.danger:hover { background: #1a0a0a; }
260
+
261
+ .terminal-container {
262
+ flex: 1;
263
+ padding: 4px;
264
+ overflow: hidden;
265
+ }
266
+
267
+ .terminal-container .xterm {
268
+ height: 100%;
269
+ }
270
+
271
+ /* Input bar */
272
+ .input-bar {
273
+ display: flex;
274
+ align-items: center;
275
+ padding: 8px 12px;
276
+ border-top: 1px solid var(--border);
277
+ background: var(--bg-panel);
278
+ gap: 8px;
279
+ }
280
+
281
+ .input-bar input {
282
+ flex: 1;
283
+ padding: 8px 12px;
284
+ border-radius: 6px;
285
+ border: 1px solid var(--border);
286
+ background: var(--bg);
287
+ color: var(--text-bright);
288
+ font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
289
+ font-size: 13px;
290
+ outline: none;
291
+ transition: border-color 0.15s;
292
+ }
293
+
294
+ .input-bar input:focus {
295
+ border-color: var(--accent-dim);
296
+ }
297
+
298
+ .input-bar input::placeholder {
299
+ color: var(--text-dim);
300
+ }
301
+
302
+ .input-bar button {
303
+ padding: 8px 16px;
304
+ border-radius: 6px;
305
+ border: none;
306
+ background: var(--accent-dim);
307
+ color: #000;
308
+ font-size: 13px;
309
+ font-weight: 600;
310
+ cursor: pointer;
311
+ transition: background 0.15s;
312
+ }
313
+
314
+ .input-bar button:hover {
315
+ background: var(--accent);
316
+ }
317
+
318
+ .input-bar button:disabled {
319
+ opacity: 0.4;
320
+ cursor: not-allowed;
321
+ }
322
+
323
+ /* No selection state */
324
+ .no-session {
325
+ flex: 1;
326
+ display: flex;
327
+ flex-direction: column;
328
+ align-items: center;
329
+ justify-content: center;
330
+ color: var(--text-dim);
331
+ gap: 12px;
332
+ }
333
+
334
+ .no-session .icon { font-size: 48px; opacity: 0.3; }
335
+ .no-session p { font-size: 14px; }
336
+
337
+ /* Auth overlay */
338
+ .auth-overlay {
339
+ position: fixed;
340
+ inset: 0;
341
+ background: var(--bg);
342
+ display: flex;
343
+ align-items: center;
344
+ justify-content: center;
345
+ z-index: 100;
346
+ }
347
+
348
+ .auth-box {
349
+ background: var(--bg-panel);
350
+ border: 1px solid var(--border);
351
+ border-radius: 12px;
352
+ padding: 32px;
353
+ width: 360px;
354
+ text-align: center;
355
+ }
356
+
357
+ .auth-box h2 {
358
+ font-size: 18px;
359
+ margin-bottom: 8px;
360
+ color: var(--text-bright);
361
+ }
362
+
363
+ .auth-box p {
364
+ font-size: 13px;
365
+ color: var(--text-dim);
366
+ margin-bottom: 20px;
367
+ }
368
+
369
+ .auth-box input {
370
+ width: 100%;
371
+ padding: 10px 14px;
372
+ border-radius: 8px;
373
+ border: 1px solid var(--border);
374
+ background: var(--bg);
375
+ color: var(--text-bright);
376
+ font-family: monospace;
377
+ font-size: 14px;
378
+ outline: none;
379
+ margin-bottom: 12px;
380
+ }
381
+
382
+ .auth-box input:focus { border-color: var(--accent-dim); }
383
+
384
+ .auth-box button {
385
+ width: 100%;
386
+ padding: 10px;
387
+ border-radius: 8px;
388
+ border: none;
389
+ background: var(--accent-dim);
390
+ color: #000;
391
+ font-size: 14px;
392
+ font-weight: 600;
393
+ cursor: pointer;
394
+ }
395
+
396
+ .auth-box button:hover { background: var(--accent); }
397
+
398
+ .auth-error {
399
+ color: var(--red);
400
+ font-size: 12px;
401
+ margin-top: 8px;
402
+ }
403
+
404
+ /* Scrollbar */
405
+ ::-webkit-scrollbar { width: 6px; }
406
+ ::-webkit-scrollbar-track { background: transparent; }
407
+ ::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
408
+ ::-webkit-scrollbar-thumb:hover { background: #444; }
409
+ </style>
410
+ </head>
411
+ <body>
412
+ <!-- Auth overlay -->
413
+ <div class="auth-overlay" id="authOverlay">
414
+ <div class="auth-box">
415
+ <h2>Instar Dashboard</h2>
416
+ <p>Enter your auth token to connect</p>
417
+ <input type="password" id="tokenInput" placeholder="Bearer token..." autofocus>
418
+ <button onclick="authenticate()">Connect</button>
419
+ <div class="auth-error" id="authError" style="display:none"></div>
420
+ </div>
421
+ </div>
422
+
423
+ <!-- Main app -->
424
+ <div class="app" id="app" style="display:none">
425
+ <header class="header">
426
+ <div class="header-left">
427
+ <span class="logo">&#x1F990;</span>
428
+ <h1>Instar Dashboard</h1>
429
+ </div>
430
+ <div class="status-badge" id="connStatus">
431
+ <span class="dot"></span>
432
+ <span id="connText">Connected</span>
433
+ </div>
434
+ </header>
435
+
436
+ <aside class="sidebar">
437
+ <div class="sidebar-header">
438
+ <h2>Sessions</h2>
439
+ <span class="session-count" id="sessionCount">0</span>
440
+ </div>
441
+ <div class="session-list" id="sessionList">
442
+ <div class="empty-state" id="emptyState">
443
+ <div class="icon">&#x1F4AD;</div>
444
+ <p>No running sessions<br>Sessions will appear here when spawned</p>
445
+ </div>
446
+ </div>
447
+ </aside>
448
+
449
+ <main class="main" id="mainPanel">
450
+ <div class="no-session" id="noSession">
451
+ <div class="icon">&#x1F5A5;</div>
452
+ <p>Select a session to view its terminal</p>
453
+ </div>
454
+
455
+ <div id="terminalView" style="display:none">
456
+ <div class="terminal-header">
457
+ <div class="session-info">
458
+ <h3 id="termSessionName">—</h3>
459
+ <span class="model-badge" id="termModelBadge" style="display:none"></span>
460
+ </div>
461
+ <div class="terminal-actions">
462
+ <button class="action-btn" onclick="sendKey('C-c')" title="Send Ctrl+C">^C</button>
463
+ <button class="action-btn" onclick="sendKey('Escape')" title="Send Escape">Esc</button>
464
+ <button class="action-btn" onclick="sendSpecial('y')" title="Send 'y'">y</button>
465
+ <button class="action-btn" onclick="sendSpecial('n')" title="Send 'n'">n</button>
466
+ </div>
467
+ </div>
468
+ <div class="terminal-container" id="terminalContainer"></div>
469
+ <div class="input-bar">
470
+ <input type="text" id="termInput" placeholder="Type a message and press Enter..."
471
+ onkeydown="if(event.key==='Enter')sendInput()">
472
+ <button onclick="sendInput()" id="sendBtn">Send</button>
473
+ </div>
474
+ </div>
475
+ </main>
476
+ </div>
477
+
478
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
479
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
480
+ <script>
481
+ // ── State ────────────────────────────────────────────────
482
+ let ws = null;
483
+ let token = '';
484
+ let sessions = [];
485
+ let activeSession = null;
486
+ let term = null;
487
+ let fitAddon = null;
488
+
489
+ // ── Auth ─────────────────────────────────────────────────
490
+ function authenticate() {
491
+ token = document.getElementById('tokenInput').value.trim();
492
+ if (!token) {
493
+ showAuthError('Please enter a token');
494
+ return;
495
+ }
496
+
497
+ // Test token by hitting /health with auth
498
+ const port = window.location.port || location.port;
499
+ fetch(`/health`, {
500
+ headers: { 'Authorization': `Bearer ${token}` }
501
+ })
502
+ .then(r => {
503
+ if (r.ok) {
504
+ localStorage.setItem('instar_token', token);
505
+ document.getElementById('authOverlay').style.display = 'none';
506
+ document.getElementById('app').style.display = 'grid';
507
+ connectWebSocket();
508
+ } else {
509
+ showAuthError('Invalid token');
510
+ }
511
+ })
512
+ .catch(() => showAuthError('Cannot connect to server'));
513
+ }
514
+
515
+ function showAuthError(msg) {
516
+ const el = document.getElementById('authError');
517
+ el.textContent = msg;
518
+ el.style.display = 'block';
519
+ }
520
+
521
+ // Auto-login with stored token
522
+ const stored = localStorage.getItem('instar_token');
523
+ if (stored) {
524
+ token = stored;
525
+ fetch(`/health`, { headers: { 'Authorization': `Bearer ${token}` } })
526
+ .then(r => {
527
+ if (r.ok) {
528
+ document.getElementById('authOverlay').style.display = 'none';
529
+ document.getElementById('app').style.display = 'grid';
530
+ connectWebSocket();
531
+ }
532
+ })
533
+ .catch(() => {});
534
+ }
535
+
536
+ // Enter on token input
537
+ document.getElementById('tokenInput').addEventListener('keydown', e => {
538
+ if (e.key === 'Enter') authenticate();
539
+ });
540
+
541
+ // ── WebSocket ────────────────────────────────────────────
542
+ let reconnectAttempts = 0;
543
+ const MAX_RECONNECT = 10;
544
+
545
+ function connectWebSocket() {
546
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
547
+ const url = `${proto}//${location.host}/ws?token=${encodeURIComponent(token)}`;
548
+
549
+ ws = new WebSocket(url);
550
+
551
+ ws.onopen = () => {
552
+ reconnectAttempts = 0;
553
+ updateConnectionStatus(true);
554
+ };
555
+
556
+ ws.onclose = () => {
557
+ updateConnectionStatus(false);
558
+ // Reconnect with backoff
559
+ if (reconnectAttempts < MAX_RECONNECT) {
560
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
561
+ reconnectAttempts++;
562
+ setTimeout(connectWebSocket, delay);
563
+ }
564
+ };
565
+
566
+ ws.onerror = () => {
567
+ // onclose will fire after this
568
+ };
569
+
570
+ ws.onmessage = (event) => {
571
+ try {
572
+ const msg = JSON.parse(event.data);
573
+ handleMessage(msg);
574
+ } catch { /* ignore parse errors */ }
575
+ };
576
+ }
577
+
578
+ function handleMessage(msg) {
579
+ switch (msg.type) {
580
+ case 'sessions':
581
+ sessions = msg.sessions;
582
+ renderSessionList();
583
+ break;
584
+
585
+ case 'output':
586
+ if (msg.session === activeSession) {
587
+ renderTerminalOutput(msg.data);
588
+ }
589
+ break;
590
+
591
+ case 'session_ended':
592
+ if (msg.session === activeSession) {
593
+ // Session ended — show in terminal
594
+ if (term) {
595
+ term.writeln('\r\n\x1b[33m--- Session ended ---\x1b[0m');
596
+ }
597
+ }
598
+ break;
599
+
600
+ case 'subscribed':
601
+ // Subscribed confirmation
602
+ break;
603
+
604
+ case 'input_ack':
605
+ // Input acknowledged
606
+ break;
607
+
608
+ case 'pong':
609
+ break;
610
+
611
+ case 'error':
612
+ console.error('[ws]', msg.message);
613
+ break;
614
+ }
615
+ }
616
+
617
+ function wsSend(msg) {
618
+ if (ws && ws.readyState === WebSocket.OPEN) {
619
+ ws.send(JSON.stringify(msg));
620
+ }
621
+ }
622
+
623
+ function updateConnectionStatus(connected) {
624
+ const badge = document.getElementById('connStatus');
625
+ const text = document.getElementById('connText');
626
+ if (connected) {
627
+ badge.className = 'status-badge';
628
+ text.textContent = 'Connected';
629
+ } else {
630
+ badge.className = 'status-badge disconnected';
631
+ text.textContent = 'Disconnected';
632
+ }
633
+ }
634
+
635
+ // ── Session List ─────────────────────────────────────────
636
+ function renderSessionList() {
637
+ const list = document.getElementById('sessionList');
638
+ const empty = document.getElementById('emptyState');
639
+ const count = document.getElementById('sessionCount');
640
+
641
+ count.textContent = sessions.length;
642
+
643
+ if (sessions.length === 0) {
644
+ empty.style.display = 'flex';
645
+ // Clear any existing session items
646
+ list.querySelectorAll('.session-item').forEach(el => el.remove());
647
+ return;
648
+ }
649
+
650
+ empty.style.display = 'none';
651
+
652
+ // Build session items
653
+ const existing = new Map();
654
+ list.querySelectorAll('.session-item').forEach(el => {
655
+ existing.set(el.dataset.session, el);
656
+ });
657
+
658
+ // Remove sessions no longer running
659
+ for (const [name, el] of existing) {
660
+ if (!sessions.find(s => s.tmuxSession === name)) {
661
+ el.remove();
662
+ }
663
+ }
664
+
665
+ // Add/update sessions
666
+ for (const session of sessions) {
667
+ let el = existing.get(session.tmuxSession);
668
+ if (!el) {
669
+ el = document.createElement('div');
670
+ el.className = 'session-item';
671
+ el.dataset.session = session.tmuxSession;
672
+ el.onclick = () => selectSession(session.tmuxSession, session);
673
+ list.appendChild(el);
674
+ }
675
+
676
+ const elapsed = formatElapsed(session.startedAt);
677
+ const model = session.model || 'sonnet';
678
+ const isActive = session.tmuxSession === activeSession;
679
+
680
+ el.className = `session-item${isActive ? ' active' : ''}`;
681
+ el.innerHTML = `
682
+ <div class="session-name">${escapeHtml(session.name)}</div>
683
+ <div class="session-meta">
684
+ <span class="model-badge ${model}">${model}</span>
685
+ <span>${elapsed}</span>
686
+ ${session.jobSlug ? `<span>${escapeHtml(session.jobSlug)}</span>` : ''}
687
+ </div>
688
+ `;
689
+ }
690
+ }
691
+
692
+ // ── Terminal ──────────────────────────────────────────────
693
+ function selectSession(tmuxSession, session) {
694
+ // Unsubscribe from previous
695
+ if (activeSession) {
696
+ wsSend({ type: 'unsubscribe', session: activeSession });
697
+ }
698
+
699
+ activeSession = tmuxSession;
700
+
701
+ // Update UI
702
+ document.getElementById('noSession').style.display = 'none';
703
+ document.getElementById('terminalView').style.display = 'flex';
704
+ document.getElementById('terminalView').style.flexDirection = 'column';
705
+ document.getElementById('terminalView').style.height = '100%';
706
+ document.getElementById('termSessionName').textContent = session.name;
707
+
708
+ const badge = document.getElementById('termModelBadge');
709
+ if (session.model) {
710
+ badge.textContent = session.model;
711
+ badge.className = `model-badge ${session.model}`;
712
+ badge.style.display = 'inline-block';
713
+ } else {
714
+ badge.style.display = 'none';
715
+ }
716
+
717
+ // Initialize terminal if needed
718
+ if (!term) {
719
+ initTerminal();
720
+ }
721
+
722
+ // Clear and subscribe
723
+ term.clear();
724
+ wsSend({ type: 'subscribe', session: tmuxSession });
725
+
726
+ // Update active state in sidebar
727
+ renderSessionList();
728
+
729
+ // Focus input
730
+ document.getElementById('termInput').focus();
731
+ }
732
+
733
+ function initTerminal() {
734
+ const container = document.getElementById('terminalContainer');
735
+ container.innerHTML = '';
736
+
737
+ term = new window.Terminal({
738
+ theme: {
739
+ background: '#0a0a0a',
740
+ foreground: '#ccc',
741
+ cursor: '#4ade80',
742
+ selectionBackground: '#264f78',
743
+ black: '#1e1e1e',
744
+ red: '#f14c4c',
745
+ green: '#4ade80',
746
+ yellow: '#f59e0b',
747
+ blue: '#3b82f6',
748
+ magenta: '#c084fc',
749
+ cyan: '#5eead4',
750
+ white: '#ccc',
751
+ brightBlack: '#666',
752
+ brightRed: '#f87171',
753
+ brightGreen: '#86efac',
754
+ brightYellow: '#fde047',
755
+ brightBlue: '#60a5fa',
756
+ brightMagenta: '#d8b4fe',
757
+ brightCyan: '#99f6e4',
758
+ brightWhite: '#eee',
759
+ },
760
+ fontSize: 13,
761
+ fontFamily: "'SF Mono', 'Fira Code', 'JetBrains Mono', 'Cascadia Code', monospace",
762
+ cursorBlink: false,
763
+ cursorStyle: 'underline',
764
+ scrollback: 10000,
765
+ convertEol: true,
766
+ });
767
+
768
+ fitAddon = new window.FitAddon.FitAddon();
769
+ term.loadAddon(fitAddon);
770
+ term.open(container);
771
+
772
+ // Fit on resize
773
+ const resizeObserver = new ResizeObserver(() => {
774
+ try { fitAddon.fit(); } catch {}
775
+ });
776
+ resizeObserver.observe(container);
777
+
778
+ // Initial fit after a tick (container needs to be visible)
779
+ requestAnimationFrame(() => {
780
+ try { fitAddon.fit(); } catch {}
781
+ });
782
+ }
783
+
784
+ function renderTerminalOutput(data) {
785
+ if (!term) return;
786
+ // Replace full terminal content with latest capture
787
+ term.clear();
788
+ term.write(data);
789
+ // Auto-scroll to bottom
790
+ term.scrollToBottom();
791
+ }
792
+
793
+ // ── Input ────────────────────────────────────────────────
794
+ function sendInput() {
795
+ const input = document.getElementById('termInput');
796
+ const text = input.value;
797
+ if (!text || !activeSession) return;
798
+
799
+ wsSend({ type: 'input', session: activeSession, text });
800
+ input.value = '';
801
+ input.focus();
802
+ }
803
+
804
+ function sendKey(key) {
805
+ if (!activeSession) return;
806
+ wsSend({ type: 'key', session: activeSession, key });
807
+ }
808
+
809
+ function sendSpecial(text) {
810
+ if (!activeSession) return;
811
+ // Send literal text without Enter
812
+ wsSend({ type: 'key', session: activeSession, key: text });
813
+ }
814
+
815
+ // ── Helpers ───────────────────────────────────────────────
816
+ function formatElapsed(isoStr) {
817
+ const ms = Date.now() - new Date(isoStr).getTime();
818
+ const mins = Math.floor(ms / 60000);
819
+ if (mins < 60) return `${mins}m`;
820
+ const hrs = Math.floor(mins / 60);
821
+ const remMins = mins % 60;
822
+ if (hrs < 24) return `${hrs}h ${remMins}m`;
823
+ const days = Math.floor(hrs / 24);
824
+ return `${days}d ${hrs % 24}h`;
825
+ }
826
+
827
+ function escapeHtml(s) {
828
+ const div = document.createElement('div');
829
+ div.textContent = s;
830
+ return div.innerHTML;
831
+ }
832
+
833
+ // ── Keyboard shortcuts ───────────────────────────────────
834
+ document.addEventListener('keydown', (e) => {
835
+ // Ctrl+C when terminal focused but not input
836
+ if (e.key === 'c' && e.ctrlKey && document.activeElement?.id !== 'termInput') {
837
+ e.preventDefault();
838
+ sendKey('C-c');
839
+ }
840
+ });
841
+ </script>
842
+ </body>
843
+ </html>
@@ -207,6 +207,23 @@ export class AutoUpdater {
207
207
  }
208
208
  }
209
209
  catch { /* not found globally */ }
210
+ // If `which instar` didn't find a global binary, try npm's prefix path directly.
211
+ // This handles the common case where npm's global bin directory is not in PATH
212
+ // (automation contexts, fresh shell sessions, custom npm prefixes).
213
+ if (!instarBin) {
214
+ try {
215
+ const npmPrefix = execFileSync('npm', ['prefix', '-g'], {
216
+ encoding: 'utf-8',
217
+ stdio: ['pipe', 'pipe', 'pipe'],
218
+ }).trim();
219
+ const candidate = `${npmPrefix}/bin/instar`;
220
+ if (fs.existsSync(candidate)) {
221
+ instarBin = candidate;
222
+ console.log(`[AutoUpdater] Found global binary via npm prefix: ${instarBin}`);
223
+ }
224
+ }
225
+ catch { /* npm not available or prefix lookup failed */ }
226
+ }
210
227
  let cmd;
211
228
  if (instarBin) {
212
229
  // Use the global binary — guaranteed to be the updated version
@@ -215,7 +232,21 @@ export class AutoUpdater {
215
232
  console.log(`[AutoUpdater] Will restart from global binary: ${instarBin}`);
216
233
  }
217
234
  else {
218
- // Fallback: use the original process.argv (global not available)
235
+ // No global binary found. If we were running from npx cache, restarting
236
+ // from process.argv would loop (npx cache is the old version, which would
237
+ // detect the update again and restart again indefinitely).
238
+ const scriptPath = process.argv[1] || '';
239
+ const isNpxCache = scriptPath.includes('.npm/_npx') || scriptPath.includes('/_npx/');
240
+ if (isNpxCache) {
241
+ console.error('[AutoUpdater] Update applied but cannot restart — global binary not found in PATH or npm prefix.');
242
+ console.error('[AutoUpdater] Restarting from npx cache would cause a restart loop.');
243
+ console.error('[AutoUpdater] Manual restart required: npm install -g instar && instar server start');
244
+ void this.notify('Update applied but auto-restart skipped — global binary not in PATH.\n\n' +
245
+ 'Run manually to activate the update:\n' +
246
+ '```\nnpm install -g instar\ninstar server start --foreground\n```');
247
+ return;
248
+ }
249
+ // Not from npx cache — safe to restart from current path
219
250
  const args = process.argv.slice(1)
220
251
  .map(a => `'${a.replace(/'/g, "'\\''")}'`)
221
252
  .join(' ');
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import fs from 'node:fs';
9
+ import os from 'node:os';
9
10
  import path from 'node:path';
10
11
  export class HealthChecker {
11
12
  config;
@@ -152,7 +153,6 @@ export class HealthChecker {
152
153
  checkMemory() {
153
154
  const now = new Date().toISOString();
154
155
  try {
155
- const os = require('node:os');
156
156
  const totalBytes = os.totalmem();
157
157
  const freeBytes = os.freemem();
158
158
  const totalGB = totalBytes / (1024 ** 3);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * WebSocket Manager — real-time terminal streaming for the dashboard.
3
+ *
4
+ * Handles client subscriptions to tmux sessions, streams terminal output
5
+ * via diff-based updates, and forwards input to sessions.
6
+ *
7
+ * Protocol (JSON messages):
8
+ *
9
+ * Client → Server:
10
+ * { type: 'subscribe', session: 'session-name' }
11
+ * { type: 'unsubscribe', session: 'session-name' }
12
+ * { type: 'input', session: 'session-name', text: 'some input' }
13
+ * { type: 'key', session: 'session-name', key: 'C-c' }
14
+ * { type: 'ping' }
15
+ *
16
+ * Server → Client:
17
+ * { type: 'output', session: 'session-name', data: '...terminal output...' }
18
+ * { type: 'sessions', sessions: [...] }
19
+ * { type: 'session_ended', session: 'session-name' }
20
+ * { type: 'subscribed', session: 'session-name' }
21
+ * { type: 'unsubscribed', session: 'session-name' }
22
+ * { type: 'input_ack', session: 'session-name', success: true }
23
+ * { type: 'pong' }
24
+ * { type: 'error', message: '...' }
25
+ */
26
+ import type { Server as HttpServer } from 'node:http';
27
+ import type { SessionManager } from '../core/SessionManager.js';
28
+ import type { StateManager } from '../core/StateManager.js';
29
+ export declare class WebSocketManager {
30
+ private wss;
31
+ private clients;
32
+ private sessionOutputCache;
33
+ private streamInterval;
34
+ private heartbeatInterval;
35
+ private sessionBroadcastInterval;
36
+ private sessionManager;
37
+ private state;
38
+ private authToken?;
39
+ constructor(options: {
40
+ server: HttpServer;
41
+ sessionManager: SessionManager;
42
+ state: StateManager;
43
+ authToken?: string;
44
+ });
45
+ private authenticate;
46
+ private verifyToken;
47
+ private handleMessage;
48
+ /**
49
+ * Stream terminal output to subscribed clients.
50
+ * Uses diff-based approach: only sends new content since last capture.
51
+ */
52
+ private startStreaming;
53
+ private sendSessionList;
54
+ private broadcastSessionList;
55
+ private clientId;
56
+ private send;
57
+ /**
58
+ * Graceful shutdown — close all connections and stop intervals.
59
+ */
60
+ shutdown(): void;
61
+ }
62
+ //# sourceMappingURL=WebSocketManager.d.ts.map
@@ -0,0 +1,288 @@
1
+ /**
2
+ * WebSocket Manager — real-time terminal streaming for the dashboard.
3
+ *
4
+ * Handles client subscriptions to tmux sessions, streams terminal output
5
+ * via diff-based updates, and forwards input to sessions.
6
+ *
7
+ * Protocol (JSON messages):
8
+ *
9
+ * Client → Server:
10
+ * { type: 'subscribe', session: 'session-name' }
11
+ * { type: 'unsubscribe', session: 'session-name' }
12
+ * { type: 'input', session: 'session-name', text: 'some input' }
13
+ * { type: 'key', session: 'session-name', key: 'C-c' }
14
+ * { type: 'ping' }
15
+ *
16
+ * Server → Client:
17
+ * { type: 'output', session: 'session-name', data: '...terminal output...' }
18
+ * { type: 'sessions', sessions: [...] }
19
+ * { type: 'session_ended', session: 'session-name' }
20
+ * { type: 'subscribed', session: 'session-name' }
21
+ * { type: 'unsubscribed', session: 'session-name' }
22
+ * { type: 'input_ack', session: 'session-name', success: true }
23
+ * { type: 'pong' }
24
+ * { type: 'error', message: '...' }
25
+ */
26
+ import { WebSocketServer, WebSocket } from 'ws';
27
+ import { createHash, timingSafeEqual } from 'node:crypto';
28
+ export class WebSocketManager {
29
+ wss;
30
+ clients = new Map();
31
+ sessionOutputCache = new Map();
32
+ streamInterval = null;
33
+ heartbeatInterval = null;
34
+ sessionBroadcastInterval = null;
35
+ sessionManager;
36
+ state;
37
+ authToken;
38
+ constructor(options) {
39
+ this.sessionManager = options.sessionManager;
40
+ this.state = options.state;
41
+ this.authToken = options.authToken;
42
+ this.wss = new WebSocketServer({
43
+ noServer: true,
44
+ });
45
+ // Handle upgrade manually for auth
46
+ options.server.on('upgrade', (request, socket, head) => {
47
+ // Only handle /ws path
48
+ const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
49
+ if (url.pathname !== '/ws') {
50
+ socket.destroy();
51
+ return;
52
+ }
53
+ // Authenticate via query param or header
54
+ if (this.authToken && !this.authenticate(request, url)) {
55
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
56
+ socket.destroy();
57
+ return;
58
+ }
59
+ this.wss.handleUpgrade(request, socket, head, (ws) => {
60
+ this.wss.emit('connection', ws, request);
61
+ });
62
+ });
63
+ this.wss.on('connection', (ws) => {
64
+ const client = {
65
+ ws,
66
+ subscriptions: new Set(),
67
+ isAlive: true,
68
+ };
69
+ this.clients.set(ws, client);
70
+ // Send initial session list
71
+ this.sendSessionList(ws);
72
+ ws.on('message', (data) => {
73
+ try {
74
+ const msg = JSON.parse(data.toString());
75
+ this.handleMessage(client, msg);
76
+ }
77
+ catch {
78
+ this.send(ws, { type: 'error', message: 'Invalid JSON' });
79
+ }
80
+ });
81
+ ws.on('pong', () => {
82
+ client.isAlive = true;
83
+ });
84
+ ws.on('close', () => {
85
+ this.clients.delete(ws);
86
+ });
87
+ ws.on('error', () => {
88
+ this.clients.delete(ws);
89
+ });
90
+ });
91
+ // Start streaming terminal output to subscribers
92
+ this.startStreaming();
93
+ // Heartbeat to detect dead connections
94
+ this.heartbeatInterval = setInterval(() => {
95
+ for (const [ws, client] of this.clients) {
96
+ if (!client.isAlive) {
97
+ ws.terminate();
98
+ this.clients.delete(ws);
99
+ continue;
100
+ }
101
+ client.isAlive = false;
102
+ ws.ping();
103
+ }
104
+ }, 30_000);
105
+ this.heartbeatInterval.unref();
106
+ // Broadcast session list periodically
107
+ this.sessionBroadcastInterval = setInterval(() => {
108
+ this.broadcastSessionList();
109
+ }, 5_000);
110
+ this.sessionBroadcastInterval.unref();
111
+ }
112
+ authenticate(request, url) {
113
+ if (!this.authToken)
114
+ return true;
115
+ // Check query param first (for browser WebSocket which can't set headers)
116
+ const tokenParam = url.searchParams.get('token');
117
+ if (tokenParam && this.verifyToken(tokenParam))
118
+ return true;
119
+ // Check Authorization header
120
+ const header = request.headers.authorization;
121
+ if (header?.startsWith('Bearer ')) {
122
+ const token = header.slice(7);
123
+ if (this.verifyToken(token))
124
+ return true;
125
+ }
126
+ return false;
127
+ }
128
+ verifyToken(token) {
129
+ if (!this.authToken)
130
+ return true;
131
+ const ha = createHash('sha256').update(token).digest();
132
+ const hb = createHash('sha256').update(this.authToken).digest();
133
+ return timingSafeEqual(ha, hb);
134
+ }
135
+ handleMessage(client, msg) {
136
+ switch (msg.type) {
137
+ case 'subscribe': {
138
+ const session = String(msg.session || '');
139
+ if (!session) {
140
+ this.send(client.ws, { type: 'error', message: 'Missing session name' });
141
+ return;
142
+ }
143
+ client.subscriptions.add(session);
144
+ // Send current output immediately
145
+ const output = this.sessionManager.captureOutput(session, 200);
146
+ if (output) {
147
+ this.sessionOutputCache.set(`${this.clientId(client)}:${session}`, output);
148
+ this.send(client.ws, { type: 'output', session, data: output });
149
+ }
150
+ this.send(client.ws, { type: 'subscribed', session });
151
+ break;
152
+ }
153
+ case 'unsubscribe': {
154
+ const session = String(msg.session || '');
155
+ client.subscriptions.delete(session);
156
+ this.sessionOutputCache.delete(`${this.clientId(client)}:${session}`);
157
+ this.send(client.ws, { type: 'unsubscribed', session });
158
+ break;
159
+ }
160
+ case 'input': {
161
+ const session = String(msg.session || '');
162
+ const text = String(msg.text || '');
163
+ if (!session || !text) {
164
+ this.send(client.ws, { type: 'error', message: 'Missing session or text' });
165
+ return;
166
+ }
167
+ const success = this.sessionManager.sendInput(session, text);
168
+ this.send(client.ws, { type: 'input_ack', session, success });
169
+ break;
170
+ }
171
+ case 'key': {
172
+ const session = String(msg.session || '');
173
+ const key = String(msg.key || '');
174
+ if (!session || !key) {
175
+ this.send(client.ws, { type: 'error', message: 'Missing session or key' });
176
+ return;
177
+ }
178
+ const success = this.sessionManager.sendKey(session, key);
179
+ this.send(client.ws, { type: 'input_ack', session, success });
180
+ break;
181
+ }
182
+ case 'ping':
183
+ this.send(client.ws, { type: 'pong' });
184
+ break;
185
+ default:
186
+ this.send(client.ws, { type: 'error', message: `Unknown message type: ${msg.type}` });
187
+ }
188
+ }
189
+ /**
190
+ * Stream terminal output to subscribed clients.
191
+ * Uses diff-based approach: only sends new content since last capture.
192
+ */
193
+ startStreaming() {
194
+ this.streamInterval = setInterval(() => {
195
+ // Collect all unique session subscriptions across clients
196
+ const subscribedSessions = new Set();
197
+ for (const client of this.clients.values()) {
198
+ for (const session of client.subscriptions) {
199
+ subscribedSessions.add(session);
200
+ }
201
+ }
202
+ // Capture output for each subscribed session
203
+ for (const session of subscribedSessions) {
204
+ const output = this.sessionManager.captureOutput(session, 200);
205
+ // Broadcast to each subscribed client
206
+ for (const [, client] of this.clients) {
207
+ if (!client.subscriptions.has(session))
208
+ continue;
209
+ const cacheKey = `${this.clientId(client)}:${session}`;
210
+ const cached = this.sessionOutputCache.get(cacheKey);
211
+ if (output === null) {
212
+ // Session may have ended
213
+ if (cached !== undefined) {
214
+ this.send(client.ws, { type: 'session_ended', session });
215
+ this.sessionOutputCache.delete(cacheKey);
216
+ }
217
+ continue;
218
+ }
219
+ // Only send if output changed
220
+ if (output !== cached) {
221
+ this.sessionOutputCache.set(cacheKey, output);
222
+ this.send(client.ws, { type: 'output', session, data: output });
223
+ }
224
+ }
225
+ }
226
+ }, 500);
227
+ this.streamInterval.unref();
228
+ }
229
+ sendSessionList(ws) {
230
+ const running = this.sessionManager.listRunningSessions();
231
+ const sessions = running.map(s => ({
232
+ id: s.id,
233
+ name: s.name,
234
+ tmuxSession: s.tmuxSession,
235
+ status: s.status,
236
+ startedAt: s.startedAt,
237
+ jobSlug: s.jobSlug,
238
+ model: s.model,
239
+ }));
240
+ this.send(ws, { type: 'sessions', sessions });
241
+ }
242
+ broadcastSessionList() {
243
+ if (this.clients.size === 0)
244
+ return;
245
+ const running = this.sessionManager.listRunningSessions();
246
+ const sessions = running.map(s => ({
247
+ id: s.id,
248
+ name: s.name,
249
+ tmuxSession: s.tmuxSession,
250
+ status: s.status,
251
+ startedAt: s.startedAt,
252
+ jobSlug: s.jobSlug,
253
+ model: s.model,
254
+ }));
255
+ const msg = JSON.stringify({ type: 'sessions', sessions });
256
+ for (const client of this.clients.values()) {
257
+ if (client.ws.readyState === WebSocket.OPEN) {
258
+ client.ws.send(msg);
259
+ }
260
+ }
261
+ }
262
+ clientId(client) {
263
+ // Use object identity via a WeakRef-friendly approach
264
+ return String(client.ws._socket?.remotePort || Math.random());
265
+ }
266
+ send(ws, msg) {
267
+ if (ws.readyState === WebSocket.OPEN) {
268
+ ws.send(JSON.stringify(msg));
269
+ }
270
+ }
271
+ /**
272
+ * Graceful shutdown — close all connections and stop intervals.
273
+ */
274
+ shutdown() {
275
+ if (this.streamInterval)
276
+ clearInterval(this.streamInterval);
277
+ if (this.heartbeatInterval)
278
+ clearInterval(this.heartbeatInterval);
279
+ if (this.sessionBroadcastInterval)
280
+ clearInterval(this.sessionBroadcastInterval);
281
+ for (const [ws] of this.clients) {
282
+ ws.close(1001, 'Server shutting down');
283
+ }
284
+ this.clients.clear();
285
+ this.wss.close();
286
+ }
287
+ }
288
+ //# sourceMappingURL=WebSocketManager.js.map
@@ -8,6 +8,7 @@ import { Router } from 'express';
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import { createHash, timingSafeEqual } from 'node:crypto';
10
10
  import fs from 'node:fs';
11
+ import os from 'node:os';
11
12
  import path from 'node:path';
12
13
  import { rateLimiter, signViewPath } from './middleware.js';
13
14
  // Validation patterns for route parameters
@@ -64,7 +65,6 @@ export function createRoutes(ctx) {
64
65
  heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
65
66
  };
66
67
  // System-wide memory state
67
- const os = require('node:os');
68
68
  const totalMem = os.totalmem();
69
69
  const freeMem = os.freemem();
70
70
  base.systemMemory = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "0.7.51",
3
+ "version": "0.7.53",
4
4
  "description": "Persistent autonomy infrastructure for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -55,12 +55,14 @@
55
55
  "commander": "^12.0.0",
56
56
  "croner": "^8.0.0",
57
57
  "express": "^4.18.0",
58
- "picocolors": "^1.0.0"
58
+ "picocolors": "^1.0.0",
59
+ "ws": "^8.19.0"
59
60
  },
60
61
  "devDependencies": {
61
62
  "@types/express": "^4.17.21",
62
63
  "@types/node": "^20.11.0",
63
64
  "@types/supertest": "^6.0.3",
65
+ "@types/ws": "^8.18.1",
64
66
  "supertest": "^7.2.2",
65
67
  "typescript": "^5.9.3",
66
68
  "vitest": "^2.0.0"