promptcase 1.0.4 → 1.0.6
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 +47 -11
- package/dist/commands/init.js +39 -6
- package/dist/commands/status.js +46 -0
- package/dist/commands/stop.js +106 -19
- package/dist/commands/sync.js +24 -8
- package/dist/index.js +0 -0
- package/dist/services/daemon.js +127 -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,18 +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 |
|
|
43
|
+
| `promptcase start` | **Internal** — run the daemon in the foreground. Invoked by the auto-start service (LaunchAgent / systemd / Startup folder). You don't normally need to run this directly. |
|
|
44
|
+
|
|
45
|
+
## How it works
|
|
46
|
+
|
|
47
|
+
1. **`promptcase init`** authenticates with your PromptCase account and spawns a single background daemon.
|
|
48
|
+
2. The daemon captures prompts from `~/.claude/projects/` and `~/.claude/history.jsonl`.
|
|
49
|
+
3. Every 60 seconds it uploads new prompts to your account.
|
|
50
|
+
4. The server deduplicates by content hash, so re-syncing the same prompt is safe.
|
|
51
|
+
5. **One daemon at a time** is guaranteed by an exclusive `mkdir`-based lock — you can't accidentally start a second one.
|
|
52
|
+
|
|
53
|
+
### Auto-start
|
|
54
|
+
|
|
55
|
+
Auto-start is installed by `init` and survives reboot:
|
|
56
|
+
|
|
57
|
+
- **macOS** — LaunchAgent at `~/Library/LaunchAgents/app.promptcase.daemon.plist` (uses `RunAtLoad=true`)
|
|
58
|
+
- **Linux** — user-level systemd service at `~/.config/systemd/user/promptcase.service` (run `systemctl --user enable promptcase` to enable)
|
|
59
|
+
- **Windows** — `.bat` in the Startup folder (runs on next user login)
|
|
41
60
|
|
|
42
61
|
## Non-interactive authentication
|
|
43
62
|
|
|
@@ -49,19 +68,36 @@ echo '{"access_token":"...","refresh_token":"...","expires_in":15552000}' | prom
|
|
|
49
68
|
|
|
50
69
|
## Configuration
|
|
51
70
|
|
|
52
|
-
|
|
71
|
+
| Path | Purpose |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `~/.promptcase/daemon.pid` | Current daemon PID |
|
|
74
|
+
| `~/.promptcase/daemon.pid.lock/` | Single-instance lock directory (created on first start) |
|
|
75
|
+
| `~/.promptcase/daemon.log` | Daemon stdout |
|
|
76
|
+
| `~/.promptcase/daemon.error.log` | Daemon stderr |
|
|
77
|
+
| `~/Library/Preferences/promptcase-nodejs/config.json` (macOS) | Credentials + sync state (managed by `conf`) |
|
|
78
|
+
| `%APPDATA%/promptcase-nodejs/config.json` (Windows) | Same, on Windows |
|
|
79
|
+
| `~/.config/configstore/promptcase-nodejs.json` (Linux) | Same, on Linux |
|
|
80
|
+
|
|
81
|
+
## Lifecycle
|
|
53
82
|
|
|
54
|
-
-
|
|
55
|
-
-
|
|
83
|
+
- `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.
|
|
84
|
+
- `promptcase logout` — kills the daemon AND removes your stored credentials.
|
|
56
85
|
|
|
57
86
|
## Troubleshooting
|
|
58
87
|
|
|
59
88
|
```bash
|
|
60
|
-
promptcase status
|
|
61
|
-
promptcase sync
|
|
62
|
-
promptcase
|
|
89
|
+
promptcase status # overall health, lifetime count, lock state
|
|
90
|
+
promptcase sync # force a sync right now
|
|
91
|
+
promptcase stop && promptcase init # full daemon restart
|
|
92
|
+
promptcase logout && promptcase init # re-authenticate from scratch
|
|
63
93
|
```
|
|
64
94
|
|
|
95
|
+
If `promptcase status` warns about multiple daemons running, run `promptcase stop` and wait a few seconds.
|
|
96
|
+
|
|
97
|
+
## Privacy
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
65
101
|
## License
|
|
66
102
|
|
|
67
|
-
MIT
|
|
103
|
+
MIT
|
package/dist/commands/init.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
import inquirer from 'inquirer';
|
|
9
9
|
import { Command } from 'commander';
|
|
10
10
|
import { exec, spawn } from 'child_process';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import path from 'path';
|
|
11
14
|
import { APIService } from '../services/api.js';
|
|
12
15
|
import { DaemonService } from '../services/daemon.js';
|
|
13
16
|
import { getConfig } from '../lib/config.js';
|
|
@@ -89,6 +92,15 @@ async function verifyAndShowUser(api) {
|
|
|
89
92
|
}
|
|
90
93
|
/**
|
|
91
94
|
* Start daemon in detached background process
|
|
95
|
+
*
|
|
96
|
+
* Polls for the lock dir to appear (the daemon writes the lock as soon as it
|
|
97
|
+
* finishes starting). This avoids the previous 1500ms fixed wait that would
|
|
98
|
+
* give false "Daemon may not have started" warnings on slow machines where
|
|
99
|
+
* Node + module load + lock acquisition took longer than 1500ms — the daemon
|
|
100
|
+
* would actually be running, but the parent had already given up and
|
|
101
|
+
* returned, leaving the user to think the spawn failed. The 1500ms was a
|
|
102
|
+
* too-tight deadline and the real signal is "lock dir exists" which means
|
|
103
|
+
* "daemon is fully initialized".
|
|
92
104
|
*/
|
|
93
105
|
async function startDaemonDetached(apiUrl) {
|
|
94
106
|
// Detach the daemon process so it runs independently
|
|
@@ -98,12 +110,21 @@ async function startDaemonDetached(apiUrl) {
|
|
|
98
110
|
windowsHide: true,
|
|
99
111
|
});
|
|
100
112
|
child.unref();
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
113
|
+
// Poll for the lock to appear. The daemon writes the lock dir as the
|
|
114
|
+
// first step of its lifecycle, so seeing it is a reliable "running" signal.
|
|
115
|
+
// Cap at 10 seconds — if the lock hasn't appeared by then, the daemon
|
|
116
|
+
// process must have died.
|
|
117
|
+
const lockPath = path.join(os.homedir(), '.promptcase', 'daemon.pid.lock');
|
|
118
|
+
const deadline = Date.now() + 10_000;
|
|
119
|
+
let acquired = false;
|
|
120
|
+
while (Date.now() < deadline) {
|
|
121
|
+
if (fs.existsSync(lockPath)) {
|
|
122
|
+
acquired = true;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
126
|
+
}
|
|
127
|
+
if (acquired) {
|
|
107
128
|
console.log(' ✅ Daemon started in background');
|
|
108
129
|
}
|
|
109
130
|
else {
|
|
@@ -249,6 +270,18 @@ export async function initFlow(apiUrl = DEFAULT_API_URL) {
|
|
|
249
270
|
const autoStartSuccess = await daemon.setupAutoStart();
|
|
250
271
|
if (autoStartSuccess) {
|
|
251
272
|
console.log(' ✅ Auto-start configured');
|
|
273
|
+
// Re-enable in case it was previously disabled via bootout. Required
|
|
274
|
+
// because `launchctl bootout` leaves the service disabled across
|
|
275
|
+
// sessions; without re-enabling, the daemon won't survive reboot.
|
|
276
|
+
if (process.platform === 'darwin') {
|
|
277
|
+
try {
|
|
278
|
+
const { execFileSync } = await import('child_process');
|
|
279
|
+
execFileSync('launchctl', ['enable', `gui/${process.getuid?.() ?? ''}/app.promptcase.daemon`], { stdio: 'ignore' });
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Best effort — keep going
|
|
283
|
+
}
|
|
284
|
+
}
|
|
252
285
|
}
|
|
253
286
|
else {
|
|
254
287
|
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/index.js
CHANGED
|
File without changes
|
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,92 @@ 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
|
+
// Ensure the parent dir exists before we try to mkdir the lock inside
|
|
118
|
+
// it. Without this, the very first run on a clean machine gets ENOENT
|
|
119
|
+
// because ~/.promptcase/ doesn't exist yet (writePidFile creates it
|
|
120
|
+
// later, but only if it gets to run).
|
|
121
|
+
const parentDir = path.dirname(lockDir);
|
|
122
|
+
if (!fs.existsSync(parentDir)) {
|
|
123
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
fs.mkdirSync(lockDir);
|
|
127
|
+
// We won the race — write our PID into the lock dir for diagnostics
|
|
128
|
+
fs.writeFileSync(path.join(lockDir, 'pid'), process.pid.toString());
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
if (err.code === 'EEXIST') {
|
|
133
|
+
// Another daemon holds the lock. Check if it's actually alive
|
|
134
|
+
// by reading the pid file inside.
|
|
135
|
+
try {
|
|
136
|
+
const otherPid = parseInt(fs.readFileSync(path.join(lockDir, 'pid'), 'utf-8').trim(), 10);
|
|
137
|
+
if (otherPid === process.pid) {
|
|
138
|
+
// We somehow already hold the lock (shouldn't happen,
|
|
139
|
+
// but guard against it). Allow start.
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
// Probe whether the other process still exists
|
|
143
|
+
try {
|
|
144
|
+
process.kill(otherPid, 0);
|
|
145
|
+
// Yes, another daemon is alive. Refuse.
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Stale lock — the holding process is dead. Clean it up
|
|
150
|
+
// and retry once.
|
|
151
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
152
|
+
return this.acquireExclusiveLock();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Lock dir exists but we can't read the pid file. Treat as
|
|
157
|
+
// stale and force-replace.
|
|
158
|
+
try {
|
|
159
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
catch { }
|
|
162
|
+
return this.acquireExclusiveLock();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Some other FS error (EACCES, ENOSPC, etc.) — fall through
|
|
166
|
+
// and let the daemon run anyway rather than blocking start.
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Release the exclusive lock (called from `stop()` and on SIGINT/SIGTERM).
|
|
176
|
+
*/
|
|
177
|
+
async releaseExclusiveLock() {
|
|
178
|
+
try {
|
|
179
|
+
const lockDir = this.pidFile + '.lock';
|
|
180
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Best effort — if the dir doesn't exist, that's fine.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
88
186
|
/**
|
|
89
187
|
* Initialize with stored credentials
|
|
90
188
|
*/
|
|
@@ -133,14 +231,29 @@ export class DaemonService {
|
|
|
133
231
|
}
|
|
134
232
|
/**
|
|
135
233
|
* Start the daemon (foreground process)
|
|
234
|
+
*
|
|
235
|
+
* Acquires an exclusive flock on the PID file. If another daemon is
|
|
236
|
+
* already running, returns `false` immediately so the caller can exit
|
|
237
|
+
* cleanly. This is what prevents the duplicate-sync bug (two daemons
|
|
238
|
+
* each doing their own 60s sync loop on the same user account).
|
|
136
239
|
*/
|
|
137
240
|
async start() {
|
|
241
|
+
// Acquire single-instance lock FIRST — don't even read credentials
|
|
242
|
+
// if another daemon holds the lock. Saves work and avoids logging
|
|
243
|
+
// misleading "Authenticated" output during a concurrent start attempt.
|
|
244
|
+
const gotLock = await this.acquireExclusiveLock();
|
|
245
|
+
if (!gotLock) {
|
|
246
|
+
console.error('Another promptcase daemon is already running.');
|
|
247
|
+
console.error('Check `promptcase status` or run `promptcase stop` first.');
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
138
250
|
// Initialize with credentials
|
|
139
251
|
if (!await this.initialize()) {
|
|
140
252
|
console.error('No credentials found. Run "promptcase init" first.');
|
|
253
|
+
await this.releaseExclusiveLock();
|
|
141
254
|
return false;
|
|
142
255
|
}
|
|
143
|
-
// Write PID file
|
|
256
|
+
// Write PID file (lock is already held on it — same fd)
|
|
144
257
|
await this.writePidFile();
|
|
145
258
|
// Start sync loop
|
|
146
259
|
const syncInterval = await this.configService.getSyncInterval();
|
|
@@ -182,7 +295,10 @@ export class DaemonService {
|
|
|
182
295
|
setInterval(async () => {
|
|
183
296
|
await this.saveRefreshedTokens();
|
|
184
297
|
}, 60 * 60 * 1000);
|
|
185
|
-
// Handle graceful shutdown
|
|
298
|
+
// Handle graceful shutdown. Release the flock on every signal so a
|
|
299
|
+
// respawn (LaunchAgent KeepAlive) can acquire it cleanly. If the
|
|
300
|
+
// process is killed with SIGKILL or via uncaughtException, the kernel
|
|
301
|
+
// releases the flock automatically when the process exits.
|
|
186
302
|
process.on('SIGINT', async () => {
|
|
187
303
|
console.log('\n\n🛑 Stopping daemon...');
|
|
188
304
|
await this.stop();
|
|
@@ -207,6 +323,7 @@ export class DaemonService {
|
|
|
207
323
|
this.syncInterval = null;
|
|
208
324
|
}
|
|
209
325
|
await this.cleanupPidFile();
|
|
326
|
+
await this.releaseExclusiveLock();
|
|
210
327
|
this.status.isRunning = false;
|
|
211
328
|
await this.configService.setDaemonStatus({
|
|
212
329
|
isRunning: false,
|
|
@@ -347,8 +464,6 @@ export class DaemonService {
|
|
|
347
464
|
</array>
|
|
348
465
|
<key>RunAtLoad</key>
|
|
349
466
|
<true/>
|
|
350
|
-
<key>KeepAlive</key>
|
|
351
|
-
<true/>
|
|
352
467
|
<key>ProcessType</key>
|
|
353
468
|
<string>Background</string>
|
|
354
469
|
<key>StandardOutPath</key>
|