cc-caffeine 0.2.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,139 @@
1
+ /**
2
+ * Electron module - Handles all Electron-specific functionality
3
+ */
4
+
5
+ let electron, Tray, Menu, powerSaveBlocker, nativeImage, app, shell;
6
+ let isElectron = false;
7
+
8
+ /**
9
+ * Load Electron modules only when needed
10
+ */
11
+ const loadElectron = () => {
12
+ if (isElectron) {
13
+ return;
14
+ }
15
+
16
+ try {
17
+ electron = require('electron');
18
+ Tray = electron.Tray;
19
+ Menu = electron.Menu;
20
+ powerSaveBlocker = electron.powerSaveBlocker;
21
+ nativeImage = electron.nativeImage;
22
+ app = electron.app;
23
+ shell = electron.shell;
24
+ isElectron = true;
25
+ } catch (error) {
26
+ console.error('Failed to load Electron:', error.message);
27
+ console.error('Make sure to use Electron: npx electron caffeine.js server');
28
+ process.exit(1);
29
+ }
30
+ };
31
+
32
+ /**
33
+ * Get Electron modules
34
+ */
35
+ const getElectron = () => {
36
+ if (!isElectron) {
37
+ loadElectron();
38
+ }
39
+ return {
40
+ electron,
41
+ Tray,
42
+ Menu,
43
+ powerSaveBlocker,
44
+ nativeImage,
45
+ app,
46
+ shell
47
+ };
48
+ };
49
+
50
+ /**
51
+ * Check if running inside Electron
52
+ */
53
+ const isRunningInElectron = () => {
54
+ return !!process.versions.electron;
55
+ };
56
+
57
+ /**
58
+ * Prevent any window from being created
59
+ */
60
+ const preventWindowCreation = () => {
61
+ const { app } = getElectron();
62
+
63
+ if (app.dock && typeof app.dock.hide === 'function') {
64
+ try {
65
+ app.dock.hide();
66
+ } catch (error) {
67
+ // Silently ignore if not macOS or other error
68
+ }
69
+ }
70
+
71
+ app.on('browser-window-created', (event, window) => {
72
+ window.hide();
73
+ });
74
+ };
75
+
76
+ /**
77
+ * Setup Electron app event handlers
78
+ */
79
+ const setupAppEventHandlers = shutdownCallback => {
80
+ const { app } = getElectron();
81
+
82
+ app.on('window-all-closed', () => {
83
+ // Don't quit on window close since we're running in background
84
+ });
85
+
86
+ app.on('before-quit', () => {
87
+ // Cleanup handled by shutdown callback
88
+ });
89
+
90
+ app.on('activate', () => {
91
+ // No window to restore, we're system tray only
92
+ });
93
+
94
+ process.on('SIGINT', () => {
95
+ if (shutdownCallback) {
96
+ shutdownCallback();
97
+ } else {
98
+ process.exit(0);
99
+ }
100
+ });
101
+
102
+ process.on('SIGTERM', () => {
103
+ if (shutdownCallback) {
104
+ shutdownCallback();
105
+ } else {
106
+ process.exit(0);
107
+ }
108
+ });
109
+ };
110
+
111
+ /**
112
+ * Wait for Electron app to be ready
113
+ */
114
+ const whenReady = () => {
115
+ const { app } = getElectron();
116
+ return app.whenReady();
117
+ };
118
+
119
+ /**
120
+ * Quit Electron app
121
+ */
122
+ const quit = () => {
123
+ const { app } = getElectron();
124
+ if (app && typeof app.quit === 'function') {
125
+ app.quit();
126
+ } else {
127
+ process.exit(0);
128
+ }
129
+ };
130
+
131
+ module.exports = {
132
+ loadElectron,
133
+ getElectron,
134
+ isRunningInElectron,
135
+ preventWindowCreation,
136
+ setupAppEventHandlers,
137
+ whenReady,
138
+ quit
139
+ };
package/src/pid.js ADDED
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * PID management module - Handles atomic PID file operations and validation
5
+ *
6
+ * This module provides functions to:
7
+ * - Atomically read/write PID files
8
+ * - Validate if a PID belongs to a caffeine server process
9
+ * - Clean up stale PID files
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const { spawn } = require('child_process');
16
+ const lockfile = require('proper-lockfile');
17
+
18
+ const CONFIG_DIR = path.join(os.homedir(), '.claude', 'plugins', 'cc-caffeine');
19
+ const PID_FILE = path.join(CONFIG_DIR, 'server.pid');
20
+
21
+ const withPidLock = async (fn) => {
22
+ // create if not exists
23
+ try {
24
+ const fd = fs.openSync(PID_FILE, 'wx');
25
+ fs.closeSync(fd);
26
+ } catch (err) {
27
+ if (err.code !== 'EEXIST') {
28
+ throw err;
29
+ }
30
+ // If EEXIST, file already exists, nothing to do
31
+ }
32
+
33
+ let output = null;
34
+
35
+ const release = await lockfile.lock(PID_FILE, {
36
+ retries: 3,
37
+ stale: 10000 // 10 seconds
38
+ });
39
+ try {
40
+ output = await fn();
41
+ } finally {
42
+ await release();
43
+ }
44
+
45
+ return output;
46
+ };
47
+
48
+ /**
49
+ * Write PID to file
50
+ * @param {number} pid - Process ID to write
51
+ */
52
+ const writePidFile = async pid => {
53
+ try {
54
+ await fs.promises.writeFile(PID_FILE, pid.toString(), 'utf8');
55
+ } catch (error) {
56
+ if (error.code === 'ENOENT') {
57
+ // File doesn't exist, create it without locking
58
+ await fs.promises.writeFile(PID_FILE, pid.toString(), 'utf8');
59
+ } else {
60
+ throw error;
61
+ }
62
+ }
63
+ };
64
+
65
+ /**
66
+ * Read PID from file
67
+ * @returns {number|null} PID if found and valid, null otherwise
68
+ */
69
+ const readPidFile = async () => {
70
+ try {
71
+ const pidStr = await fs.promises.readFile(PID_FILE, 'utf8');
72
+ const pid = parseInt(pidStr.trim(), 10);
73
+
74
+ if (isNaN(pid) || pid <= 0) {
75
+ return null;
76
+ }
77
+
78
+ return pid;
79
+ } catch (error) {
80
+ if (error.code === 'ENOENT') {
81
+ return null; // File doesn't exist
82
+ }
83
+ throw error;
84
+ }
85
+ };
86
+
87
+ /**
88
+ * Remove PID file
89
+ */
90
+ const removePidFileWithLock = async () => {
91
+ try {
92
+ const release = await lockfile.lock(PID_FILE, {
93
+ retries: 3,
94
+ stale: 10000 // 10 seconds
95
+ });
96
+
97
+ try {
98
+ await removePidFile();
99
+ } finally {
100
+ await release();
101
+ }
102
+ } catch (error) {
103
+ if (error.code === 'ENOENT') {
104
+ // File already doesn't exist, that's fine
105
+ return;
106
+ }
107
+ throw error;
108
+ }
109
+ };
110
+
111
+ const removePidFile = async () => {
112
+ const pid = await readPidFile();
113
+ if (pid === process.pid) {
114
+ await fs.promises.unlink(PID_FILE);
115
+ }
116
+ };
117
+
118
+ /**
119
+ * Check if a process with given PID exists and is a caffeine server
120
+ * @param {number} pid - Process ID to check
121
+ * @returns {Promise<boolean>} True if process exists and is caffeine server
122
+ */
123
+ const validatePid = async pid => {
124
+ return new Promise(resolve => {
125
+ // First check if process exists
126
+ try {
127
+ process.kill(pid, 0); // Signal 0 just checks if process exists
128
+ } catch (error) {
129
+ if (error.code === 'ESRCH') {
130
+ // Process doesn't exist
131
+ resolve(false);
132
+ return;
133
+ }
134
+ // Other errors (like EPERM) mean process exists but we can't signal it
135
+ }
136
+
137
+ // Process exists, now check if it's a caffeine server
138
+ const isWindows = os.platform() === 'win32';
139
+ const psCommand = isWindows
140
+ ? spawn('wmic', ['process', 'where', `processid=${pid}`, 'get', 'commandline'], {
141
+ stdio: 'pipe'
142
+ })
143
+ : spawn('ps', ['-p', pid, '-o', 'command='], { stdio: 'pipe' });
144
+
145
+ let output = '';
146
+
147
+ psCommand.stdout.on('data', data => {
148
+ output += data.toString();
149
+ });
150
+
151
+ psCommand.on('close', code => {
152
+ if (code !== 0) {
153
+ resolve(false);
154
+ return;
155
+ }
156
+
157
+ const commandLine = output.trim().toLowerCase();
158
+ for (const line of commandLine.split('\n')) {
159
+ // Check if command line contains both "caffeine" and "server"
160
+ const isCaffeineServer = line.includes('caffeine server') || line.includes('caffeine.js server');
161
+ const isElectron = line.includes('electron');
162
+
163
+ if (isCaffeineServer && isElectron) {
164
+ resolve(true);
165
+ return;
166
+ }
167
+ }
168
+
169
+ resolve(false);
170
+ });
171
+
172
+ psCommand.on('error', () => {
173
+ resolve(false);
174
+ });
175
+ });
176
+ };
177
+
178
+ /**
179
+ * Check if caffeine server is running using PID file
180
+ * @returns {Promise<boolean>} True if server is running
181
+ */
182
+ const isServerRunningWithLock = async () => {
183
+ return await withPidLock(async () => {
184
+ return await isServerRunning();
185
+ });
186
+ };
187
+
188
+ /**
189
+ * Check if caffeine server is running using PID file
190
+ * @returns {Promise<boolean>} True if server is running
191
+ */
192
+ const isServerRunning = async () => {
193
+ try {
194
+ const pid = await readPidFile();
195
+
196
+ if (!pid) {
197
+ return false;
198
+ }
199
+
200
+ const isValid = await validatePid(pid);
201
+
202
+ if (!isValid) {
203
+ // PID is stale, clean it up
204
+ await removePidFile();
205
+ return false;
206
+ }
207
+
208
+ return true;
209
+ } catch (error) {
210
+ console.error('Error checking if server is running:', error);
211
+ return false;
212
+ }
213
+ };
214
+
215
+ module.exports = {
216
+ writePidFile,
217
+ readPidFile,
218
+ removePidFileWithLock,
219
+ removePidFile,
220
+ validatePid,
221
+ isServerRunningWithLock,
222
+ isServerRunning,
223
+ withPidLock
224
+ };
package/src/server.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Server module - Handles server process management and startup
3
+ */
4
+
5
+ const path = require('path');
6
+ const { spawn } = require('child_process');
7
+
8
+ const { initSessionsFile } = require('./session');
9
+ const { getSystemTray, startPolling, shutdownServer } = require('./system-tray');
10
+ const {
11
+ isRunningInElectron,
12
+ preventWindowCreation,
13
+ setupAppEventHandlers,
14
+ whenReady,
15
+ quit
16
+ } = require('./electron');
17
+ const { isServerRunning, writePidFile, withPidLock, isServerRunningWithLock } = require('./pid');
18
+
19
+ const CHECK_INTERVAL = 5 * 1000; // 10 seconds
20
+
21
+ /**
22
+ * Ensure server is running, start if needed
23
+ */
24
+ const runServerProcessIfNotStarted = async () => {
25
+ const isRunning = await isServerRunningWithLock();
26
+ if (isRunning) {
27
+ console.error('Server is already running');
28
+ return;
29
+ }
30
+
31
+ console.error('Server not running, starting...');
32
+ await startServerProcess();
33
+ };
34
+
35
+ /**
36
+ * Start server process using npm
37
+ */
38
+ const startServerProcess = async () => {
39
+ console.error('Starting caffeine server...');
40
+
41
+ const cwd = path.join(__dirname, '..');
42
+
43
+ const serverProcess = spawn('npm', ['run', 'server'], {
44
+ detached: true,
45
+ stdio: 'ignore',
46
+ cwd // is needed to find the correct caffeine.js
47
+ });
48
+
49
+ serverProcess.unref();
50
+
51
+ // Wait for server to start
52
+ await new Promise(resolve => setTimeout(resolve, 500));
53
+
54
+ return true;
55
+ };
56
+
57
+ /**
58
+ * Handle server command - start Electron server or delegate with atomic file locking
59
+ */
60
+ const handleServer = async () => {
61
+ let mustStartServer = false;
62
+ let mustStartElectron = false;
63
+
64
+ await withPidLock(async () => {
65
+ try {
66
+ // Inside the lock, check if server is already running
67
+ const alreadyRunning = await isServerRunning();
68
+ if (alreadyRunning) {
69
+ console.error('Caffeine server is already running');
70
+ return;
71
+ }
72
+
73
+ if (isRunningInElectron()) {
74
+ mustStartServer = true;
75
+ console.error('Already running inside Electron, starting server...');
76
+ await writePidFile(process.pid);
77
+ } else {
78
+ mustStartElectron = true;
79
+ console.error('Not running inside Electron, spawning Electron process...');
80
+ }
81
+ } catch (error) {
82
+ if (error.code === 'ELOCKED' || error.code === 'EEXIST') {
83
+ // Another process has the lock, server is likely starting up
84
+ console.error('Server startup is in progress by another process');
85
+ } else {
86
+ console.error('Failed to acquire server startup lock:', error);
87
+ throw error;
88
+ }
89
+ }
90
+ });
91
+
92
+ if (mustStartElectron) {
93
+ await spawnElectronProcess();
94
+ } else if (mustStartServer) {
95
+ await startServer();
96
+ } else if (isRunningInElectron()) {
97
+ await shutdownServer();
98
+ process.exit(0);
99
+ }
100
+ };
101
+
102
+ /**
103
+ * Start server when already inside Electron
104
+ */
105
+ const startServer = async () => {
106
+ console.error('Loading Electron...');
107
+
108
+ // Prevent any window from being created
109
+ preventWindowCreation();
110
+
111
+ // Setup event handlers with shutdown callback
112
+ setupAppEventHandlers(() => {
113
+ // We'll handle shutdown in the main process
114
+ process.exit(0);
115
+ });
116
+
117
+ // Wait for app to be ready before starting system tray
118
+ await whenReady();
119
+
120
+ // Start the actual server
121
+ try {
122
+ await initSessionsFile();
123
+ const state = getSystemTray();
124
+ startPolling(state, CHECK_INTERVAL);
125
+ console.error('Caffeine server started successfully with system tray');
126
+
127
+ // Only setup signal handlers if server actually started
128
+ if (state) {
129
+ // Handle process termination for Electron process
130
+ process.on('SIGINT', async () => {
131
+ console.error('Received SIGINT, shutting down server...');
132
+ await shutdownServer(state);
133
+ quit();
134
+ });
135
+
136
+ process.on('SIGTERM', async () => {
137
+ console.error('Received SIGTERM, shutting down server...');
138
+ await shutdownServer(state);
139
+ quit();
140
+ });
141
+ }
142
+
143
+ return state;
144
+ } catch (error) {
145
+ console.error('Failed to start server:', error);
146
+ process.exit(1);
147
+ }
148
+ };
149
+
150
+ /**
151
+ * Spawn new Electron process for server
152
+ */
153
+ const spawnElectronProcess = () => {
154
+ const cwd = path.join(__dirname, '..');
155
+
156
+ const electronProcess = spawn('npx', ['electron', 'caffeine.js', 'server'], {
157
+ stdio: 'inherit',
158
+ shell: true,
159
+ detached: false,
160
+ cwd // is needed to find caffeine.js
161
+ });
162
+
163
+ electronProcess.on('exit', code => {
164
+ process.exit(code || 0);
165
+ });
166
+
167
+ electronProcess.on('error', error => {
168
+ console.error('Failed to spawn Electron process:', error);
169
+ process.exit(1);
170
+ });
171
+
172
+ electronProcess.on('close', code => {
173
+ process.exit(code || 0);
174
+ });
175
+
176
+ return electronProcess.pid;
177
+ };
178
+
179
+ module.exports = {
180
+ handleServer,
181
+ runServerProcessIfNotStarted
182
+ };