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.
- package/.claude/settings.local.json +70 -0
- package/.claude-plugin/plugin.json +23 -0
- package/.eslintrc.json +36 -0
- package/.github/FUNDING.yml +1 -0
- package/.github/dependabot.yml +6 -0
- package/.github/workflows/build.yml +79 -0
- package/.prettierrc +11 -0
- package/CLAUDE.md +307 -0
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/assets/icon-coffee-empty.png +0 -0
- package/assets/icon-coffee-empty.svg +10 -0
- package/assets/icon-coffee-full.png +0 -0
- package/assets/icon-coffee-full.svg +15 -0
- package/caffeine.js +63 -0
- package/hooks/hooks.json +67 -0
- package/package.json +57 -0
- package/src/commands.js +168 -0
- package/src/electron.js +139 -0
- package/src/pid.js +224 -0
- package/src/server.js +182 -0
- package/src/session.js +227 -0
- package/src/system-tray.js +247 -0
package/src/session.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const lockfile = require('proper-lockfile');
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = path.join(os.homedir(), '.claude', 'plugins', 'cc-caffeine');
|
|
7
|
+
const SESSIONS_FILE = path.join(CONFIG_DIR, 'sessions.json');
|
|
8
|
+
const SESSION_TIMEOUT = 15 * 60 * 1000; // 15 minutes
|
|
9
|
+
const MAX_RETRIES = 10;
|
|
10
|
+
|
|
11
|
+
const initSessionsFile = async () => {
|
|
12
|
+
if (!fs.existsSync(SESSIONS_FILE)) {
|
|
13
|
+
const initialData = {
|
|
14
|
+
sessions: {},
|
|
15
|
+
last_updated: new Date().toISOString()
|
|
16
|
+
};
|
|
17
|
+
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(initialData, null, 2));
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const readSessionsWithLock = async (retryCount = 0) => {
|
|
22
|
+
try {
|
|
23
|
+
await initSessionsFile();
|
|
24
|
+
|
|
25
|
+
const release = await lockfile.lock(SESSIONS_FILE, {
|
|
26
|
+
retries: MAX_RETRIES,
|
|
27
|
+
stale: 30000 // 30 seconds
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const data = fs.readFileSync(SESSIONS_FILE, 'utf8');
|
|
32
|
+
return JSON.parse(data);
|
|
33
|
+
} finally {
|
|
34
|
+
await release();
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (retryCount < MAX_RETRIES) {
|
|
38
|
+
console.warn(`Retry ${retryCount + 1}/${MAX_RETRIES} for readSessionsWithLock`);
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, 100 * Math.pow(2, retryCount)));
|
|
40
|
+
return readSessionsWithLock(retryCount + 1);
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Note: Individual write operations should use addSession/removeSession for atomicity
|
|
47
|
+
|
|
48
|
+
const addSessionWithLock = async sessionId => {
|
|
49
|
+
await initSessionsFile();
|
|
50
|
+
|
|
51
|
+
const release = await lockfile.lock(SESSIONS_FILE, {
|
|
52
|
+
retries: MAX_RETRIES,
|
|
53
|
+
stale: 30000 // 30 seconds
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// Read data while holding the lock
|
|
58
|
+
const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
|
|
59
|
+
const now = new Date().toISOString();
|
|
60
|
+
|
|
61
|
+
// Clean up expired sessions first
|
|
62
|
+
const nowDate = new Date();
|
|
63
|
+
let removedCount = 0;
|
|
64
|
+
|
|
65
|
+
for (const [existingSessionId, sessionData] of Object.entries(data.sessions)) {
|
|
66
|
+
const lastActivity = new Date(sessionData.last_activity);
|
|
67
|
+
const timeDiff = nowDate - lastActivity;
|
|
68
|
+
|
|
69
|
+
if (timeDiff >= SESSION_TIMEOUT) {
|
|
70
|
+
delete data.sessions[existingSessionId];
|
|
71
|
+
removedCount++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Add or update the session
|
|
76
|
+
if (data.sessions[sessionId]) {
|
|
77
|
+
// Update existing session's last_activity only
|
|
78
|
+
data.sessions[sessionId].last_activity = now;
|
|
79
|
+
} else {
|
|
80
|
+
// Create new session
|
|
81
|
+
data.sessions[sessionId] = {
|
|
82
|
+
created_at: now,
|
|
83
|
+
last_activity: now,
|
|
84
|
+
project_dir: process.env.CLAUDE_PROJECT_DIR
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Write updated data while still holding the lock
|
|
89
|
+
data.last_updated = now;
|
|
90
|
+
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2));
|
|
91
|
+
|
|
92
|
+
const isNewSession =
|
|
93
|
+
!data.sessions[sessionId] ||
|
|
94
|
+
(data.sessions[sessionId].created_at === now &&
|
|
95
|
+
data.sessions[sessionId].last_activity === now);
|
|
96
|
+
const action = isNewSession ? 'added' : 'updated';
|
|
97
|
+
|
|
98
|
+
// console.error(`Cleaned up ${removedCount} expired sessions and ${action} session: ${sessionId}`);
|
|
99
|
+
return { id: sessionId, cleaned_sessions: removedCount, action };
|
|
100
|
+
} finally {
|
|
101
|
+
await release();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const removeSessionWithLock = async sessionId => {
|
|
106
|
+
await initSessionsFile();
|
|
107
|
+
|
|
108
|
+
const release = await lockfile.lock(SESSIONS_FILE, {
|
|
109
|
+
retries: MAX_RETRIES,
|
|
110
|
+
stale: 30000 // 30 seconds
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
// Read data while holding the lock
|
|
115
|
+
const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
|
|
116
|
+
const now = new Date().toISOString();
|
|
117
|
+
let changes = 0;
|
|
118
|
+
|
|
119
|
+
// Clean up expired sessions first
|
|
120
|
+
const nowDate = new Date();
|
|
121
|
+
let cleanedCount = 0;
|
|
122
|
+
|
|
123
|
+
for (const [existingSessionId, sessionData] of Object.entries(data.sessions)) {
|
|
124
|
+
const lastActivity = new Date(sessionData.last_activity);
|
|
125
|
+
const timeDiff = nowDate - lastActivity;
|
|
126
|
+
|
|
127
|
+
if (timeDiff >= SESSION_TIMEOUT) {
|
|
128
|
+
delete data.sessions[existingSessionId];
|
|
129
|
+
cleanedCount++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Remove the specific session if it exists
|
|
134
|
+
if (data.sessions[sessionId]) {
|
|
135
|
+
delete data.sessions[sessionId];
|
|
136
|
+
changes = 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Write updated data while still holding the lock
|
|
140
|
+
if (changes > 0 || cleanedCount > 0) {
|
|
141
|
+
data.last_updated = now;
|
|
142
|
+
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (cleanedCount > 0) {
|
|
146
|
+
// console.error(`Cleaned up ${cleanedCount} expired sessions`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { changes, cleaned_sessions: cleanedCount };
|
|
150
|
+
} finally {
|
|
151
|
+
await release();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const getActiveSessionsWithLock = async () => {
|
|
156
|
+
await initSessionsFile();
|
|
157
|
+
|
|
158
|
+
const release = await lockfile.lock(SESSIONS_FILE, {
|
|
159
|
+
retries: MAX_RETRIES,
|
|
160
|
+
stale: 30000 // 30 seconds
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
|
|
165
|
+
const now = new Date();
|
|
166
|
+
const activeSessions = [];
|
|
167
|
+
|
|
168
|
+
for (const [sessionId, sessionData] of Object.entries(data.sessions)) {
|
|
169
|
+
const lastActivity = new Date(sessionData.last_activity);
|
|
170
|
+
const timeDiff = now - lastActivity;
|
|
171
|
+
|
|
172
|
+
if (timeDiff < SESSION_TIMEOUT) {
|
|
173
|
+
activeSessions.push({
|
|
174
|
+
id: sessionId,
|
|
175
|
+
...sessionData
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return activeSessions;
|
|
181
|
+
} finally {
|
|
182
|
+
await release();
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const cleanupExpiredSessionsWithLock = async () => {
|
|
187
|
+
await initSessionsFile();
|
|
188
|
+
|
|
189
|
+
const release = await lockfile.lock(SESSIONS_FILE, {
|
|
190
|
+
retries: MAX_RETRIES,
|
|
191
|
+
stale: 30000 // 30 seconds
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
|
|
196
|
+
const now = new Date();
|
|
197
|
+
let removedCount = 0;
|
|
198
|
+
|
|
199
|
+
for (const [sessionId, sessionData] of Object.entries(data.sessions)) {
|
|
200
|
+
const lastActivity = new Date(sessionData.last_activity);
|
|
201
|
+
const timeDiff = now - lastActivity;
|
|
202
|
+
|
|
203
|
+
if (timeDiff >= SESSION_TIMEOUT) {
|
|
204
|
+
delete data.sessions[sessionId];
|
|
205
|
+
removedCount++;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (removedCount > 0) {
|
|
210
|
+
data.last_updated = new Date().toISOString();
|
|
211
|
+
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { changes: removedCount };
|
|
215
|
+
} finally {
|
|
216
|
+
await release();
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
initSessionsFile,
|
|
222
|
+
readSessionsWithLock,
|
|
223
|
+
addSessionWithLock,
|
|
224
|
+
removeSessionWithLock,
|
|
225
|
+
getActiveSessionsWithLock,
|
|
226
|
+
cleanupExpiredSessionsWithLock
|
|
227
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System Tray module - Handles all system tray functionality
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const { getActiveSessionsWithLock, cleanupExpiredSessionsWithLock } = require('./session');
|
|
8
|
+
const { getElectron } = require('./electron');
|
|
9
|
+
const { removePidFileWithLock } = require('./pid');
|
|
10
|
+
const package = require('../package.json');
|
|
11
|
+
|
|
12
|
+
let trayState = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create icon for system tray
|
|
16
|
+
*/
|
|
17
|
+
const createIcon = isActive => {
|
|
18
|
+
const icon = isActive ? '../assets/icon-coffee-full.png' : '../assets/icon-coffee-empty.png';
|
|
19
|
+
const iconPath = path.join(__dirname, icon);
|
|
20
|
+
const { nativeImage } = getElectron();
|
|
21
|
+
return nativeImage.createFromPath(iconPath);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create system tray
|
|
26
|
+
*/
|
|
27
|
+
const createSystemTray = () => {
|
|
28
|
+
const { Tray, Menu } = getElectron();
|
|
29
|
+
|
|
30
|
+
if (!Tray) {
|
|
31
|
+
throw new Error('Electron Tray is not available');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const tray = new Tray(createIcon(false));
|
|
36
|
+
tray.setToolTip('CC-Caffeine: Normal');
|
|
37
|
+
|
|
38
|
+
trayState = {
|
|
39
|
+
tray,
|
|
40
|
+
isCaffeinated: false,
|
|
41
|
+
pollInterval: null,
|
|
42
|
+
powerSaveBlockerId: null
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (!Menu) {
|
|
46
|
+
throw new Error('Electron Menu is not available');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const contextMenu = Menu.buildFromTemplate([
|
|
50
|
+
{
|
|
51
|
+
label: `Version: ${package.version}`,
|
|
52
|
+
enabled: false
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
label: 'Github',
|
|
56
|
+
click: () => {
|
|
57
|
+
getElectron().shell.openExternal('https://github.com/samber/cc-caffeine')
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
label: '💖 Sponsor',
|
|
62
|
+
click: () => {
|
|
63
|
+
getElectron().shell.openExternal('https://github.com/sponsors/samber')
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
type: 'separator'
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
label: 'Exit',
|
|
71
|
+
click: async () => {
|
|
72
|
+
await shutdownServer(trayState);
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
tray.setContextMenu(contextMenu);
|
|
79
|
+
return trayState;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error('Error creating Electron system tray:', error);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get current system tray state
|
|
88
|
+
*/
|
|
89
|
+
const getSystemTrayState = () => {
|
|
90
|
+
return trayState;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get system tray instance
|
|
95
|
+
*/
|
|
96
|
+
const getSystemTray = () => {
|
|
97
|
+
if (!trayState) {
|
|
98
|
+
return createSystemTray();
|
|
99
|
+
}
|
|
100
|
+
return trayState;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Update tray icon based on caffeine state
|
|
105
|
+
*/
|
|
106
|
+
const updateTrayIcon = state => {
|
|
107
|
+
if (!state || !state.tray) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const icon = createIcon(state.isCaffeinated);
|
|
112
|
+
state.tray.setImage(icon);
|
|
113
|
+
state.tray.setToolTip(`CC-Caffeine: ${state.isCaffeinated ? 'Caffeinated' : 'Normal'}`);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Enable caffeine (prevent sleep)
|
|
118
|
+
*/
|
|
119
|
+
const enableCaffeine = state => {
|
|
120
|
+
if (!state.isCaffeinated) {
|
|
121
|
+
const { powerSaveBlocker } = getElectron();
|
|
122
|
+
state.isCaffeinated = true;
|
|
123
|
+
state.powerSaveBlockerId = powerSaveBlocker.start('prevent-app-suspension');
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Disable caffeine (allow sleep)
|
|
129
|
+
*/
|
|
130
|
+
const disableCaffeine = state => {
|
|
131
|
+
if (state.isCaffeinated) {
|
|
132
|
+
const { powerSaveBlocker } = getElectron();
|
|
133
|
+
state.isCaffeinated = false;
|
|
134
|
+
if (state.powerSaveBlockerId !== null) {
|
|
135
|
+
powerSaveBlocker.stop(state.powerSaveBlockerId);
|
|
136
|
+
state.powerSaveBlockerId = null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Update caffeine status based on active sessions
|
|
143
|
+
*/
|
|
144
|
+
const updateCaffeineStatus = async state => {
|
|
145
|
+
if (!state) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await cleanupExpiredSessionsWithLock();
|
|
151
|
+
const activeSessions = await getActiveSessionsWithLock();
|
|
152
|
+
const shouldCaffeinate = activeSessions.length > 0;
|
|
153
|
+
|
|
154
|
+
if (shouldCaffeinate && !state.isCaffeinated) {
|
|
155
|
+
enableCaffeine(state);
|
|
156
|
+
} else if (!shouldCaffeinate && state.isCaffeinated) {
|
|
157
|
+
disableCaffeine(state);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
updateTrayIcon(state);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.error('Error updating caffeine status:', error);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Start polling for session changes
|
|
168
|
+
*/
|
|
169
|
+
const startPolling = (state, interval = 10000) => {
|
|
170
|
+
// Initial check
|
|
171
|
+
updateCaffeineStatus(state);
|
|
172
|
+
|
|
173
|
+
// Set up periodic polling
|
|
174
|
+
state.pollInterval = setInterval(() => {
|
|
175
|
+
updateCaffeineStatus(state);
|
|
176
|
+
}, interval);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Stop polling
|
|
181
|
+
*/
|
|
182
|
+
const stopPolling = state => {
|
|
183
|
+
if (state && state.pollInterval) {
|
|
184
|
+
try {
|
|
185
|
+
clearInterval(state.pollInterval);
|
|
186
|
+
state.pollInterval = null;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error('Error clearing interval:', error.message);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Shutdown server and clean up resources
|
|
195
|
+
*/
|
|
196
|
+
const shutdownServer = async state => {
|
|
197
|
+
console.error('Shutting down caffeine server...');
|
|
198
|
+
|
|
199
|
+
if (!state) {
|
|
200
|
+
console.error('No state provided, exiting...');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Stop polling
|
|
205
|
+
stopPolling(state);
|
|
206
|
+
|
|
207
|
+
// Always disable caffeine before shutting down
|
|
208
|
+
try {
|
|
209
|
+
disableCaffeine(state);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error('Error disabling caffeine:', error.message);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Clean up Electron system tray
|
|
215
|
+
try {
|
|
216
|
+
if (state.tray) {
|
|
217
|
+
state.tray.destroy();
|
|
218
|
+
state.tray = null;
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error('Error destroying Electron system tray:', error.message);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Remove PID file
|
|
225
|
+
try {
|
|
226
|
+
await removePidFileWithLock();
|
|
227
|
+
} catch (error) {
|
|
228
|
+
console.error('Error removing PID file:', error.message);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Reset global state
|
|
232
|
+
trayState = null;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
module.exports = {
|
|
236
|
+
createIcon,
|
|
237
|
+
createSystemTray,
|
|
238
|
+
getSystemTray,
|
|
239
|
+
getSystemTrayState,
|
|
240
|
+
updateTrayIcon,
|
|
241
|
+
enableCaffeine,
|
|
242
|
+
disableCaffeine,
|
|
243
|
+
updateCaffeineStatus,
|
|
244
|
+
startPolling,
|
|
245
|
+
stopPolling,
|
|
246
|
+
shutdownServer
|
|
247
|
+
};
|