vibemon 1.5.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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Input validation functions for the Vibe Monitor
3
+ */
4
+
5
+ const { VALID_STATES, CHARACTER_NAMES } = require('../shared/config.cjs');
6
+
7
+ // Validation limits
8
+ const PROJECT_MAX_LENGTH = 100;
9
+ const TOOL_MAX_LENGTH = 50;
10
+ const MODEL_MAX_LENGTH = 50;
11
+ const TERMINAL_ID_MAX_LENGTH = 100;
12
+ // Memory is now a number (0-100), not a string
13
+ // iTerm2: w0t0p0:UUID format, Ghostty: numeric PID
14
+ const ITERM2_SESSION_PATTERN = /^w\d+t\d+p\d+:[0-9A-Fa-f-]{36}$/;
15
+ const GHOSTTY_PID_PATTERN = /^\d{1,10}$/;
16
+
17
+ /**
18
+ * Validate state value
19
+ * @param {string} state
20
+ * @returns {{valid: boolean, error: string|null}}
21
+ */
22
+ function validateState(state) {
23
+ if (state === undefined) {
24
+ return { valid: true, error: null };
25
+ }
26
+ if (!VALID_STATES.includes(state)) {
27
+ return { valid: false, error: `Invalid state: ${state}. Valid states: ${VALID_STATES.join(', ')}` };
28
+ }
29
+ return { valid: true, error: null };
30
+ }
31
+
32
+ /**
33
+ * Validate character value
34
+ * @param {string} character
35
+ * @returns {{valid: boolean, error: string|null}}
36
+ */
37
+ function validateCharacter(character) {
38
+ if (character === undefined) {
39
+ return { valid: true, error: null };
40
+ }
41
+ if (!CHARACTER_NAMES.includes(character)) {
42
+ return { valid: false, error: `Invalid character: ${character}. Valid characters: ${CHARACTER_NAMES.join(', ')}` };
43
+ }
44
+ return { valid: true, error: null };
45
+ }
46
+
47
+ /**
48
+ * Validate project name
49
+ * @param {string} project
50
+ * @returns {{valid: boolean, error: string|null}}
51
+ */
52
+ function validateProject(project) {
53
+ if (project === undefined) {
54
+ return { valid: true, error: null };
55
+ }
56
+ if (typeof project !== 'string') {
57
+ return { valid: false, error: 'Project must be a string' };
58
+ }
59
+ if (project.length > PROJECT_MAX_LENGTH) {
60
+ return { valid: false, error: `Project name exceeds ${PROJECT_MAX_LENGTH} characters` };
61
+ }
62
+ return { valid: true, error: null };
63
+ }
64
+
65
+ /**
66
+ * Validate memory value (number 0-100)
67
+ * @param {number} memory
68
+ * @returns {{valid: boolean, error: string|null}}
69
+ */
70
+ function validateMemory(memory) {
71
+ if (memory === undefined || memory === null || memory === '') {
72
+ return { valid: true, error: null };
73
+ }
74
+ if (typeof memory !== 'number') {
75
+ return { valid: false, error: 'Memory must be a number' };
76
+ }
77
+ if (!Number.isInteger(memory) || memory < 0 || memory > 100) {
78
+ return { valid: false, error: 'Memory must be an integer between 0 and 100' };
79
+ }
80
+ return { valid: true, error: null };
81
+ }
82
+
83
+ /**
84
+ * Validate tool name
85
+ * @param {string} tool
86
+ * @returns {{valid: boolean, error: string|null}}
87
+ */
88
+ function validateTool(tool) {
89
+ if (tool === undefined || tool === '') {
90
+ return { valid: true, error: null };
91
+ }
92
+ if (typeof tool !== 'string') {
93
+ return { valid: false, error: 'Tool must be a string' };
94
+ }
95
+ if (tool.length > TOOL_MAX_LENGTH) {
96
+ return { valid: false, error: `Tool name exceeds ${TOOL_MAX_LENGTH} characters` };
97
+ }
98
+ return { valid: true, error: null };
99
+ }
100
+
101
+ /**
102
+ * Validate model name
103
+ * @param {string} model
104
+ * @returns {{valid: boolean, error: string|null}}
105
+ */
106
+ function validateModel(model) {
107
+ if (model === undefined || model === '') {
108
+ return { valid: true, error: null };
109
+ }
110
+ if (typeof model !== 'string') {
111
+ return { valid: false, error: 'Model must be a string' };
112
+ }
113
+ if (model.length > MODEL_MAX_LENGTH) {
114
+ return { valid: false, error: `Model name exceeds ${MODEL_MAX_LENGTH} characters` };
115
+ }
116
+ return { valid: true, error: null };
117
+ }
118
+
119
+ /**
120
+ * Validate terminal ID (iTerm2 session or Ghostty PID)
121
+ * @param {string} terminalId
122
+ * @returns {{valid: boolean, error: string|null}}
123
+ */
124
+ function validateTerminalId(terminalId) {
125
+ if (terminalId === undefined || terminalId === null || terminalId === '') {
126
+ return { valid: true, error: null };
127
+ }
128
+ if (typeof terminalId !== 'string') {
129
+ return { valid: false, error: 'terminalId must be a string' };
130
+ }
131
+ if (terminalId.length > TERMINAL_ID_MAX_LENGTH) {
132
+ return { valid: false, error: `terminalId exceeds ${TERMINAL_ID_MAX_LENGTH} characters` };
133
+ }
134
+ // Accept iTerm2 session format or Ghostty PID format
135
+ if (!ITERM2_SESSION_PATTERN.test(terminalId) && !GHOSTTY_PID_PATTERN.test(terminalId)) {
136
+ return { valid: false, error: 'terminalId must be a valid iTerm2 session ID or Ghostty PID' };
137
+ }
138
+ return { valid: true, error: null };
139
+ }
140
+
141
+ /**
142
+ * Validate status payload
143
+ * @param {object} data
144
+ * @returns {{valid: boolean, error: string|null}}
145
+ */
146
+ function validateStatusPayload(data) {
147
+ const stateResult = validateState(data.state);
148
+ if (!stateResult.valid) return stateResult;
149
+
150
+ const characterResult = validateCharacter(data.character);
151
+ if (!characterResult.valid) return characterResult;
152
+
153
+ const projectResult = validateProject(data.project);
154
+ if (!projectResult.valid) return projectResult;
155
+
156
+ const memoryResult = validateMemory(data.memory);
157
+ if (!memoryResult.valid) return memoryResult;
158
+
159
+ const toolResult = validateTool(data.tool);
160
+ if (!toolResult.valid) return toolResult;
161
+
162
+ const modelResult = validateModel(data.model);
163
+ if (!modelResult.valid) return modelResult;
164
+
165
+ const terminalIdResult = validateTerminalId(data.terminalId);
166
+ if (!terminalIdResult.valid) return terminalIdResult;
167
+
168
+ return { valid: true, error: null };
169
+ }
170
+
171
+ module.exports = {
172
+ validateState,
173
+ validateCharacter,
174
+ validateProject,
175
+ validateMemory,
176
+ validateTool,
177
+ validateModel,
178
+ validateTerminalId,
179
+ validateStatusPayload
180
+ };
@@ -0,0 +1,313 @@
1
+ /**
2
+ * WebSocket client for Vibe Monitor
3
+ * Connects to a central server to receive real-time status updates
4
+ */
5
+
6
+ const WebSocket = require('ws');
7
+ const Store = require('electron-store');
8
+ const { WS_URL, WS_TOKEN } = require('../shared/config.cjs');
9
+
10
+ // Reconnection configuration
11
+ const RECONNECT_INITIAL_DELAY = 5000; // 5 seconds
12
+ const RECONNECT_MAX_DELAY = 30000; // 30 seconds
13
+ const RECONNECT_MULTIPLIER = 1.5;
14
+
15
+ class WsClient {
16
+ constructor() {
17
+ this.ws = null;
18
+ this.url = WS_URL;
19
+ this.reconnectDelay = RECONNECT_INITIAL_DELAY;
20
+ this.reconnectTimer = null;
21
+ this.isConnecting = false;
22
+ this.isConnected = false;
23
+ this.shouldReconnect = true;
24
+
25
+ // Persistent storage for token
26
+ this.store = new Store({
27
+ name: 'ws-settings',
28
+ defaults: {
29
+ token: null
30
+ }
31
+ });
32
+
33
+ // Load token: stored value > environment variable
34
+ const storedToken = this.store.get('token');
35
+ this.token = storedToken || WS_TOKEN || null;
36
+
37
+ // Callbacks
38
+ this.onStatusUpdate = null; // Called when status message received
39
+ this.onConnectionChange = null; // Called when connection state changes
40
+ }
41
+
42
+ /**
43
+ * Get current token
44
+ * @returns {string|null}
45
+ */
46
+ getToken() {
47
+ return this.token;
48
+ }
49
+
50
+ /**
51
+ * Set token and save to store
52
+ * @param {string|null} token
53
+ */
54
+ setToken(token) {
55
+ this.token = token || null;
56
+ this.store.set('token', this.token);
57
+
58
+ // Reconnect with new token (token is passed via URL query parameter)
59
+ if (this.isConnected || this.isConnecting) {
60
+ this.reconnect();
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Reconnect to WebSocket server
66
+ */
67
+ reconnect() {
68
+ // Close current connection
69
+ if (this.ws) {
70
+ this.ws.close();
71
+ this.ws = null;
72
+ }
73
+ this.isConnecting = false;
74
+ this.isConnected = false;
75
+
76
+ // Clear any pending reconnect timer
77
+ if (this.reconnectTimer) {
78
+ clearTimeout(this.reconnectTimer);
79
+ this.reconnectTimer = null;
80
+ }
81
+
82
+ // Reconnect immediately
83
+ this.reconnectDelay = RECONNECT_INITIAL_DELAY;
84
+ this.connect();
85
+ }
86
+
87
+ /**
88
+ * Clear token from store
89
+ */
90
+ clearToken() {
91
+ this.token = null;
92
+ this.store.delete('token');
93
+ }
94
+
95
+ /**
96
+ * Check if WebSocket is configured
97
+ * @returns {boolean}
98
+ */
99
+ isConfigured() {
100
+ return Boolean(this.url);
101
+ }
102
+
103
+ /**
104
+ * Get connection status
105
+ * @returns {string} 'connected', 'connecting', 'disconnected', or 'not-configured'
106
+ */
107
+ getStatus() {
108
+ if (!this.isConfigured()) {
109
+ return 'not-configured';
110
+ }
111
+ if (this.isConnected) {
112
+ return 'connected';
113
+ }
114
+ if (this.isConnecting) {
115
+ return 'connecting';
116
+ }
117
+ return 'disconnected';
118
+ }
119
+
120
+ /**
121
+ * Build connection URL with token as query parameter (like ESP32)
122
+ * @returns {string}
123
+ */
124
+ buildConnectionUrl() {
125
+ if (!this.token) {
126
+ return this.url;
127
+ }
128
+
129
+ // Add token as query parameter (same as ESP32: /?token=xxx)
130
+ const separator = this.url.includes('?') ? '&' : '?';
131
+ return `${this.url}${separator}token=${encodeURIComponent(this.token)}`;
132
+ }
133
+
134
+ /**
135
+ * Start WebSocket connection
136
+ */
137
+ connect() {
138
+ if (!this.isConfigured()) {
139
+ console.log('WebSocket not configured (VIBEMON_WS_URL not set)');
140
+ return;
141
+ }
142
+
143
+ if (this.isConnecting || this.isConnected) {
144
+ return;
145
+ }
146
+
147
+ this.isConnecting = true;
148
+ this.notifyConnectionChange();
149
+
150
+ const connectionUrl = this.buildConnectionUrl();
151
+ console.log(`WebSocket connecting to ${this.url}...`);
152
+
153
+ try {
154
+ this.ws = new WebSocket(connectionUrl);
155
+
156
+ this.ws.on('open', () => {
157
+ console.log('WebSocket connected');
158
+ this.isConnecting = false;
159
+ this.isConnected = true;
160
+ this.reconnectDelay = RECONNECT_INITIAL_DELAY;
161
+ this.notifyConnectionChange();
162
+ });
163
+
164
+ this.ws.on('message', (data) => {
165
+ this.handleMessage(data);
166
+ });
167
+
168
+ this.ws.on('close', (code, reason) => {
169
+ console.log(`WebSocket closed: ${code} ${reason}`);
170
+ this.handleDisconnect();
171
+ });
172
+
173
+ this.ws.on('error', (error) => {
174
+ console.error('WebSocket error:', error.message);
175
+ // Error will be followed by close event
176
+ });
177
+ } catch (error) {
178
+ console.error('WebSocket connection error:', error.message);
179
+ this.handleDisconnect();
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Send authentication message
185
+ */
186
+ sendAuth() {
187
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
188
+ return;
189
+ }
190
+
191
+ const authMessage = JSON.stringify({
192
+ type: 'auth',
193
+ token: this.token
194
+ });
195
+
196
+ this.ws.send(authMessage);
197
+ console.log('WebSocket auth sent');
198
+ }
199
+
200
+ /**
201
+ * Handle incoming message
202
+ * @param {Buffer|string} data
203
+ */
204
+ handleMessage(data) {
205
+ try {
206
+ const message = JSON.parse(data.toString());
207
+
208
+ // Handle error messages from server
209
+ if (message.type === 'error') {
210
+ console.error('WebSocket server error:', message.message);
211
+ return;
212
+ }
213
+
214
+ // Handle auth success
215
+ if (message.type === 'authenticated') {
216
+ console.log('WebSocket authenticated, userId:', message.userId);
217
+ return;
218
+ }
219
+
220
+ // Handle status update (server sends {type: "status", data: {...}})
221
+ if (message.type === 'status' && message.data) {
222
+ if (this.onStatusUpdate) {
223
+ this.onStatusUpdate(message.data);
224
+ }
225
+ return;
226
+ }
227
+
228
+ // Handle status update (direct format: {state: "..."})
229
+ if (message.state) {
230
+ if (this.onStatusUpdate) {
231
+ this.onStatusUpdate(message);
232
+ }
233
+ }
234
+ } catch (error) {
235
+ console.error('WebSocket message parse error:', error.message);
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Handle disconnection and schedule reconnect
241
+ */
242
+ handleDisconnect() {
243
+ this.isConnecting = false;
244
+ this.isConnected = false;
245
+ this.ws = null;
246
+ this.notifyConnectionChange();
247
+
248
+ if (this.shouldReconnect) {
249
+ this.scheduleReconnect();
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Schedule reconnection with exponential backoff
255
+ */
256
+ scheduleReconnect() {
257
+ if (this.reconnectTimer) {
258
+ clearTimeout(this.reconnectTimer);
259
+ }
260
+
261
+ console.log(`WebSocket reconnecting in ${this.reconnectDelay / 1000}s...`);
262
+
263
+ this.reconnectTimer = setTimeout(() => {
264
+ this.reconnectTimer = null;
265
+ this.connect();
266
+ }, this.reconnectDelay);
267
+
268
+ // Increase delay for next attempt (exponential backoff)
269
+ this.reconnectDelay = Math.min(
270
+ this.reconnectDelay * RECONNECT_MULTIPLIER,
271
+ RECONNECT_MAX_DELAY
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Notify connection state change
277
+ */
278
+ notifyConnectionChange() {
279
+ if (this.onConnectionChange) {
280
+ this.onConnectionChange(this.getStatus());
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Disconnect and stop reconnection
286
+ */
287
+ disconnect() {
288
+ this.shouldReconnect = false;
289
+
290
+ if (this.reconnectTimer) {
291
+ clearTimeout(this.reconnectTimer);
292
+ this.reconnectTimer = null;
293
+ }
294
+
295
+ if (this.ws) {
296
+ this.ws.close();
297
+ this.ws = null;
298
+ }
299
+
300
+ this.isConnecting = false;
301
+ this.isConnected = false;
302
+ this.notifyConnectionChange();
303
+ }
304
+
305
+ /**
306
+ * Cleanup resources
307
+ */
308
+ cleanup() {
309
+ this.disconnect();
310
+ }
311
+ }
312
+
313
+ module.exports = { WsClient };
package/package.json ADDED
@@ -0,0 +1,112 @@
1
+ {
2
+ "name": "vibemon",
3
+ "version": "1.5.0",
4
+ "description": "AI assistant status monitor",
5
+ "main": "main.js",
6
+ "bin": {
7
+ "vibemon": "./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
+ "lint": "eslint .",
18
+ "lint:fix": "eslint . --fix",
19
+ "test": "jest",
20
+ "test:watch": "jest --watch",
21
+ "test:coverage": "jest --coverage"
22
+ },
23
+ "author": "nalbam",
24
+ "license": "MIT",
25
+ "keywords": [
26
+ "vibemon",
27
+ "claude-code",
28
+ "kiro",
29
+ "ai-assistant",
30
+ "status-monitor",
31
+ "electron",
32
+ "pixel-art"
33
+ ],
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/nalbam/vibemon-app.git"
37
+ },
38
+ "homepage": "https://nalbam.github.io/vibemon-app/",
39
+ "bugs": {
40
+ "url": "https://github.com/nalbam/vibemon-app/issues"
41
+ },
42
+ "dependencies": {
43
+ "canvas": "^3.2.1",
44
+ "dotenv": "^17.2.3",
45
+ "electron": "^33.0.0",
46
+ "electron-store": "^8.2.0",
47
+ "ws": "^8.19.0"
48
+ },
49
+ "devDependencies": {
50
+ "electron-builder": "^25.0.0",
51
+ "eslint": "^9.0.0",
52
+ "jest": "^29.7.0"
53
+ },
54
+ "files": [
55
+ "README.md",
56
+ "bin/",
57
+ "main.js",
58
+ "preload.js",
59
+ "index.html",
60
+ "stats.html",
61
+ "renderer.js",
62
+ "styles.css",
63
+ "assets/",
64
+ "modules/",
65
+ "shared/"
66
+ ],
67
+ "build": {
68
+ "appId": "com.nalbam.vibemon",
69
+ "productName": "VibeMon",
70
+ "mac": {
71
+ "category": "public.app-category.developer-tools",
72
+ "icon": "assets/icon.icns",
73
+ "target": [
74
+ "dmg",
75
+ "zip"
76
+ ]
77
+ },
78
+ "win": {
79
+ "icon": "assets/icon.ico",
80
+ "target": [
81
+ {
82
+ "target": "nsis",
83
+ "arch": [
84
+ "x64",
85
+ "arm64"
86
+ ]
87
+ },
88
+ {
89
+ "target": "portable",
90
+ "arch": [
91
+ "x64"
92
+ ]
93
+ }
94
+ ]
95
+ },
96
+ "nsis": {
97
+ "oneClick": false,
98
+ "perMachine": false,
99
+ "allowToChangeInstallationDirectory": true,
100
+ "createDesktopShortcut": true,
101
+ "createStartMenuShortcut": true
102
+ },
103
+ "linux": {
104
+ "icon": "assets/icon.png",
105
+ "target": [
106
+ "AppImage",
107
+ "deb"
108
+ ],
109
+ "category": "Development"
110
+ }
111
+ }
112
+ }
package/preload.js ADDED
@@ -0,0 +1,22 @@
1
+ const { contextBridge, ipcRenderer } = require('electron');
2
+
3
+ contextBridge.exposeInMainWorld('electronAPI', {
4
+ closeWindow: () => ipcRenderer.send('close-window'),
5
+ minimizeWindow: () => ipcRenderer.send('minimize-window'),
6
+ showContextMenu: () => ipcRenderer.send('show-context-menu'),
7
+ focusTerminal: () => ipcRenderer.invoke('focus-terminal'),
8
+ onStateUpdate: (callback) => {
9
+ const handler = (_event, data) => {
10
+ try {
11
+ callback(data);
12
+ } catch (error) {
13
+ console.error('State update callback error:', error);
14
+ }
15
+ };
16
+ ipcRenderer.on('state-update', handler);
17
+ // Return cleanup function to prevent memory leaks
18
+ return () => ipcRenderer.removeListener('state-update', handler);
19
+ },
20
+ getVersion: () => ipcRenderer.invoke('get-version'),
21
+ getPlatform: () => process.platform
22
+ });
package/renderer.js ADDED
@@ -0,0 +1,84 @@
1
+ import { createVibeMonEngine } from 'https://static.vibemon.io/js/vibemon-engine-standalone.js';
2
+
3
+ // Static server base URL
4
+ const STATIC_BASE = 'https://static.vibemon.io';
5
+
6
+ // VibeMon engine instance
7
+ let vibeMonEngine = null;
8
+
9
+ // IPC cleanup function
10
+ let cleanupStateListener = null;
11
+
12
+ // Initialize
13
+ async function init() {
14
+ const container = document.getElementById('vibemon-display');
15
+
16
+ // Get platform info for emoji detection
17
+ let useEmoji = false;
18
+ if (window.electronAPI?.getPlatform) {
19
+ const platform = window.electronAPI.getPlatform();
20
+ useEmoji = platform === 'darwin';
21
+ }
22
+
23
+ // Create and initialize VibeMon engine with static server images
24
+ vibeMonEngine = createVibeMonEngine(container, {
25
+ useEmoji,
26
+ characterImageUrls: {
27
+ apto: `${STATIC_BASE}/characters/apto.png`,
28
+ clawd: `${STATIC_BASE}/characters/clawd.png`,
29
+ kiro: `${STATIC_BASE}/characters/kiro.png`,
30
+ claw: `${STATIC_BASE}/characters/claw.png`
31
+ }
32
+ });
33
+ await vibeMonEngine.init();
34
+
35
+ // Initial render and start animation
36
+ vibeMonEngine.render();
37
+ vibeMonEngine.startAnimation();
38
+
39
+ // Listen for state updates from main process
40
+ if (window.electronAPI) {
41
+ cleanupStateListener = window.electronAPI.onStateUpdate((data) => {
42
+ // Validate incoming data
43
+ if (!data || typeof data !== 'object') return;
44
+
45
+ // Update state in VibeMon engine
46
+ vibeMonEngine.setState(data);
47
+ vibeMonEngine.render();
48
+ });
49
+ }
50
+
51
+ // Right-click context menu (works on all platforms)
52
+ document.addEventListener('contextmenu', (e) => {
53
+ e.preventDefault();
54
+ if (window.electronAPI?.showContextMenu) {
55
+ window.electronAPI.showContextMenu();
56
+ }
57
+ });
58
+
59
+ // Click to focus terminal (iTerm2/Ghostty on macOS)
60
+ document.addEventListener('click', (e) => {
61
+ // Ignore right-click
62
+ if (e.button !== 0) return;
63
+ if (window.electronAPI?.focusTerminal) {
64
+ window.electronAPI.focusTerminal();
65
+ }
66
+ });
67
+ }
68
+
69
+ // Cleanup on unload
70
+ function cleanup() {
71
+ if (vibeMonEngine) {
72
+ vibeMonEngine.cleanup();
73
+ vibeMonEngine = null;
74
+ }
75
+ if (cleanupStateListener) {
76
+ cleanupStateListener();
77
+ cleanupStateListener = null;
78
+ }
79
+ }
80
+
81
+ // Initialize on load
82
+ window.onload = init;
83
+ window.onbeforeunload = cleanup;
84
+ window.onunload = cleanup;