promptcase 1.0.4 → 1.0.5
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/README.md +46 -11
- package/dist/commands/init.js +12 -0
- package/dist/commands/status.js +46 -0
- package/dist/commands/stop.js +106 -19
- package/dist/commands/sync.js +24 -8
- package/dist/services/daemon.js +119 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A background daemon that captures AI prompts you send to Claude Code and syncs them to your [PromptCase](https://promptcase.app) account for later search, tagging, and organisation.
|
|
4
4
|
|
|
5
|
+
> Install once, authenticate once, then forget. The daemon runs in the background and syncs your prompts every minute.
|
|
6
|
+
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -26,19 +28,35 @@ promptcase sync
|
|
|
26
28
|
promptcase show
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
The daemon
|
|
31
|
+
The daemon auto-starts on login/boot and runs in the background.
|
|
30
32
|
|
|
31
33
|
## Commands
|
|
32
34
|
|
|
33
35
|
| Command | Description |
|
|
34
36
|
| --- | --- |
|
|
35
|
-
| `promptcase init` | Authenticate and start the daemon |
|
|
36
|
-
| `promptcase status` | Show auth, daemon, and auto-start status |
|
|
37
|
+
| `promptcase init` | Authenticate and (re)start the daemon |
|
|
38
|
+
| `promptcase status` | Show auth, daemon, lifetime sync counter, and auto-start status |
|
|
37
39
|
| `promptcase sync` | Force a sync and run diagnostics |
|
|
38
40
|
| `promptcase show` | Show recent synced prompts |
|
|
39
|
-
| `promptcase stop` | Stop the running daemon (auto-start preserved) |
|
|
41
|
+
| `promptcase stop` | Stop the running daemon (auto-start is preserved for next boot) |
|
|
40
42
|
| `promptcase logout` | Stop daemon, revoke token, clear credentials |
|
|
41
43
|
|
|
44
|
+
## How it works
|
|
45
|
+
|
|
46
|
+
1. **`promptcase init`** authenticates with your PromptCase account and spawns a single background daemon.
|
|
47
|
+
2. The daemon captures prompts from `~/.claude/projects/` and `~/.claude/history.jsonl`.
|
|
48
|
+
3. Every 60 seconds it uploads new prompts to your account.
|
|
49
|
+
4. The server deduplicates by content hash, so re-syncing the same prompt is safe.
|
|
50
|
+
5. **One daemon at a time** is guaranteed by an exclusive `mkdir`-based lock — you can't accidentally start a second one.
|
|
51
|
+
|
|
52
|
+
### Auto-start
|
|
53
|
+
|
|
54
|
+
Auto-start is installed by `init` and survives reboot:
|
|
55
|
+
|
|
56
|
+
- **macOS** — LaunchAgent at `~/Library/LaunchAgents/app.promptcase.daemon.plist` (uses `RunAtLoad=true`)
|
|
57
|
+
- **Linux** — user-level systemd service at `~/.config/systemd/user/promptcase.service` (run `systemctl --user enable promptcase` to enable)
|
|
58
|
+
- **Windows** — `.bat` in the Startup folder (runs on next user login)
|
|
59
|
+
|
|
42
60
|
## Non-interactive authentication
|
|
43
61
|
|
|
44
62
|
Pipe a token JSON to `init`:
|
|
@@ -49,19 +67,36 @@ echo '{"access_token":"...","refresh_token":"...","expires_in":15552000}' | prom
|
|
|
49
67
|
|
|
50
68
|
## Configuration
|
|
51
69
|
|
|
52
|
-
|
|
70
|
+
| Path | Purpose |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| `~/.promptcase/daemon.pid` | Current daemon PID |
|
|
73
|
+
| `~/.promptcase/daemon.pid.lock/` | Single-instance lock directory (created on first start) |
|
|
74
|
+
| `~/.promptcase/daemon.log` | Daemon stdout |
|
|
75
|
+
| `~/.promptcase/daemon.error.log` | Daemon stderr |
|
|
76
|
+
| `~/Library/Preferences/promptcase-nodejs/config.json` (macOS) | Credentials + sync state (managed by `conf`) |
|
|
77
|
+
| `%APPDATA%/promptcase-nodejs/config.json` (Windows) | Same, on Windows |
|
|
78
|
+
| `~/.config/configstore/promptcase-nodejs.json` (Linux) | Same, on Linux |
|
|
79
|
+
|
|
80
|
+
## Lifecycle
|
|
53
81
|
|
|
54
|
-
-
|
|
55
|
-
-
|
|
82
|
+
- `promptcase stop` — kills the daemon. Auto-start plist stays, so the daemon resumes on next boot. Run `promptcase init` to start it again without rebooting.
|
|
83
|
+
- `promptcase logout` — kills the daemon AND removes your stored credentials.
|
|
56
84
|
|
|
57
85
|
## Troubleshooting
|
|
58
86
|
|
|
59
87
|
```bash
|
|
60
|
-
promptcase status
|
|
61
|
-
promptcase sync
|
|
62
|
-
promptcase
|
|
88
|
+
promptcase status # overall health, lifetime count, lock state
|
|
89
|
+
promptcase sync # force a sync right now
|
|
90
|
+
promptcase stop && promptcase init # full daemon restart
|
|
91
|
+
promptcase logout && promptcase init # re-authenticate from scratch
|
|
63
92
|
```
|
|
64
93
|
|
|
94
|
+
If `promptcase status` warns about multiple daemons running, run `promptcase stop` and wait a few seconds.
|
|
95
|
+
|
|
96
|
+
## Privacy
|
|
97
|
+
|
|
98
|
+
The daemon only reads `~/.claude/` (Claude Code's local data directory). It sends each prompt's content and metadata (timestamp, project path) to the PromptCase server over HTTPS. No other files are accessed.
|
|
99
|
+
|
|
65
100
|
## License
|
|
66
101
|
|
|
67
|
-
MIT
|
|
102
|
+
MIT
|
package/dist/commands/init.js
CHANGED
|
@@ -249,6 +249,18 @@ export async function initFlow(apiUrl = DEFAULT_API_URL) {
|
|
|
249
249
|
const autoStartSuccess = await daemon.setupAutoStart();
|
|
250
250
|
if (autoStartSuccess) {
|
|
251
251
|
console.log(' ✅ Auto-start configured');
|
|
252
|
+
// Re-enable in case it was previously disabled via bootout. Required
|
|
253
|
+
// because `launchctl bootout` leaves the service disabled across
|
|
254
|
+
// sessions; without re-enabling, the daemon won't survive reboot.
|
|
255
|
+
if (process.platform === 'darwin') {
|
|
256
|
+
try {
|
|
257
|
+
const { execFileSync } = await import('child_process');
|
|
258
|
+
execFileSync('launchctl', ['enable', `gui/${process.getuid?.() ?? ''}/app.promptcase.daemon`], { stdio: 'ignore' });
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// Best effort — keep going
|
|
262
|
+
}
|
|
263
|
+
}
|
|
252
264
|
}
|
|
253
265
|
else {
|
|
254
266
|
console.log(' ⚠️ Auto-start setup failed');
|
package/dist/commands/status.js
CHANGED
|
@@ -5,10 +5,47 @@ import { Command } from 'commander';
|
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import os from 'os';
|
|
8
|
+
import { execFileSync } from 'child_process';
|
|
8
9
|
import { APIService } from '../services/api.js';
|
|
9
10
|
import { DaemonService } from '../services/daemon.js';
|
|
10
11
|
import { getConfig } from '../lib/config.js';
|
|
11
12
|
import { DEFAULT_API_URL } from '../lib/constants.js';
|
|
13
|
+
/**
|
|
14
|
+
* Count `PromptCase Daemon` processes currently running. This is a
|
|
15
|
+
* defense-in-depth check for the duplicate-sync bug — if more than one
|
|
16
|
+
* daemon is alive, every sync loop duplicates work for the same user.
|
|
17
|
+
*/
|
|
18
|
+
function countDaemonProcesses() {
|
|
19
|
+
try {
|
|
20
|
+
if (process.platform === 'win32') {
|
|
21
|
+
// tasklist: count lines containing "node.exe" in process list
|
|
22
|
+
const out = execFileSync('tasklist', ['/FI', 'IMAGENAME eq node.exe'], {
|
|
23
|
+
encoding: 'utf-8',
|
|
24
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
25
|
+
});
|
|
26
|
+
return out.split('\n').filter((l) => /node\.exe/i.test(l)).length;
|
|
27
|
+
}
|
|
28
|
+
// macOS / Linux: pgrep matches "PromptCase Daemon" process title.
|
|
29
|
+
// Falls back to scanning "node ...promptcase" lines if pgrep missing.
|
|
30
|
+
try {
|
|
31
|
+
const out = execFileSync('pgrep', ['-f', 'PromptCase Daemon'], {
|
|
32
|
+
encoding: 'utf-8',
|
|
33
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
34
|
+
});
|
|
35
|
+
return out.trim().split('\n').filter(Boolean).length;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
const out = execFileSync('ps', ['-A', '-o', 'command='], {
|
|
39
|
+
encoding: 'utf-8',
|
|
40
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
41
|
+
});
|
|
42
|
+
return out.split('\n').filter((l) => /node.*promptcase.*start/.test(l) || /PromptCase Daemon/.test(l)).length;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return -1; // unknown
|
|
47
|
+
}
|
|
48
|
+
}
|
|
12
49
|
export function createStatusCommand(apiUrl = DEFAULT_API_URL) {
|
|
13
50
|
const command = new Command('status');
|
|
14
51
|
command
|
|
@@ -78,6 +115,15 @@ export function createStatusCommand(apiUrl = DEFAULT_API_URL) {
|
|
|
78
115
|
const nextSyncDate = new Date(daemonStatus.nextSyncAt);
|
|
79
116
|
console.log(` Next sync: ${nextSyncDate.toLocaleString()}`);
|
|
80
117
|
}
|
|
118
|
+
// Defense in depth: warn if the system shows >1 daemon running,
|
|
119
|
+
// even when our lock-based `isRunning()` says one is alive. This
|
|
120
|
+
// catches the case where another daemon was spawned manually.
|
|
121
|
+
const count = countDaemonProcesses();
|
|
122
|
+
if (count > 1) {
|
|
123
|
+
console.log(`\n ⚠️ ${count} promptcase daemons are running.`);
|
|
124
|
+
console.log(' Run `promptcase stop` and `promptcase init` to clean up.');
|
|
125
|
+
console.log(' Multiple daemons will sync the same prompts, causing duplicates.');
|
|
126
|
+
}
|
|
81
127
|
}
|
|
82
128
|
else {
|
|
83
129
|
console.log(' Status: 🔴 Not running');
|
package/dist/commands/stop.js
CHANGED
|
@@ -1,42 +1,129 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Stop command - Stop daemon
|
|
2
|
+
* Stop command - Stop the daemon process
|
|
3
3
|
*
|
|
4
4
|
* Behavior:
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
5
|
+
* - Signals the live daemon to exit (sends SIGTERM/SIGINT, not just file cleanup)
|
|
6
|
+
* - Cleans up lock + PID files so a subsequent `init` can re-acquire the lock
|
|
7
|
+
* - Auto-start plist stays in place — daemon will re-spawn on next boot
|
|
7
8
|
*/
|
|
8
9
|
import { Command } from 'commander';
|
|
10
|
+
import { execFile } from 'child_process';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
9
13
|
import { DaemonService } from '../services/daemon.js';
|
|
10
14
|
import { DEFAULT_API_URL } from '../lib/constants.js';
|
|
15
|
+
/**
|
|
16
|
+
* Read the live daemon's PID from either the PID file or the lock dir.
|
|
17
|
+
* Returns null if no daemon is running.
|
|
18
|
+
*/
|
|
19
|
+
function readLiveDaemonPid() {
|
|
20
|
+
const candidates = [
|
|
21
|
+
path.join(process.env.HOME || '', '.promptcase', 'daemon.pid'),
|
|
22
|
+
path.join(process.env.HOME || '', '.promptcase', 'daemon.pid.lock', 'pid'),
|
|
23
|
+
];
|
|
24
|
+
for (const file of candidates) {
|
|
25
|
+
try {
|
|
26
|
+
const content = fs.readFileSync(file, 'utf-8').trim();
|
|
27
|
+
const pid = parseInt(content, 10);
|
|
28
|
+
if (!Number.isNaN(pid) && pid > 0)
|
|
29
|
+
return pid;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Try next
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Signal a process to exit gracefully. Falls back to SIGKILL if it refuses.
|
|
39
|
+
* Returns true if the process exited within `timeoutMs`, false otherwise.
|
|
40
|
+
*/
|
|
41
|
+
function signalProcess(pid, timeoutMs = 5000) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
if (process.platform === 'win32') {
|
|
44
|
+
// taskkill /F /PID to terminate; on Windows SIGTERM is emulated
|
|
45
|
+
execFile('taskkill', ['/F', '/PID', String(pid)], () => resolve(true));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 'SIGTERM');
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
resolve(true);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const start = Date.now();
|
|
56
|
+
const poll = setInterval(() => {
|
|
57
|
+
try {
|
|
58
|
+
process.kill(pid, 0); // probe
|
|
59
|
+
if (Date.now() - start > timeoutMs) {
|
|
60
|
+
// Force-kill
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 'SIGKILL');
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
clearInterval(poll);
|
|
66
|
+
resolve(true);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Process is gone
|
|
71
|
+
clearInterval(poll);
|
|
72
|
+
resolve(true);
|
|
73
|
+
}
|
|
74
|
+
}, 200);
|
|
75
|
+
// Hard ceiling
|
|
76
|
+
setTimeout(() => {
|
|
77
|
+
clearInterval(poll);
|
|
78
|
+
try {
|
|
79
|
+
process.kill(pid, 0);
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, 'SIGKILL');
|
|
82
|
+
}
|
|
83
|
+
catch { }
|
|
84
|
+
resolve(false);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
resolve(true);
|
|
88
|
+
}
|
|
89
|
+
}, timeoutMs + 1500);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
11
92
|
export function createStopCommand(apiUrl = DEFAULT_API_URL) {
|
|
12
93
|
const command = new Command('stop');
|
|
13
94
|
command
|
|
14
|
-
.description('Stop the daemon
|
|
95
|
+
.description('Stop the daemon process (auto-start survives for next boot)')
|
|
15
96
|
.action(async () => {
|
|
16
97
|
const daemon = new DaemonService(apiUrl);
|
|
17
98
|
console.log('\n🛑 Stopping PromptCase Daemon\n');
|
|
18
|
-
|
|
19
|
-
|
|
99
|
+
// Check what we know about the daemon state
|
|
100
|
+
const fileStateAlive = await daemon.isRunning();
|
|
101
|
+
const livePid = readLiveDaemonPid();
|
|
102
|
+
if (!fileStateAlive && !livePid) {
|
|
20
103
|
console.log(' Daemon is not running.\n');
|
|
21
104
|
return;
|
|
22
105
|
}
|
|
23
|
-
|
|
24
|
-
|
|
106
|
+
// Clean up files (lock, PID file, status)
|
|
107
|
+
// This is safe to do regardless of whether the process exits — the
|
|
108
|
+
// daemon's own SIGTERM handler will also clean these.
|
|
109
|
+
const fileSuccess = await daemon.stop();
|
|
110
|
+
// Actually kill the live daemon process if there is one
|
|
111
|
+
let processExited = true;
|
|
112
|
+
if (livePid && livePid !== process.pid) {
|
|
113
|
+
processExited = await signalProcess(livePid);
|
|
114
|
+
}
|
|
115
|
+
if (processExited && fileSuccess) {
|
|
25
116
|
console.log(' ✅ Daemon stopped successfully');
|
|
26
|
-
|
|
117
|
+
}
|
|
118
|
+
else if (!processExited) {
|
|
119
|
+
console.log(' ⚠️ Daemon did not stop gracefully — sending SIGKILL next time');
|
|
120
|
+
console.log(' Run: kill -9 ' + livePid);
|
|
27
121
|
}
|
|
28
122
|
else {
|
|
29
|
-
console.log(' ⚠️
|
|
30
|
-
if (process.platform === 'win32') {
|
|
31
|
-
console.log(' taskkill /F /IM node.exe');
|
|
32
|
-
}
|
|
33
|
-
else {
|
|
34
|
-
console.log(' pkill -f "promptcase start"');
|
|
35
|
-
}
|
|
123
|
+
console.log(' ⚠️ Some cleanup failed. Check "promptcase status".');
|
|
36
124
|
}
|
|
37
|
-
console.log('
|
|
38
|
-
console.log('
|
|
39
|
-
console.log(' Or manually remove the LaunchAgent / systemd service / .bat file.\n');
|
|
125
|
+
console.log(' ℹ️ Auto-start is still configured. Daemon will re-launch after reboot.');
|
|
126
|
+
console.log(' ℹ️ Run "promptcase init" to start the daemon now without rebooting.\n');
|
|
40
127
|
});
|
|
41
128
|
return command;
|
|
42
129
|
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -48,6 +48,7 @@ export function createSyncCommand(apiUrl = DEFAULT_API_URL) {
|
|
|
48
48
|
const daemonStatus = await config.getDaemonStatus();
|
|
49
49
|
console.log(` Status: Running (PID ${daemonStatus.pid})`);
|
|
50
50
|
console.log(` Last sync: ${daemonStatus.lastSyncAt ? new Date(daemonStatus.lastSyncAt).toLocaleString() : 'Never'}`);
|
|
51
|
+
console.log(' ℹ️ Daemon is already running — will defer to its 60s loop instead of double-uploading');
|
|
51
52
|
}
|
|
52
53
|
else {
|
|
53
54
|
console.log(' Status: Not running');
|
|
@@ -64,16 +65,24 @@ export function createSyncCommand(apiUrl = DEFAULT_API_URL) {
|
|
|
64
65
|
console.log(' ⚠️ Claude Code not installed (~/.claude not found)');
|
|
65
66
|
console.log(' Nothing to capture until Claude Code is installed and used.');
|
|
66
67
|
}
|
|
67
|
-
// 5. Perform sync
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
// 5. Perform sync (only if no daemon is running). When the daemon is
|
|
69
|
+
// alive, its own 60s loop will pick up any new prompts — running a
|
|
70
|
+
// second sync here would race with it and cause duplicate uploads.
|
|
71
|
+
let status = null;
|
|
72
|
+
if (isRunning) {
|
|
73
|
+
console.log('\n 🔄 Skipping foreground sync — daemon handles it');
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
console.log('\n 🔄 Triggering sync...');
|
|
77
|
+
const initialized = await daemon.initialize();
|
|
78
|
+
if (!initialized) {
|
|
79
|
+
console.log(' ❌ Failed to initialize daemon');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
status = await daemon.sync();
|
|
73
83
|
}
|
|
74
|
-
const status = await daemon.sync();
|
|
75
84
|
console.log('\n 📈 Results:');
|
|
76
|
-
if (status.lastSyncAt) {
|
|
85
|
+
if (status && status.lastSyncAt) {
|
|
77
86
|
console.log(` Last sync: ${status.lastSyncAt.toLocaleString()}`);
|
|
78
87
|
console.log(` Total synced (lifetime): ${status.promptsSynced} prompts`);
|
|
79
88
|
if (status.errors.length > 0) {
|
|
@@ -81,6 +90,13 @@ export function createSyncCommand(apiUrl = DEFAULT_API_URL) {
|
|
|
81
90
|
status.errors.slice(-3).forEach((err) => console.log(` - ${err}`));
|
|
82
91
|
}
|
|
83
92
|
}
|
|
93
|
+
else if (isRunning) {
|
|
94
|
+
// Deferred to daemon — show its status instead
|
|
95
|
+
const daemonStatus = await config.getDaemonStatus();
|
|
96
|
+
console.log(` Daemon is alive (PID ${daemonStatus.pid})`);
|
|
97
|
+
console.log(` Last sync: ${daemonStatus.lastSyncAt ? new Date(daemonStatus.lastSyncAt).toLocaleString() : 'Never'}`);
|
|
98
|
+
console.log(` Total synced: ${daemonStatus.promptsSynced ?? 0} prompts (lifetime)`);
|
|
99
|
+
}
|
|
84
100
|
// 6. If daemon is not running, start it
|
|
85
101
|
if (!isRunning) {
|
|
86
102
|
console.log('\n 🚀 Daemon was not running. Starting now in background...');
|
package/dist/services/daemon.js
CHANGED
|
@@ -40,27 +40,39 @@ export class DaemonService {
|
|
|
40
40
|
return path.join(configDir, 'daemon.pid');
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
-
* Check if daemon is already running
|
|
43
|
+
* Check if daemon is already running.
|
|
44
|
+
*
|
|
45
|
+
* Uses the lock directory as the authoritative check — the lock is the
|
|
46
|
+
* actual mutual exclusion mechanism. If a live process holds the lock,
|
|
47
|
+
* the daemon is running.
|
|
44
48
|
*/
|
|
45
49
|
async isRunning() {
|
|
46
|
-
|
|
50
|
+
const lockDir = this.pidFile + '.lock';
|
|
51
|
+
if (!fs.existsSync(lockDir)) {
|
|
47
52
|
return false;
|
|
48
53
|
}
|
|
49
54
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
// Read the PID of the holding process
|
|
56
|
+
const otherPid = parseInt(fs.readFileSync(path.join(lockDir, 'pid'), 'utf-8').trim(), 10);
|
|
57
|
+
if (Number.isNaN(otherPid) || otherPid === process.pid) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
// On Unix, probe the process with kill(pid, 0)
|
|
52
61
|
if (process.platform !== 'win32') {
|
|
53
62
|
try {
|
|
54
|
-
process.kill(
|
|
63
|
+
process.kill(otherPid, 0);
|
|
55
64
|
return true;
|
|
56
65
|
}
|
|
57
66
|
catch {
|
|
58
|
-
//
|
|
59
|
-
|
|
67
|
+
// Lock holder is dead — clean up stale lock
|
|
68
|
+
try {
|
|
69
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
catch { }
|
|
60
72
|
return false;
|
|
61
73
|
}
|
|
62
74
|
}
|
|
63
|
-
// On Windows
|
|
75
|
+
// On Windows: lock exists AND pid was readable — assume alive
|
|
64
76
|
return true;
|
|
65
77
|
}
|
|
66
78
|
catch {
|
|
@@ -85,6 +97,84 @@ export class DaemonService {
|
|
|
85
97
|
fs.unlinkSync(this.pidFile);
|
|
86
98
|
}
|
|
87
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Acquire an exclusive lock for this daemon instance.
|
|
102
|
+
*
|
|
103
|
+
* Uses POSIX-atomic `mkdir` as a cross-platform mutex. Two daemons racing
|
|
104
|
+
* to start will both try to `mkdir` the same directory; the kernel
|
|
105
|
+
* guarantees only one succeeds, the other gets EEXIST and exits cleanly.
|
|
106
|
+
*
|
|
107
|
+
* This is what prevents the duplicate-sync bug: only one daemon can hold
|
|
108
|
+
* the lock at a time. The lock directory contains a file with the
|
|
109
|
+
* current PID for diagnostics.
|
|
110
|
+
*
|
|
111
|
+
* On Windows, mkdir also fails if the directory exists, so the same
|
|
112
|
+
* pattern works there too.
|
|
113
|
+
*/
|
|
114
|
+
async acquireExclusiveLock() {
|
|
115
|
+
try {
|
|
116
|
+
const lockDir = this.pidFile + '.lock';
|
|
117
|
+
try {
|
|
118
|
+
fs.mkdirSync(lockDir);
|
|
119
|
+
// We won the race — write our PID into the lock dir for diagnostics
|
|
120
|
+
fs.writeFileSync(path.join(lockDir, 'pid'), process.pid.toString());
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (err.code === 'EEXIST') {
|
|
125
|
+
// Another daemon holds the lock. Check if it's actually alive
|
|
126
|
+
// by reading the pid file inside.
|
|
127
|
+
try {
|
|
128
|
+
const otherPid = parseInt(fs.readFileSync(path.join(lockDir, 'pid'), 'utf-8').trim(), 10);
|
|
129
|
+
if (otherPid === process.pid) {
|
|
130
|
+
// We somehow already hold the lock (shouldn't happen,
|
|
131
|
+
// but guard against it). Allow start.
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
// Probe whether the other process still exists
|
|
135
|
+
try {
|
|
136
|
+
process.kill(otherPid, 0);
|
|
137
|
+
// Yes, another daemon is alive. Refuse.
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Stale lock — the holding process is dead. Clean it up
|
|
142
|
+
// and retry once.
|
|
143
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
144
|
+
return this.acquireExclusiveLock();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Lock dir exists but we can't read the pid file. Treat as
|
|
149
|
+
// stale and force-replace.
|
|
150
|
+
try {
|
|
151
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
catch { }
|
|
154
|
+
return this.acquireExclusiveLock();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Some other FS error (EACCES, ENOSPC, etc.) — fall through
|
|
158
|
+
// and let the daemon run anyway rather than blocking start.
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Release the exclusive lock (called from `stop()` and on SIGINT/SIGTERM).
|
|
168
|
+
*/
|
|
169
|
+
async releaseExclusiveLock() {
|
|
170
|
+
try {
|
|
171
|
+
const lockDir = this.pidFile + '.lock';
|
|
172
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Best effort — if the dir doesn't exist, that's fine.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
88
178
|
/**
|
|
89
179
|
* Initialize with stored credentials
|
|
90
180
|
*/
|
|
@@ -133,14 +223,29 @@ export class DaemonService {
|
|
|
133
223
|
}
|
|
134
224
|
/**
|
|
135
225
|
* Start the daemon (foreground process)
|
|
226
|
+
*
|
|
227
|
+
* Acquires an exclusive flock on the PID file. If another daemon is
|
|
228
|
+
* already running, returns `false` immediately so the caller can exit
|
|
229
|
+
* cleanly. This is what prevents the duplicate-sync bug (two daemons
|
|
230
|
+
* each doing their own 60s sync loop on the same user account).
|
|
136
231
|
*/
|
|
137
232
|
async start() {
|
|
233
|
+
// Acquire single-instance lock FIRST — don't even read credentials
|
|
234
|
+
// if another daemon holds the lock. Saves work and avoids logging
|
|
235
|
+
// misleading "Authenticated" output during a concurrent start attempt.
|
|
236
|
+
const gotLock = await this.acquireExclusiveLock();
|
|
237
|
+
if (!gotLock) {
|
|
238
|
+
console.error('Another promptcase daemon is already running.');
|
|
239
|
+
console.error('Check `promptcase status` or run `promptcase stop` first.');
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
138
242
|
// Initialize with credentials
|
|
139
243
|
if (!await this.initialize()) {
|
|
140
244
|
console.error('No credentials found. Run "promptcase init" first.');
|
|
245
|
+
await this.releaseExclusiveLock();
|
|
141
246
|
return false;
|
|
142
247
|
}
|
|
143
|
-
// Write PID file
|
|
248
|
+
// Write PID file (lock is already held on it — same fd)
|
|
144
249
|
await this.writePidFile();
|
|
145
250
|
// Start sync loop
|
|
146
251
|
const syncInterval = await this.configService.getSyncInterval();
|
|
@@ -182,7 +287,10 @@ export class DaemonService {
|
|
|
182
287
|
setInterval(async () => {
|
|
183
288
|
await this.saveRefreshedTokens();
|
|
184
289
|
}, 60 * 60 * 1000);
|
|
185
|
-
// Handle graceful shutdown
|
|
290
|
+
// Handle graceful shutdown. Release the flock on every signal so a
|
|
291
|
+
// respawn (LaunchAgent KeepAlive) can acquire it cleanly. If the
|
|
292
|
+
// process is killed with SIGKILL or via uncaughtException, the kernel
|
|
293
|
+
// releases the flock automatically when the process exits.
|
|
186
294
|
process.on('SIGINT', async () => {
|
|
187
295
|
console.log('\n\n🛑 Stopping daemon...');
|
|
188
296
|
await this.stop();
|
|
@@ -207,6 +315,7 @@ export class DaemonService {
|
|
|
207
315
|
this.syncInterval = null;
|
|
208
316
|
}
|
|
209
317
|
await this.cleanupPidFile();
|
|
318
|
+
await this.releaseExclusiveLock();
|
|
210
319
|
this.status.isRunning = false;
|
|
211
320
|
await this.configService.setDaemonStatus({
|
|
212
321
|
isRunning: false,
|
|
@@ -347,8 +456,6 @@ export class DaemonService {
|
|
|
347
456
|
</array>
|
|
348
457
|
<key>RunAtLoad</key>
|
|
349
458
|
<true/>
|
|
350
|
-
<key>KeepAlive</key>
|
|
351
|
-
<true/>
|
|
352
459
|
<key>ProcessType</key>
|
|
353
460
|
<string>Background</string>
|
|
354
461
|
<key>StandardOutPath</key>
|