vibe-monitor 1.0.0

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.
package/main.js ADDED
@@ -0,0 +1,504 @@
1
+ const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, screen } = require('electron');
2
+ const path = require('path');
3
+ const http = require('http');
4
+ const { createCanvas } = require('canvas');
5
+
6
+ let mainWindow;
7
+ let tray;
8
+ let isAlwaysOnTop = true;
9
+ let currentState = 'start';
10
+ let currentCharacter = 'clawd'; // 'clawd' or 'kiro'
11
+ let currentProject = '';
12
+ let currentTool = '';
13
+ let currentModel = '';
14
+ let currentMemory = '';
15
+
16
+ // State timeout management
17
+ let stateTimeoutTimer = null;
18
+ const DONE_TO_IDLE_TIMEOUT = 60 * 1000; // 1 minute
19
+ const IDLE_TO_SLEEP_TIMEOUT = 10 * 60 * 1000; // 10 minutes
20
+
21
+ // HTTP server for receiving status updates
22
+ let httpServer;
23
+ const HTTP_PORT = 19280;
24
+ const MAX_PAYLOAD_SIZE = 10 * 1024; // 10KB limit for security
25
+
26
+ // Valid states for validation
27
+ const VALID_STATES = ['start', 'idle', 'thinking', 'working', 'notification', 'done', 'sleep'];
28
+
29
+ // State colors for tray icon (must match shared/config.js states.bgColor)
30
+ // NOTE: Cannot import ESM from CommonJS, keep in sync manually
31
+ const STATE_COLORS = {
32
+ start: '#00CCCC',
33
+ idle: '#00AA00',
34
+ thinking: '#6633CC',
35
+ working: '#0066CC',
36
+ notification: '#FFCC00',
37
+ done: '#00AA00',
38
+ sleep: '#1a1a4e'
39
+ };
40
+
41
+ // Character configurations for tray icon (must match shared/config.js CHARACTER_CONFIG)
42
+ // NOTE: Cannot import ESM from CommonJS, keep in sync manually
43
+ const CHARACTER_CONFIG = {
44
+ clawd: {
45
+ name: 'clawd',
46
+ displayName: 'Clawd',
47
+ color: '#D97757',
48
+ isGhost: false
49
+ },
50
+ kiro: {
51
+ name: 'kiro',
52
+ displayName: 'Kiro',
53
+ color: '#FFFFFF',
54
+ isGhost: true
55
+ }
56
+ };
57
+
58
+ const CHARACTER_NAMES = Object.keys(CHARACTER_CONFIG);
59
+ const DEFAULT_CHARACTER = 'clawd';
60
+ const COLOR_EYE = '#000000';
61
+
62
+ // Tray icon cache for performance
63
+ const trayIconCache = new Map();
64
+
65
+ // Create tray icon with state-based background color using canvas for proper PNG encoding
66
+ function createTrayIcon(state, character = 'clawd') {
67
+ const cacheKey = `${state}-${character}`;
68
+
69
+ // Return cached icon if available
70
+ if (trayIconCache.has(cacheKey)) {
71
+ return trayIconCache.get(cacheKey);
72
+ }
73
+
74
+ const size = 22;
75
+ const canvas = createCanvas(size, size);
76
+ const ctx = canvas.getContext('2d');
77
+
78
+ const bgColor = STATE_COLORS[state] || STATE_COLORS.idle;
79
+ const charConfig = CHARACTER_CONFIG[character] || CHARACTER_CONFIG[DEFAULT_CHARACTER];
80
+ const charColor = charConfig.color;
81
+ const isGhost = charConfig.isGhost;
82
+
83
+ // Helper to draw filled rectangle
84
+ function rect(x, y, w, h, color) {
85
+ ctx.fillStyle = color;
86
+ ctx.fillRect(x, y, w, h);
87
+ }
88
+
89
+ // Clear canvas (transparent)
90
+ ctx.clearRect(0, 0, size, size);
91
+
92
+ // Draw rounded background
93
+ const radius = 4;
94
+ ctx.fillStyle = bgColor;
95
+ ctx.beginPath();
96
+ ctx.roundRect(0, 0, size, size, radius);
97
+ ctx.fill();
98
+
99
+ if (isGhost) {
100
+ // Draw ghost character for kiro
101
+ // Body (rounded top effect)
102
+ rect(6, 4, 10, 2, charColor); // Rounded top
103
+ rect(5, 6, 12, 8, charColor); // Main body
104
+
105
+ // Wavy tail (instead of legs)
106
+ rect(5, 14, 4, 3, charColor); // Left wave
107
+ rect(9, 15, 4, 2, charColor); // Middle wave
108
+ rect(13, 14, 4, 3, charColor); // Right wave
109
+
110
+ // Eyes (2x2 each)
111
+ rect(7, 8, 2, 2, COLOR_EYE); // Left eye
112
+ rect(13, 8, 2, 2, COLOR_EYE); // Right eye
113
+ } else {
114
+ // Draw clawd character (default)
115
+ // Body (centered, 14x8)
116
+ rect(4, 6, 14, 8, charColor);
117
+
118
+ // Arms (2x3 each)
119
+ rect(2, 8, 2, 3, charColor); // Left arm
120
+ rect(18, 8, 2, 3, charColor); // Right arm
121
+
122
+ // Legs (2x4 each, 4 legs)
123
+ rect(5, 14, 2, 4, charColor); // Left outer
124
+ rect(8, 14, 2, 4, charColor); // Left inner
125
+ rect(12, 14, 2, 4, charColor); // Right inner
126
+ rect(15, 14, 2, 4, charColor); // Right outer
127
+
128
+ // Eyes (2x2 each)
129
+ rect(6, 9, 2, 2, COLOR_EYE); // Left eye
130
+ rect(14, 9, 2, 2, COLOR_EYE); // Right eye
131
+ }
132
+
133
+ // Convert canvas to PNG buffer and create nativeImage
134
+ const pngBuffer = canvas.toBuffer('image/png');
135
+ const icon = nativeImage.createFromBuffer(pngBuffer);
136
+
137
+ // Cache the icon for future use
138
+ trayIconCache.set(cacheKey, icon);
139
+
140
+ return icon;
141
+ }
142
+
143
+ function createWindow() {
144
+ const { workArea } = screen.getPrimaryDisplay();
145
+
146
+ mainWindow = new BrowserWindow({
147
+ width: 172,
148
+ height: 348,
149
+ x: workArea.x + workArea.width - 172,
150
+ y: workArea.y,
151
+ frame: false,
152
+ transparent: true,
153
+ alwaysOnTop: isAlwaysOnTop,
154
+ resizable: false,
155
+ skipTaskbar: false,
156
+ hasShadow: true,
157
+ show: false,
158
+ icon: path.join(__dirname, 'assets', 'icon.png'),
159
+ webPreferences: {
160
+ preload: path.join(__dirname, 'preload.js'),
161
+ contextIsolation: true,
162
+ nodeIntegration: false
163
+ }
164
+ });
165
+
166
+ mainWindow.loadFile('index.html');
167
+
168
+ // Allow window to be dragged
169
+ mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
170
+
171
+ // Show window without stealing focus
172
+ mainWindow.once('ready-to-show', () => {
173
+ // Set alwaysOnTop with level for better Windows support
174
+ if (isAlwaysOnTop) {
175
+ mainWindow.setAlwaysOnTop(true, 'screen-saver');
176
+ }
177
+ mainWindow.showInactive();
178
+ });
179
+
180
+ mainWindow.on('closed', () => {
181
+ mainWindow = null;
182
+ });
183
+ }
184
+
185
+ function createTray() {
186
+ const icon = createTrayIcon(currentState, currentCharacter);
187
+ tray = new Tray(icon);
188
+ tray.setToolTip('Vibe Monitor');
189
+ updateTrayMenu();
190
+ }
191
+
192
+ function updateTrayIcon() {
193
+ if (tray) {
194
+ const icon = createTrayIcon(currentState, currentCharacter);
195
+ tray.setImage(icon);
196
+ }
197
+ }
198
+
199
+ function updateTrayMenu() {
200
+ const contextMenu = Menu.buildFromTemplate([
201
+ {
202
+ label: `State: ${currentState}`,
203
+ enabled: false
204
+ },
205
+ {
206
+ label: `Character: ${currentCharacter}`,
207
+ enabled: false
208
+ },
209
+ { type: 'separator' },
210
+ {
211
+ label: 'Set State',
212
+ submenu: [
213
+ { label: 'Start', click: () => updateState({ state: 'start' }) },
214
+ { label: 'Idle', click: () => updateState({ state: 'idle' }) },
215
+ { label: 'Working', click: () => updateState({ state: 'working' }) },
216
+ { label: 'Notification', click: () => updateState({ state: 'notification' }) },
217
+ { label: 'Done', click: () => updateState({ state: 'done' }) },
218
+ { label: 'Sleep', click: () => updateState({ state: 'sleep' }) }
219
+ ]
220
+ },
221
+ {
222
+ label: 'Set Character',
223
+ submenu: CHARACTER_NAMES.map(name => {
224
+ const char = CHARACTER_CONFIG[name];
225
+ return {
226
+ label: char.displayName,
227
+ type: 'radio',
228
+ checked: currentCharacter === name,
229
+ click: () => updateState({ state: currentState, character: name })
230
+ };
231
+ })
232
+ },
233
+ { type: 'separator' },
234
+ {
235
+ label: 'Always on Top',
236
+ type: 'checkbox',
237
+ checked: isAlwaysOnTop,
238
+ click: () => {
239
+ isAlwaysOnTop = !isAlwaysOnTop;
240
+ if (mainWindow) {
241
+ mainWindow.setAlwaysOnTop(isAlwaysOnTop, 'screen-saver');
242
+ }
243
+ }
244
+ },
245
+ {
246
+ label: 'Show Window',
247
+ click: () => {
248
+ if (mainWindow) {
249
+ mainWindow.show();
250
+ mainWindow.focus();
251
+ }
252
+ }
253
+ },
254
+ { type: 'separator' },
255
+ {
256
+ label: `HTTP Server: localhost:${HTTP_PORT}`,
257
+ enabled: false
258
+ },
259
+ { type: 'separator' },
260
+ {
261
+ label: 'Quit',
262
+ click: () => {
263
+ app.quit();
264
+ }
265
+ }
266
+ ]);
267
+
268
+ tray.setContextMenu(contextMenu);
269
+ }
270
+
271
+ // Clear any existing state timeout timer
272
+ function clearStateTimeout() {
273
+ if (stateTimeoutTimer) {
274
+ clearTimeout(stateTimeoutTimer);
275
+ stateTimeoutTimer = null;
276
+ }
277
+ }
278
+
279
+ // Set up state timeout based on current state
280
+ function setupStateTimeout() {
281
+ clearStateTimeout();
282
+
283
+ if (currentState === 'done') {
284
+ // done -> idle after 1 minute
285
+ stateTimeoutTimer = setTimeout(() => {
286
+ updateState({ state: 'idle' });
287
+ }, DONE_TO_IDLE_TIMEOUT);
288
+ } else if (currentState === 'idle' || currentState === 'start') {
289
+ // idle/start -> sleep after 10 minutes
290
+ stateTimeoutTimer = setTimeout(() => {
291
+ updateState({ state: 'sleep' });
292
+ }, IDLE_TO_SLEEP_TIMEOUT);
293
+ }
294
+ }
295
+
296
+ function updateState(data) {
297
+ // If state is provided (from vibe-monitor.sh), update all fields
298
+ if (data.state !== undefined) {
299
+ currentState = data.state;
300
+ if (data.character !== undefined) {
301
+ currentCharacter = CHARACTER_CONFIG[data.character] ? data.character : DEFAULT_CHARACTER;
302
+ }
303
+ // Clear model and memory when project changes
304
+ if (data.project !== undefined && data.project !== currentProject) {
305
+ currentModel = '';
306
+ currentMemory = '';
307
+ data.model = '';
308
+ data.memory = '';
309
+ }
310
+ if (data.project !== undefined) currentProject = data.project;
311
+ if (data.tool !== undefined) currentTool = data.tool;
312
+ if (data.model !== undefined) currentModel = data.model;
313
+ if (data.memory !== undefined) currentMemory = data.memory;
314
+
315
+ // Set up state timeout for auto-transitions
316
+ setupStateTimeout();
317
+ } else {
318
+ // If no state (from statusline.sh), only update model/memory if project matches
319
+ if (data.project !== undefined && data.project === currentProject) {
320
+ if (data.model !== undefined) currentModel = data.model;
321
+ if (data.memory !== undefined) currentMemory = data.memory;
322
+ }
323
+ }
324
+ if (mainWindow) {
325
+ mainWindow.webContents.send('state-update', data);
326
+ }
327
+ updateTrayIcon();
328
+ updateTrayMenu();
329
+ }
330
+
331
+ // Show window and position to top-right corner
332
+ function showAndPositionWindow() {
333
+ if (mainWindow) {
334
+ const { workArea } = screen.getPrimaryDisplay();
335
+ const x = workArea.x + workArea.width - 172;
336
+ const y = workArea.y;
337
+ // Use setBounds for better Windows compatibility
338
+ mainWindow.setBounds({ x, y, width: 172, height: 348 });
339
+ mainWindow.showInactive();
340
+ return true;
341
+ }
342
+ return false;
343
+ }
344
+
345
+ function startHttpServer() {
346
+ httpServer = http.createServer((req, res) => {
347
+ // CORS headers
348
+ res.setHeader('Access-Control-Allow-Origin', '*');
349
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
350
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
351
+
352
+ if (req.method === 'OPTIONS') {
353
+ res.writeHead(200);
354
+ res.end();
355
+ return;
356
+ }
357
+
358
+ if (req.method === 'POST' && req.url === '/status') {
359
+ const chunks = [];
360
+ let bodySize = 0;
361
+ let aborted = false;
362
+
363
+ req.on('data', chunk => {
364
+ if (aborted) return;
365
+ bodySize += chunk.length;
366
+ if (bodySize > MAX_PAYLOAD_SIZE) {
367
+ aborted = true;
368
+ res.writeHead(413, { 'Content-Type': 'application/json' });
369
+ res.end(JSON.stringify({ error: 'Payload too large' }));
370
+ req.destroy();
371
+ return;
372
+ }
373
+ chunks.push(chunk);
374
+ });
375
+
376
+ req.on('end', () => {
377
+ if (aborted) return;
378
+ try {
379
+ const body = Buffer.concat(chunks).toString('utf-8');
380
+ const data = JSON.parse(body);
381
+
382
+ // Validate state if provided
383
+ if (data.state !== undefined && !VALID_STATES.includes(data.state)) {
384
+ res.writeHead(400, { 'Content-Type': 'application/json' });
385
+ res.end(JSON.stringify({ error: `Invalid state: ${data.state}` }));
386
+ return;
387
+ }
388
+
389
+ // Validate character if provided
390
+ if (data.character !== undefined && !CHARACTER_NAMES.includes(data.character)) {
391
+ res.writeHead(400, { 'Content-Type': 'application/json' });
392
+ res.end(JSON.stringify({ error: `Invalid character: ${data.character}` }));
393
+ return;
394
+ }
395
+
396
+ updateState(data);
397
+ res.writeHead(200, { 'Content-Type': 'application/json' });
398
+ res.end(JSON.stringify({ success: true, state: currentState }));
399
+ } catch (e) {
400
+ res.writeHead(400, { 'Content-Type': 'application/json' });
401
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
402
+ }
403
+ });
404
+
405
+ req.on('error', () => {
406
+ if (!aborted) {
407
+ res.writeHead(500, { 'Content-Type': 'application/json' });
408
+ res.end(JSON.stringify({ error: 'Request error' }));
409
+ }
410
+ });
411
+ } else if (req.method === 'GET' && req.url === '/status') {
412
+ res.writeHead(200, { 'Content-Type': 'application/json' });
413
+ res.end(JSON.stringify({
414
+ state: currentState,
415
+ project: currentProject,
416
+ tool: currentTool,
417
+ model: currentModel,
418
+ memory: currentMemory
419
+ }));
420
+ } else if (req.method === 'GET' && req.url === '/health') {
421
+ res.writeHead(200, { 'Content-Type': 'application/json' });
422
+ res.end(JSON.stringify({ status: 'ok' }));
423
+ } else if (req.method === 'POST' && req.url === '/show') {
424
+ const shown = showAndPositionWindow();
425
+ res.writeHead(200, { 'Content-Type': 'application/json' });
426
+ res.end(JSON.stringify({ success: shown }));
427
+ } else if (req.method === 'GET' && req.url === '/debug') {
428
+ const displays = screen.getAllDisplays();
429
+ const primary = screen.getPrimaryDisplay();
430
+ const windowBounds = mainWindow ? mainWindow.getBounds() : null;
431
+ res.writeHead(200, { 'Content-Type': 'application/json' });
432
+ res.end(JSON.stringify({
433
+ primaryDisplay: {
434
+ bounds: primary.bounds,
435
+ workArea: primary.workArea,
436
+ workAreaSize: primary.workAreaSize,
437
+ scaleFactor: primary.scaleFactor
438
+ },
439
+ allDisplays: displays.map(d => ({
440
+ id: d.id,
441
+ bounds: d.bounds,
442
+ workArea: d.workArea,
443
+ scaleFactor: d.scaleFactor
444
+ })),
445
+ window: windowBounds,
446
+ platform: process.platform
447
+ }, null, 2));
448
+ } else {
449
+ res.writeHead(404);
450
+ res.end('Not Found');
451
+ }
452
+ });
453
+
454
+ httpServer.on('error', (err) => {
455
+ console.error('HTTP Server error:', err.message);
456
+ if (err.code === 'EADDRINUSE') {
457
+ console.error(`Port ${HTTP_PORT} is already in use`);
458
+ }
459
+ });
460
+
461
+ httpServer.listen(HTTP_PORT, '127.0.0.1', () => {
462
+ console.log(`Vibe Monitor HTTP server running on http://127.0.0.1:${HTTP_PORT}`);
463
+ });
464
+ }
465
+
466
+ // IPC handlers
467
+ ipcMain.on('close-window', () => {
468
+ if (mainWindow) {
469
+ mainWindow.hide();
470
+ }
471
+ });
472
+
473
+ ipcMain.on('minimize-window', () => {
474
+ if (mainWindow) {
475
+ mainWindow.minimize();
476
+ }
477
+ });
478
+
479
+ app.whenReady().then(() => {
480
+ createWindow();
481
+ createTray();
482
+ startHttpServer();
483
+ setupStateTimeout(); // Start timeout timer for initial state
484
+
485
+ app.on('activate', () => {
486
+ if (BrowserWindow.getAllWindows().length === 0) {
487
+ createWindow();
488
+ }
489
+ });
490
+ });
491
+
492
+ app.on('window-all-closed', () => {
493
+ // Keep app running in tray on macOS
494
+ if (process.platform !== 'darwin') {
495
+ app.quit();
496
+ }
497
+ });
498
+
499
+ app.on('before-quit', () => {
500
+ clearStateTimeout();
501
+ if (httpServer) {
502
+ httpServer.close();
503
+ }
504
+ });
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "vibe-monitor",
3
+ "version": "1.0.0",
4
+ "description": "Claude Code Status Monitor - Frameless Desktop App",
5
+ "main": "main.js",
6
+ "bin": {
7
+ "vibe-monitor": "./bin/cli.js"
8
+ },
9
+ "scripts": {
10
+ "start": "electron .",
11
+ "build": "electron-builder",
12
+ "build:mac": "electron-builder --mac",
13
+ "build:dmg": "electron-builder --mac dmg",
14
+ "build:win": "electron-builder --win",
15
+ "build:linux": "electron-builder --linux",
16
+ "build:all": "electron-builder --mac --win --linux"
17
+ },
18
+ "author": "nalbam",
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "electron": "^33.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "canvas": "^3.2.1",
25
+ "electron-builder": "^25.0.0"
26
+ },
27
+ "files": [
28
+ "bin/",
29
+ "main.js",
30
+ "preload.js",
31
+ "index.html",
32
+ "renderer.js",
33
+ "styles.css",
34
+ "assets/"
35
+ ],
36
+ "build": {
37
+ "appId": "com.nalbam.vibe-monitor",
38
+ "productName": "Vibe Monitor",
39
+ "mac": {
40
+ "category": "public.app-category.developer-tools",
41
+ "icon": "assets/icon.icns",
42
+ "target": [
43
+ "dmg",
44
+ "zip"
45
+ ]
46
+ },
47
+ "win": {
48
+ "icon": "assets/icon.ico",
49
+ "target": [
50
+ {
51
+ "target": "nsis",
52
+ "arch": [
53
+ "x64",
54
+ "arm64"
55
+ ]
56
+ },
57
+ {
58
+ "target": "portable",
59
+ "arch": [
60
+ "x64"
61
+ ]
62
+ }
63
+ ]
64
+ },
65
+ "nsis": {
66
+ "oneClick": false,
67
+ "perMachine": false,
68
+ "allowToChangeInstallationDirectory": true,
69
+ "createDesktopShortcut": true,
70
+ "createStartMenuShortcut": true
71
+ },
72
+ "linux": {
73
+ "icon": "assets/icon.png",
74
+ "target": [
75
+ "AppImage",
76
+ "deb"
77
+ ],
78
+ "category": "Development"
79
+ }
80
+ }
81
+ }
package/preload.js ADDED
@@ -0,0 +1,9 @@
1
+ const { contextBridge, ipcRenderer } = require('electron');
2
+
3
+ contextBridge.exposeInMainWorld('electronAPI', {
4
+ closeWindow: () => ipcRenderer.send('close-window'),
5
+ minimizeWindow: () => ipcRenderer.send('minimize-window'),
6
+ onStateUpdate: (callback) => {
7
+ ipcRenderer.on('state-update', (event, data) => callback(data));
8
+ }
9
+ });