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 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 runs in the background and auto-starts on system boot (LaunchAgent on macOS, user-level systemd on Linux, Startup folder on Windows).
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
- Config and credentials are stored in `~/.promptcase/`. Logs:
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
- - `~/.promptcase/daemon.log` (stdout)
55
- - `~/.promptcase/daemon.error.log` (stderr)
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 # overall health
61
- promptcase sync # diagnose and force a refresh
62
- promptcase logout && promptcase init # reset credentials
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
@@ -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');
@@ -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');
@@ -1,42 +1,129 @@
1
1
  /**
2
- * Stop command - Stop daemon for this OS session
2
+ * Stop command - Stop the daemon process
3
3
  *
4
4
  * Behavior:
5
- * - Stops the daemon process if running
6
- * - Keeps auto-start configuration intact so daemon restarts on next boot
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 for this OS session (auto-start on next boot preserved)')
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
- const isRunning = await daemon.isRunning();
19
- if (!isRunning) {
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
- const success = await daemon.stop();
24
- if (success) {
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
- console.log(' ℹ️ Auto-start is still configured. Daemon will restart on next boot.');
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(' ⚠️ Could not stop daemon gracefully. Kill the process manually:');
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('\n Auto-start preserved. To completely remove auto-start, run:');
38
- console.log(' promptcase logout');
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
  }
@@ -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
- console.log('\n 🔄 Triggering sync...');
69
- const initialized = await daemon.initialize();
70
- if (!initialized) {
71
- console.log(' ❌ Failed to initialize daemon');
72
- return;
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...');
@@ -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
- if (!fs.existsSync(this.pidFile)) {
50
+ const lockDir = this.pidFile + '.lock';
51
+ if (!fs.existsSync(lockDir)) {
47
52
  return false;
48
53
  }
49
54
  try {
50
- const pid = parseInt(fs.readFileSync(this.pidFile, 'utf-8').trim(), 10);
51
- // On Unix, check if process exists
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(pid, 0);
63
+ process.kill(otherPid, 0);
55
64
  return true;
56
65
  }
57
66
  catch {
58
- // Process doesn't exist
59
- await this.cleanupPidFile();
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, just check if PID file exists
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptcase",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "CLI daemon to capture and sync AI prompts to PromptCase web app",
5
5
  "main": "dist/index.js",
6
6
  "bin": {