instar 0.7.52 → 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,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
@@ -21,6 +21,7 @@ import type { TelegraphService } from '../publishing/TelegraphService.js';
21
21
  import type { PrivateViewer } from '../publishing/PrivateViewer.js';
22
22
  import type { TunnelManager } from '../tunnel/TunnelManager.js';
23
23
  import type { EvolutionManager } from '../core/EvolutionManager.js';
24
+ import type { SessionWatchdog } from '../monitoring/SessionWatchdog.js';
24
25
  export interface RouteContext {
25
26
  config: InstarConfig;
26
27
  sessionManager: SessionManager;
@@ -38,6 +39,7 @@ export interface RouteContext {
38
39
  viewer: PrivateViewer | null;
39
40
  tunnel: TunnelManager | null;
40
41
  evolution: EvolutionManager | null;
42
+ watchdog: SessionWatchdog | null;
41
43
  startTime: Date;
42
44
  }
43
45
  export declare function createRoutes(ctx: RouteContext): Router;
@@ -1695,6 +1695,27 @@ export function createRoutes(ctx) {
1695
1695
  }
1696
1696
  res.json({ ok: true, id: req.params.id, status });
1697
1697
  });
1698
+ // ── Watchdog ──────────────────────────────────────────────────
1699
+ router.get('/watchdog/status', (req, res) => {
1700
+ if (!ctx.watchdog) {
1701
+ res.json({ enabled: false, sessions: [], interventionHistory: [] });
1702
+ return;
1703
+ }
1704
+ res.json(ctx.watchdog.getStatus());
1705
+ });
1706
+ router.post('/watchdog/toggle', (req, res) => {
1707
+ if (!ctx.watchdog) {
1708
+ res.status(404).json({ error: 'Watchdog not configured' });
1709
+ return;
1710
+ }
1711
+ const { enabled } = req.body;
1712
+ if (typeof enabled !== 'boolean') {
1713
+ res.status(400).json({ error: 'enabled (boolean) required' });
1714
+ return;
1715
+ }
1716
+ ctx.watchdog.setEnabled(enabled);
1717
+ res.json({ enabled: ctx.watchdog.isEnabled() });
1718
+ });
1698
1719
  return router;
1699
1720
  }
1700
1721
  export function formatUptime(ms) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "0.7.52",
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"