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">🦐</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">💭</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">🖥</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>
|
package/dist/core/AutoUpdater.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
package/dist/server/routes.js
CHANGED
|
@@ -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.
|
|
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"
|