claude-code-watch 0.0.1

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,375 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const cp = require('child_process');
8
+ const { WebSocketServer } = require('ws');
9
+ const { Watcher, listSessions, listActiveSessions } = require('../watcher/watcher');
10
+ const { setDebugAll, contextWindowFor, formatTokenCount } = require('../parser/parser');
11
+
12
+ const MIME = {
13
+ '.html': 'text/html; charset=utf-8',
14
+ '.css': 'text/css; charset=utf-8',
15
+ '.js': 'application/javascript; charset=utf-8',
16
+ '.json': 'application/json; charset=utf-8',
17
+ '.png': 'image/png',
18
+ '.svg': 'image/svg+xml',
19
+ '.ico': 'image/x-icon',
20
+ };
21
+
22
+ const MAX_ITEM_BUFFER = 2000;
23
+
24
+ class DashboardServer {
25
+ constructor(options = {}) {
26
+ this.port = options.port || 23000;
27
+ this.host = options.host || '127.0.0.1';
28
+ this.collapseAfterMs = options.collapseAfter || 0;
29
+ this.watcher = null;
30
+ this.clients = new Set();
31
+ this.itemBuffer = [];
32
+ this.contextMap = new Map();
33
+
34
+ this.server = null;
35
+ this.wss = null;
36
+
37
+ setDebugAll(options.debugAll || false);
38
+ }
39
+
40
+ getCtxKey(sessionID, agentID) {
41
+ return sessionID + ':' + (agentID || '');
42
+ }
43
+
44
+ updateContext(item) {
45
+ const key = this.getCtxKey(item.sessionID, item.agentID);
46
+ let ctx = this.contextMap.get(key);
47
+ if (!ctx) {
48
+ ctx = { inputTokens: 0, outputTokens: 0, cacheCreation: 0, cacheRead: 0, model: '', contextWindow: 200000, lastActivity: Date.now() };
49
+ this.contextMap.set(key, ctx);
50
+ }
51
+ if (item.inputTokens) ctx.inputTokens += item.inputTokens;
52
+ if (item.outputTokens) ctx.outputTokens += item.outputTokens;
53
+ if (item.cacheCreationTokens) ctx.cacheCreation += item.cacheCreationTokens;
54
+ if (item.cacheReadTokens) ctx.cacheRead += item.cacheReadTokens;
55
+ if (item.model) {
56
+ ctx.model = item.model;
57
+ ctx.contextWindow = contextWindowFor(item.model);
58
+ }
59
+ ctx.lastActivity = Date.now();
60
+ }
61
+
62
+ getContextSnapshot() {
63
+ const result = {};
64
+ for (const [key, ctx] of this.contextMap) {
65
+ result[key] = {
66
+ inputTokens: ctx.inputTokens,
67
+ outputTokens: ctx.outputTokens,
68
+ cacheCreation: ctx.cacheCreation,
69
+ cacheRead: ctx.cacheRead,
70
+ model: ctx.model,
71
+ contextWindow: ctx.contextWindow,
72
+ lastActivity: ctx.lastActivity,
73
+ };
74
+ }
75
+ return result;
76
+ }
77
+
78
+ broadcast(type, payload) {
79
+ const msg = JSON.stringify({ type, payload });
80
+ for (const ws of this.clients) {
81
+ if (ws.readyState === 1) {
82
+ try { ws.send(msg); } catch {}
83
+ }
84
+ }
85
+ }
86
+
87
+ sendJSON(res, data, status = 200) {
88
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
89
+ res.end(JSON.stringify(data));
90
+ }
91
+
92
+ async serveStatic(res, filePath) {
93
+ const ext = path.extname(filePath).toLowerCase();
94
+ try {
95
+ const data = await fs.promises.readFile(filePath);
96
+ res.writeHead(200, {
97
+ 'Content-Type': MIME[ext] || 'application/octet-stream',
98
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
99
+ });
100
+ res.end(data);
101
+ } catch {
102
+ res.writeHead(404);
103
+ res.end('Not Found');
104
+ }
105
+ }
106
+
107
+ async handleHTTP(req, res) {
108
+ const url = new URL(req.url, `http://${req.headers.host}`);
109
+ const p = url.pathname;
110
+
111
+ if (p === '/' || p === '/index.html') {
112
+ await this.serveStatic(res, path.join(__dirname, '../../public/index.html'));
113
+ return;
114
+ }
115
+
116
+ if (p.startsWith('/api/')) {
117
+ this.handleAPI(req, res, url);
118
+ return;
119
+ }
120
+
121
+ // Prevent path traversal
122
+ const resolved = path.resolve(path.join(__dirname, '../../public', p));
123
+ if (!resolved.startsWith(path.resolve(path.join(__dirname, '../../public')))) {
124
+ res.writeHead(403);
125
+ res.end('Forbidden');
126
+ return;
127
+ }
128
+ await this.serveStatic(res, resolved);
129
+ }
130
+
131
+ handleAPI(req, res, url) {
132
+ const route = url.pathname.slice('/api'.length);
133
+ const params = url.searchParams;
134
+
135
+ if (route === '/sessions') {
136
+ listSessions(20).then(s => this.sendJSON(res, s)).catch(() => this.sendJSON(res, [], 500));
137
+ return;
138
+ }
139
+
140
+ if (route === '/sessions/active') {
141
+ const w = parseInt(params.get('window')) || 5 * 60 * 1000;
142
+ listActiveSessions(w).then(s => this.sendJSON(res, s)).catch(() => this.sendJSON(res, [], 500));
143
+ return;
144
+ }
145
+
146
+ if (route === '/status') {
147
+ this.sendJSON(res, {
148
+ sessions: this.watcher ? this.watcher.getSessionsSnapshot().map(s => ({
149
+ id: s.id,
150
+ projectPath: s.projectPath,
151
+ agentCount: Object.keys(s.subagents).length,
152
+ taskCount: Object.keys(s.backgroundTasks).length,
153
+ })) : [],
154
+ autoDiscovery: this.watcher ? this.watcher.isAutoDiscoveryEnabled() : true,
155
+ itemBufferSize: this.itemBuffer.length,
156
+ context: this.getContextSnapshot(),
157
+ });
158
+ return;
159
+ }
160
+
161
+ if (route === '/context') {
162
+ this.sendJSON(res, this.getContextSnapshot());
163
+ return;
164
+ }
165
+
166
+ if (route === '/task-output') {
167
+ const filePath = params.get('path');
168
+ if (!filePath) { this.sendJSON(res, { error: 'Missing path param' }, 400); return; }
169
+ try {
170
+ const resolved = path.resolve(filePath);
171
+ if (!resolved.startsWith(path.resolve(os.homedir(), '.claude', 'projects'))) {
172
+ this.sendJSON(res, { error: 'Access denied' }, 403);
173
+ return;
174
+ }
175
+ const content = fs.readFileSync(resolved, 'utf-8');
176
+ this.sendJSON(res, { content });
177
+ } catch (err) {
178
+ this.sendJSON(res, { error: err.message }, 404);
179
+ }
180
+ return;
181
+ }
182
+
183
+ this.sendJSON(res, { error: 'Not Found' }, 404);
184
+ }
185
+
186
+ onWsConnection(ws) {
187
+ this.clients.add(ws);
188
+
189
+ ws.on('message', (data) => {
190
+ try {
191
+ const cmd = JSON.parse(data.toString('utf-8'));
192
+ this.handleCommand(ws, cmd);
193
+ } catch {}
194
+ });
195
+
196
+ ws.on('close', () => {
197
+ this.clients.delete(ws);
198
+ });
199
+
200
+ ws.on('error', () => {});
201
+
202
+ this.sendSnapshot(ws);
203
+ this.sendItemBatch(ws);
204
+ this.sendContext(ws);
205
+ this.sendConfig(ws);
206
+ }
207
+
208
+ handleCommand(ws, cmd) {
209
+ if (!this.watcher) return;
210
+
211
+ switch (cmd.action) {
212
+ case 'toggleAutoDiscovery':
213
+ this.watcher.toggleAutoDiscovery();
214
+ this.broadcast('autoDiscoveryChanged', { enabled: this.watcher.isAutoDiscoveryEnabled() });
215
+ break;
216
+ case 'removeSession':
217
+ this.watcher.removeSession(cmd.sessionID);
218
+ this.broadcast('sessionRemoved', { sessionID: cmd.sessionID });
219
+ break;
220
+ case 'setSkipHistory':
221
+ this.watcher.setSkipHistory(cmd.skip);
222
+ break;
223
+ case 'getContext':
224
+ this.sendContext(ws);
225
+ break;
226
+ default:
227
+ break;
228
+ }
229
+ }
230
+
231
+ sendSnapshot(ws) {
232
+ if (!this.watcher) return;
233
+ const sessions = this.watcher.getSessionsSnapshot().map(s => ({
234
+ id: s.id,
235
+ projectPath: s.projectPath,
236
+ subagents: Object.entries(s.subagentTypes || s.subagents || {}).reduce((acc, [id, type]) => {
237
+ acc[id] = typeof type === 'string' ? type : '';
238
+ return acc;
239
+ }, {}),
240
+ backgroundTasks: Object.entries(s.backgroundTasks || {}).map(([id, t]) => ({
241
+ id,
242
+ parentAgentID: t.parentAgentID,
243
+ toolName: t.toolName,
244
+ outputPath: t.outputPath,
245
+ isComplete: t.isComplete,
246
+ })),
247
+ }));
248
+ try {
249
+ ws.send(JSON.stringify({
250
+ type: 'snapshot',
251
+ payload: {
252
+ sessions,
253
+ autoDiscovery: this.watcher.isAutoDiscoveryEnabled(),
254
+ },
255
+ }));
256
+ } catch {}
257
+ }
258
+
259
+ sendItemBatch(ws) {
260
+ try {
261
+ ws.send(JSON.stringify({ type: 'itemBatch', payload: this.itemBuffer }));
262
+ } catch {}
263
+ }
264
+
265
+ sendContext(ws) {
266
+ try {
267
+ ws.send(JSON.stringify({ type: 'context', payload: this.getContextSnapshot() }));
268
+ } catch {}
269
+ }
270
+
271
+ sendConfig(ws) {
272
+ try {
273
+ ws.send(JSON.stringify({ type: 'config', payload: { collapseAfter: this.collapseAfterMs } }));
274
+ } catch {}
275
+ }
276
+
277
+ setupWatcher(watcherOpts) {
278
+ const w = new Watcher(watcherOpts);
279
+ this.watcher = w;
280
+
281
+ w.on('sessionRemoved', ({ sessionID }) => {
282
+ for (const key of this.contextMap.keys()) {
283
+ if (key.startsWith(sessionID + ':')) this.contextMap.delete(key);
284
+ }
285
+ });
286
+
287
+ w.on('item', (item) => {
288
+ this.itemBuffer.push(item);
289
+ if (this.itemBuffer.length > MAX_ITEM_BUFFER) {
290
+ const excess = this.itemBuffer.length - MAX_ITEM_BUFFER;
291
+ this.itemBuffer.copyWithin(0, excess);
292
+ this.itemBuffer.length = MAX_ITEM_BUFFER;
293
+ }
294
+ this.updateContext(item);
295
+ this.broadcast('item', item);
296
+ });
297
+ w.on('broadcast', (type, payload) => {
298
+ this.broadcast(type, payload);
299
+ });
300
+
301
+ return w;
302
+ }
303
+
304
+ start(options = {}) {
305
+ const skipHistory = options.skipHistory || false;
306
+ const pollMs = options.pollMs || 500;
307
+ const activeWindow = options.activeWindow || 5 * 60 * 1000;
308
+ const maxSessions = options.maxSessions || 0;
309
+
310
+ const watcherOpts = {
311
+ sessionID: options.sessionID || '',
312
+ pollInterval: pollMs,
313
+ activeWindow,
314
+ maxSessions,
315
+ };
316
+
317
+ this.server = http.createServer((req, res) => {
318
+ this.handleHTTP(req, res).catch(() => {
319
+ if (!res.headersSent) {
320
+ res.writeHead(500);
321
+ res.end('Internal Server Error');
322
+ }
323
+ });
324
+ });
325
+
326
+ this.wss = new WebSocketServer({ server: this.server });
327
+ this.wss.on('connection', (ws) => this.onWsConnection(ws));
328
+
329
+ const w = this.setupWatcher(watcherOpts);
330
+
331
+ w.init().then(() => {
332
+ if (skipHistory) w.setSkipHistory(true);
333
+ w.start();
334
+
335
+ // Open browser AFTER sessions are discovered, so new clients get a full snapshot
336
+ const url = `http://localhost:${this.port}`;
337
+ const platform = process.platform;
338
+ if (platform === 'darwin') {
339
+ cp.spawn('open', [url]);
340
+ } else if (platform === 'win32') {
341
+ cp.spawn('cmd', ['/c', 'start', '', url]);
342
+ } else {
343
+ cp.spawn('xdg-open', [url]);
344
+ }
345
+ }).catch(err => {
346
+ console.error('Watcher init error:', err.message);
347
+ process.exit(1);
348
+ });
349
+
350
+ this.server.listen(this.port, this.host, () => {
351
+ const url = `http://localhost:${this.port}`;
352
+ console.log(`\n claude-watch web server`);
353
+ console.log(` ───────────────────────────`);
354
+ console.log(` Local: ${url}`);
355
+ console.log(` Network: http://${this.host}:${this.port}`);
356
+ console.log(` Quit: Ctrl+C\n`);
357
+ });
358
+
359
+ return { server: this.server, watcher: w };
360
+ }
361
+
362
+ stop() {
363
+ if (this.wss) this.wss.close();
364
+ if (this.server) this.server.close();
365
+ if (this.watcher) this.watcher.stop();
366
+ this.clients.clear();
367
+ }
368
+ }
369
+
370
+ function startServer(options = {}) {
371
+ const ds = new DashboardServer(options);
372
+ return ds.start(options);
373
+ }
374
+
375
+ module.exports = { DashboardServer, startServer };