apple-tools-mcp 1.1.4 → 1.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/README.md CHANGED
@@ -39,6 +39,8 @@ If you installed from source, point your MCP client at the local `index.js` inst
39
39
  "args": ["/absolute/path/to/Apple-Tools-MCP/index.js"]
40
40
  ```
41
41
 
42
+ **Mac Mini** stays on a **global npm** install (`npm install -g apple-tools-mcp`) — no git clone on Mini. **MacBook / development** uses the clone above.
43
+
42
44
  ### 2. Grant Full Disk Access
43
45
 
44
46
  The MCP server needs access to read your Mail, Messages, and Calendar databases.
@@ -89,15 +91,22 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
89
91
 
90
92
  Other clients use their own settings UI or config file. Use the same `command` and `args`; only the file path or UI differs.
91
93
 
94
+ MCP clients are **short-lived stdio** processes: they exit when the client closes stdin. Always-on indexing belongs on the **indexer daemon**, not a sleep-pipe wrapper around this binary.
95
+
92
96
  ### 4. Restart your MCP client
93
97
 
94
98
  Quit and reopen the client so it loads the server. For Claude Desktop, fully quit (Cmd+Q) and reopen.
95
99
 
96
100
  ## Building the Index
97
101
 
98
- On first use, the server will automatically build a vector index of your emails, messages, and calendar events. Email history is unlimited by default. This may take a while depending on the volume of data.
102
+ On first use, a vector index of your emails, messages, and calendar events is built automatically. Email history is unlimited by default. This may take a while depending on the volume of data.
103
+
104
+ **Who indexes**
99
105
 
100
- You can manually rebuild the index:
106
+ - **Indexer daemon running** (recommended on Mac Mini): the daemon owns `~/.apple-tools-mcp/indexer.lock` and refreshes `~/.apple-tools-mcp/vector-index/`. MCP stdio clients only search; they do not start background refresh.
107
+ - **No daemon** (default MacBook / Claude Desktop / Cursor): the MCP stdio process indexes **locally on startup**, same as previous versions, then exits when the client disconnects.
108
+
109
+ You can manually rebuild the index (stop the indexer daemon first if it is running, so it is not writing at the same time):
101
110
 
102
111
  ```bash
103
112
  # Index all email history (default)
@@ -109,6 +118,96 @@ APPLE_TOOLS_INDEX_DAYS_BACK=30 npm run build-index
109
118
 
110
119
  The index is stored in `~/.apple-tools-mcp/vector-index/`.
111
120
 
121
+ ## Index refresh interval
122
+
123
+ Resolved **once at process start**. Precedence (highest wins):
124
+
125
+ 1. `INDEX_INTERVAL_MS` environment variable (milliseconds or human form: `30s`, `1m`, `5m`, `1h`)
126
+ 2. `~/.apple-tools-mcp/config.json` keys `indexInterval` or `indexIntervalMs`
127
+ 3. Product default: **5 minutes** (`300000` ms) — typical MacBook / MCP local-fallback
128
+
129
+ Values are **clamped** to **15 seconds** minimum and **6 hours** maximum. Invalid JSON, unknown keys, and unparseable intervals are logged and ignored (the process does not crash). The effective interval is logged at start, for example:
130
+
131
+ ```text
132
+ Effective index refresh interval: 1m (60000 ms) [source=config]
133
+ ```
134
+
135
+ A warn line is also logged when clamping occurs.
136
+
137
+ ### Example `~/.apple-tools-mcp/config.json` (Mac Mini)
138
+
139
+ Recommended Mini always-on interval is **1 minute**. 30 seconds is allowed (at or above the 15s floor).
140
+
141
+ ```json
142
+ {
143
+ "indexInterval": "1m"
144
+ }
145
+ ```
146
+
147
+ Equivalent: `"indexIntervalMs": 60000`, or `INDEX_INTERVAL_MS=60000` (env overrides the file).
148
+
149
+ Missing `config.json` is fine — env then the 5-minute default apply.
150
+
151
+ ## Always-on indexer (Mac Mini LaunchAgent)
152
+
153
+ On Mini, run the **indexer daemon**, not a sleep-pipe wrapper around `apple-tools-mcp`. Grok Bot, Claude Desktop, and other clients still attach via short-lived stdio MCP (`npx -y apple-tools-mcp` or the global `apple-tools-mcp` bin).
154
+
155
+ **Entrypoint:** `node index.js --mode=indexer`
156
+ **Convenience bin:** `apple-tools-indexer` (same file; npm global install provides it)
157
+ **npm script (clone only):** `npm run indexer`
158
+ **One-shot rebuild:** `npm run build-index` (stop the indexer daemon first)
159
+
160
+ LaunchAgent should invoke **node + `--mode=indexer`** on the **global** package (Mini has no git clone). LaunchAgent does not inherit your shell `PATH`, so use absolute paths from `which node` and `npm root -g`.
161
+
162
+ ```bash
163
+ which node
164
+ # Apple Silicon Homebrew example: /opt/homebrew/bin/node
165
+ # Intel Homebrew / usr/local example: /usr/local/bin/node
166
+
167
+ npm root -g
168
+ # Example: /opt/homebrew/lib/node_modules
169
+ ```
170
+
171
+ Example `~/Library/LaunchAgents/com.apple-tools-mcp.indexer.plist`:
172
+
173
+ ```xml
174
+ <?xml version="1.0" encoding="UTF-8"?>
175
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
176
+ <plist version="1.0">
177
+ <dict>
178
+ <key>Label</key>
179
+ <string>com.apple-tools-mcp.indexer</string>
180
+ <key>ProgramArguments</key>
181
+ <array>
182
+ <string>/opt/homebrew/bin/node</string>
183
+ <string>/opt/homebrew/lib/node_modules/apple-tools-mcp/index.js</string>
184
+ <string>--mode=indexer</string>
185
+ </array>
186
+ <key>RunAtLoad</key>
187
+ <true/>
188
+ <key>KeepAlive</key>
189
+ <true/>
190
+ <key>EnvironmentVariables</key>
191
+ <dict>
192
+ <key>PATH</key>
193
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
194
+ </dict>
195
+ <key>StandardOutPath</key>
196
+ <string>/tmp/apple-tools-indexer.out.log</string>
197
+ <key>StandardErrorPath</key>
198
+ <string>/tmp/apple-tools-indexer.err.log</string>
199
+ </dict>
200
+ </plist>
201
+ ```
202
+
203
+ Replace the node and `node_modules` paths with the values from `which node` and `npm root -g`. Load it with:
204
+
205
+ ```bash
206
+ launchctl load ~/Library/LaunchAgents/com.apple-tools-mcp.indexer.plist
207
+ ```
208
+
209
+ KeepAlive belongs on this indexer job only — not on the MCP stdio process.
210
+
112
211
  ## Available Tools
113
212
 
114
213
  Once configured, your MCP client can use these tools:
@@ -206,12 +305,15 @@ Ensure Node.js has Full Disk Access (see Installation step 2).
206
305
  If the index becomes corrupted or out of sync:
207
306
 
208
307
  ```bash
308
+ # If the Mini indexer LaunchAgent is running, unload it first
309
+ # launchctl unload ~/Library/LaunchAgents/com.apple-tools-mcp.indexer.plist
310
+
209
311
  # Remove existing index files
210
312
  rm -rf ~/.apple-tools-mcp/vector-index
211
313
  rm -f ~/.apple-tools-mcp/index-meta.json
212
314
  rm -f ~/.apple-tools-mcp/indexer.lock
213
315
 
214
- # Restart your MCP client to trigger a fresh rebuild
316
+ # Restart the indexer daemon or your MCP client to trigger a fresh rebuild
215
317
  ```
216
318
 
217
319
  ### Monitor indexing progress
@@ -250,12 +352,15 @@ npm install -D vitest @vitest/coverage-v8 fast-check
250
352
  # Run tests
251
353
  npm test
252
354
 
253
- # Run tests with verbose coverage report
254
- npx vitest run --coverage --reporter=verbose
355
+ # Run the indexer daemon (owns indexer.lock + vector-index refresh)
356
+ npm run indexer
255
357
 
256
- # Build index with debug output
358
+ # One-shot rebuild (stop the indexer daemon first)
257
359
  npm run build-index
258
360
 
361
+ # Run tests with verbose coverage report
362
+ npx vitest run --coverage --reporter=verbose
363
+
259
364
  # Run audit to check index health
260
365
  npm run audit
261
366
  ```
package/index.js CHANGED
@@ -10,142 +10,110 @@ import fs from "fs";
10
10
  import path from "path";
11
11
  import { validateEmailPath, stripHtmlTags, unfoldRfc822Headers, validateLimit, validateDaysBack, validateWeekOffset, toUnixMillis } from "./lib/validators.js";
12
12
  import { isSearchBlockedByIndexing, cycleEndFlags, indexUnavailableMessage } from "./lib/indexGate.js";
13
+ import { isIndexerMode } from "./lib/processMode.js";
14
+ import { loadResolvedIndexInterval, logResolvedInterval } from "./lib/config.js";
15
+ import { createIndexerLock, DEFAULT_LOCK_HEARTBEAT_MS } from "./lib/indexerLock.js";
16
+ import {
17
+ shouldConnectMcpStdio,
18
+ bindStdinCloseExit,
19
+ beginIndexCycle,
20
+ applyIndexerCycleEnd,
21
+ mcpIndexingStartup,
22
+ waitForIndexerLock,
23
+ beginOwnedIndexing
24
+ } from "./lib/indexerRuntime.js";
13
25
 
14
26
  const PACKAGE_VERSION = JSON.parse(
15
27
  fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
16
28
  ).version;
17
29
 
30
+ // Canonical indexer entrypoint: `node index.js --mode=indexer` or `apple-tools-indexer`.
31
+ const INDEXER_MODE = isIndexerMode();
32
+ const resolvedIndexInterval = loadResolvedIndexInterval();
33
+ const INDEX_INTERVAL = resolvedIndexInterval.ms;
34
+ const LOCK_HEARTBEAT_MS = DEFAULT_LOCK_HEARTBEAT_MS;
35
+ const LOCK_RETRY_MS = 5 * 1000;
36
+
18
37
  // Lock file to prevent duplicate indexing processes
19
38
  const LOCK_FILE = path.join(process.env.HOME, ".apple-tools-mcp", "indexer.lock");
20
- const LOCK_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - if lock is older, assume hung process
39
+ const indexerLock = createIndexerLock({
40
+ lockFile: LOCK_FILE,
41
+ log: (msg) => console.error(msg)
42
+ });
21
43
  // True only while this process won the indexer lock. Distinct from
22
44
  // sessionIndexComplete: a secondary instance that lost the lock never
23
45
  // completes a local cycle and must not stay on "still indexing" forever.
24
46
  let ownsIndexLock = false;
25
47
 
26
48
  function acquireLock() {
27
- try {
28
- // Ensure directory exists first
29
- const lockDir = path.dirname(LOCK_FILE);
30
- if (!fs.existsSync(lockDir)) {
31
- fs.mkdirSync(lockDir, { recursive: true });
32
- }
49
+ const ok = indexerLock.acquire();
50
+ ownsIndexLock = indexerLock.ownsLock;
51
+ if (ok) {
52
+ startLockHeartbeat();
53
+ }
54
+ return ok;
55
+ }
33
56
 
34
- // Check for existing lock file
35
- if (fs.existsSync(LOCK_FILE)) {
36
- const lockData = fs.readFileSync(LOCK_FILE, "utf8");
37
- const [pidStr, timestampStr] = lockData.split(':');
38
- const pid = parseInt(pidStr);
39
- const timestamp = parseInt(timestampStr) || Date.now();
40
- const lockAge = Date.now() - timestamp;
41
-
42
- // If we already hold the lock, return true
43
- if (pid === process.pid) {
44
- ownsIndexLock = true;
45
- return true;
46
- }
57
+ function releaseLock() {
58
+ indexerLock.release();
59
+ ownsIndexLock = indexerLock.ownsLock;
60
+ if (!ownsIndexLock) {
61
+ stopLockHeartbeat();
62
+ }
63
+ }
47
64
 
48
- try {
49
- process.kill(pid, 0); // Check if process exists (signal 0 = no-op)
50
-
51
- // Process exists - check if lock is stale (hung process)
52
- if (lockAge > LOCK_TIMEOUT_MS) {
53
- console.error(`Lock file is ${Math.round(lockAge / 60000)} minutes old. Assuming hung process (PID ${pid}). Removing stale lock.`);
54
- fs.unlinkSync(LOCK_FILE);
55
- } else {
56
- console.error(`Another indexing instance running (PID ${pid}). Skipping indexing.`);
57
- ownsIndexLock = false;
58
- return false;
59
- }
60
- } catch {
61
- // Process doesn't exist, stale lock file - remove it
62
- console.error(`Removing stale lock file (PID ${pid} not running)`);
63
- fs.unlinkSync(LOCK_FILE);
64
- }
65
- }
65
+ function startLockHeartbeat() {
66
+ indexerLock.startHeartbeat(LOCK_HEARTBEAT_MS);
67
+ }
66
68
 
67
- // Use atomic 'wx' flag to create lock file exclusively
68
- // This prevents TOCTOU race condition - will throw EEXIST if file was created between check and write
69
- try {
70
- fs.writeFileSync(LOCK_FILE, `${process.pid}:${Date.now()}`, { flag: 'wx' });
71
- ownsIndexLock = true;
72
- return true;
73
- } catch (err) {
74
- if (err.code === 'EEXIST') {
75
- // Another process won the race
76
- console.error("Another process acquired lock during race. Skipping indexing.");
77
- ownsIndexLock = false;
78
- return false;
79
- }
80
- throw err; // Re-throw unexpected errors
81
- }
82
- } catch (e) {
83
- console.error("Lock file error:", e.message);
84
- ownsIndexLock = false;
85
- return false; // On error, fail safe - don't proceed
86
- }
69
+ function stopLockHeartbeat() {
70
+ indexerLock.stopHeartbeat();
87
71
  }
88
72
 
89
- function releaseLock() {
90
- try {
91
- if (fs.existsSync(LOCK_FILE)) {
92
- const lockData = fs.readFileSync(LOCK_FILE, "utf8");
93
- const [pidStr] = lockData.split(':');
94
- const pid = parseInt(pidStr);
95
- if (pid === process.pid) {
96
- fs.unlinkSync(LOCK_FILE);
97
- ownsIndexLock = false;
98
- console.error(`Released lock file (PID ${process.pid})`);
99
- }
100
- }
101
- } catch (err) {
102
- // Log error but don't throw - we're likely shutting down
103
- console.error(`Error releasing lock: ${err.message}`);
73
+ function shutdownIndexing(exitCode) {
74
+ stopBackgroundIndexing();
75
+ stopLockHeartbeat();
76
+ releaseLock();
77
+ if (exitCode !== undefined) {
78
+ process.exit(exitCode);
104
79
  }
105
80
  }
106
81
 
107
82
  // Clean up lock and timer on exit
108
83
  process.on("exit", () => {
109
84
  stopBackgroundIndexing();
85
+ stopLockHeartbeat();
110
86
  releaseLock();
111
87
  });
112
88
  process.on("SIGINT", () => {
113
- stopBackgroundIndexing();
114
- releaseLock();
89
+ shutdownIndexing();
115
90
  process.exit();
116
91
  });
117
92
  process.on("SIGTERM", () => {
118
- stopBackgroundIndexing();
119
- releaseLock();
93
+ shutdownIndexing();
120
94
  process.exit();
121
95
  });
122
96
  process.on("SIGHUP", () => {
123
- stopBackgroundIndexing();
124
- releaseLock();
97
+ shutdownIndexing();
125
98
  process.exit();
126
99
  });
127
100
 
128
101
  // Handle uncaught errors - cleanup before crashing
129
102
  process.on("uncaughtException", (err) => {
130
103
  console.error("Uncaught exception:", err);
131
- stopBackgroundIndexing();
132
- releaseLock();
133
- process.exit(1);
104
+ shutdownIndexing(1);
134
105
  });
135
106
 
136
107
  process.on("unhandledRejection", (reason, promise) => {
137
108
  console.error("Unhandled rejection at:", promise, "reason:", reason);
138
- stopBackgroundIndexing();
139
- releaseLock();
140
- process.exit(1);
109
+ shutdownIndexing(1);
141
110
  });
142
111
 
143
- // Exit when stdin closes (MCP client disconnected)
144
- process.stdin.on("close", () => {
112
+ // MCP stdio clients exit when the host closes stdin. The indexer daemon must
113
+ // not — LaunchAgent / KeepAlive often attaches stdin to /dev/null.
114
+ bindStdinCloseExit(process.stdin, INDEXER_MODE, () => {
145
115
  console.error("Client disconnected. Exiting.");
146
- stopBackgroundIndexing();
147
- releaseLock();
148
- process.exit(0);
116
+ shutdownIndexing(0);
149
117
  });
150
118
 
151
119
  // Vector search imports
@@ -199,10 +167,9 @@ let sessionIndexComplete = false; // Track if this session's indexing is done
199
167
  let isFirstEverRun = true; // True if no index exists yet
200
168
  let lastIndexTime = 0;
201
169
  let lastProgressTime = 0; // Track when we last made progress (for hung detection)
202
- // Allow environment variable to override default 5-minute interval
203
- const INDEX_INTERVAL = parseInt(process.env.INDEX_INTERVAL_MS || (5 * 60 * 1000));
204
170
  let indexTimer = null;
205
171
  let progressCheckTimer = null;
172
+ let loggedIndexInterval = false;
206
173
 
207
174
  // Check if this is the first ever run (no index exists)
208
175
  async function checkIfFirstRun() {
@@ -223,13 +190,15 @@ function getIndexingMessage() {
223
190
 
224
191
  // Run a single indexing cycle (called by background timer)
225
192
  function runIndexCycle() {
226
- if (indexingInProgress) {
227
- console.error("Indexing already in progress, skipping cycle");
193
+ const cycle = beginIndexCycle(indexingInProgress);
194
+ if (!cycle.started) {
228
195
  return;
229
196
  }
197
+ indexingInProgress = cycle.indexingInProgress;
230
198
 
231
199
  // Safety net: check lock before indexing
232
200
  if (!acquireLock()) {
201
+ indexingInProgress = false;
233
202
  console.error("Another instance is indexing. Skipping.");
234
203
  return;
235
204
  }
@@ -299,6 +268,11 @@ function triggerIndexIfNeeded() {
299
268
 
300
269
  // Start continuous background indexing
301
270
  function startBackgroundIndexing() {
271
+ if (!loggedIndexInterval) {
272
+ logResolvedInterval(resolvedIndexInterval);
273
+ loggedIndexInterval = true;
274
+ }
275
+
302
276
  // Run indexing immediately on startup
303
277
  runIndexCycle();
304
278
 
@@ -307,7 +281,7 @@ function startBackgroundIndexing() {
307
281
  runIndexCycle();
308
282
  }, INDEX_INTERVAL);
309
283
 
310
- console.error(`Background indexing started (interval: ${INDEX_INTERVAL / 1000}s)`);
284
+ console.error(`Background indexing started (interval: ${resolvedIndexInterval.human} / ${INDEX_INTERVAL} ms)`);
311
285
  }
312
286
 
313
287
  // Stop background indexing and clean up timers
@@ -323,17 +297,22 @@ function stopBackgroundIndexing() {
323
297
  console.error("Background indexing stopped");
324
298
  }
325
299
 
326
- // Unblock searches and drop the indexer lock after a cycle ends.
327
- // Must run on failure as well as success so tools are not stuck forever.
300
+ // Unblock searches after a cycle ends. Must run on failure as well as success
301
+ // so tools are not stuck forever. The indexer daemon keeps indexer.lock for
302
+ // the process lifetime; MCP local-fallback still releases between cycles.
328
303
  function applyCycleEnd(success) {
329
- const flags = cycleEndFlags(success);
330
- indexingInProgress = flags.indexingInProgress;
331
- sessionIndexComplete = flags.sessionIndexComplete;
332
- ownsIndexLock = flags.ownsIndexLock;
333
- if (flags.isFirstEverRun === false) {
304
+ const result = applyIndexerCycleEnd({
305
+ success,
306
+ indexerMode: INDEXER_MODE,
307
+ cycleEndFlags,
308
+ releaseLock
309
+ });
310
+ indexingInProgress = result.indexingInProgress;
311
+ sessionIndexComplete = result.sessionIndexComplete;
312
+ ownsIndexLock = result.ownsIndexLock;
313
+ if (result.isFirstEverRun === false) {
334
314
  isFirstEverRun = false;
335
315
  }
336
- releaseLock();
337
316
  }
338
317
 
339
318
  // Index-backed tools wait only while THIS process owns the lock and has not
@@ -345,13 +324,35 @@ function stillIndexingMessage() {
345
324
  return null;
346
325
  }
347
326
 
327
+ function waitForLockAndStartDaemon() {
328
+ waitForIndexerLock(acquireLock, {
329
+ retryMs: LOCK_RETRY_MS,
330
+ onAcquired: () => {
331
+ beginOwnedIndexing({
332
+ startHeartbeat: startLockHeartbeat,
333
+ startBackground: startBackgroundIndexing
334
+ });
335
+ }
336
+ });
337
+ }
338
+
348
339
  // Initialize and start indexing
349
340
  async function initializeIndexing() {
350
341
  isFirstEverRun = await checkIfFirstRun();
351
342
 
352
- // Try to acquire lock - if another instance is indexing, skip background
353
- // indexing but keep the MCP server running so search still works.
354
- if (!acquireLock()) {
343
+ if (INDEXER_MODE) {
344
+ console.error(`Apple Tools MCP indexer running (v${PACKAGE_VERSION})`);
345
+ logResolvedInterval(resolvedIndexInterval);
346
+ loggedIndexInterval = true;
347
+ waitForLockAndStartDaemon();
348
+ return;
349
+ }
350
+
351
+ // MCP stdio: if the indexer daemon (or another instance) holds the lock,
352
+ // skip background refresh and use the shared index. If nothing holds the
353
+ // lock, index locally as before so the stdio happy path still works.
354
+ const startup = mcpIndexingStartup(() => acquireLock());
355
+ if (!startup.startBackground) {
355
356
  console.error("Another apple-tools-mcp instance is indexing. Server will run without background indexing.");
356
357
  // Lost lock is not "still indexing": this process will never complete a
357
358
  // local cycle. Searches proceed whenever isIndexReady() is true.
@@ -359,8 +360,13 @@ async function initializeIndexing() {
359
360
  return;
360
361
  }
361
362
 
362
- // Start background indexing
363
- startBackgroundIndexing();
363
+ // Start background indexing (local fallback when no daemon is running)
364
+ // using the same heartbeat path as the daemon so a long first-index cycle
365
+ // cannot look like a stale lock to another waiter.
366
+ beginOwnedIndexing({
367
+ startHeartbeat: startLockHeartbeat,
368
+ startBackground: startBackgroundIndexing
369
+ });
364
370
  }
365
371
 
366
372
  // Start indexing immediately on server startup
@@ -1524,7 +1530,10 @@ async function main() {
1524
1530
  const transport = new StdioServerTransport();
1525
1531
  await server.connect(transport);
1526
1532
  console.error(`Apple Tools MCP server running (v${PACKAGE_VERSION})`);
1527
- // Background indexing runs automatically on startup and every INDEX_INTERVAL
1533
+ // Background indexing: indexer daemon when --mode=indexer; otherwise local
1534
+ // fallback on this stdio process only if indexer.lock is free.
1528
1535
  }
1529
1536
 
1530
- main().catch(console.error);
1537
+ if (shouldConnectMcpStdio(INDEXER_MODE)) {
1538
+ main().catch(console.error);
1539
+ }
package/lib/config.js ADDED
@@ -0,0 +1,312 @@
1
+ /**
2
+ * User config for apple-tools-mcp.
3
+ *
4
+ * Path is fixed at ~/.apple-tools-mcp/config.json (or $HOME). Arbitrary paths
5
+ * from config contents are ignored — config is treated as data, not as
6
+ * instructions or path overrides.
7
+ *
8
+ * Interval precedence (highest wins):
9
+ * 1. INDEX_INTERVAL_MS environment variable
10
+ * 2. config.json `indexInterval` (or `indexIntervalMs`)
11
+ * 3. Product default (5 minutes)
12
+ *
13
+ * Values are clamped to [15s, 6h]. Human forms like `30s`, `1m`, `1h` are accepted.
14
+ */
15
+
16
+ import fs from "fs";
17
+ import os from "os";
18
+ import path from "path";
19
+
20
+ export const APPLE_TOOLS_DIR_NAME = ".apple-tools-mcp";
21
+ export const CONFIG_FILE_NAME = "config.json";
22
+
23
+ /** MacBook / MCP local-fallback default. */
24
+ export const DEFAULT_INDEX_INTERVAL_MS = 5 * 60 * 1000;
25
+
26
+ /** Documented floor: 15 seconds (30s remains allowed). */
27
+ export const MIN_INDEX_INTERVAL_MS = 15 * 1000;
28
+
29
+ /** Documented ceiling: 6 hours. */
30
+ export const MAX_INDEX_INTERVAL_MS = 6 * 60 * 60 * 1000;
31
+
32
+ /** Recommended Mini always-on value (set in config.json, not the product default). */
33
+ export const MINI_RECOMMENDED_INDEX_INTERVAL_MS = 60 * 1000;
34
+
35
+ const KNOWN_CONFIG_KEYS = new Set(["indexInterval", "indexIntervalMs"]);
36
+
37
+ const MAX_DURATION_STRING_LENGTH = 32;
38
+
39
+ function defaultWarn(message) {
40
+ console.error(message);
41
+ }
42
+
43
+ /**
44
+ * Directory that holds config.json, indexer.lock, and vector-index.
45
+ * Always under the user home directory — never a path from config contents.
46
+ *
47
+ * @param {{ env?: NodeJS.ProcessEnv, homedir?: () => string }} [options]
48
+ * @returns {string}
49
+ */
50
+ export function getAppleToolsDir(options = {}) {
51
+ const env = options.env || process.env;
52
+ const homedir = options.homedir || (() => os.homedir());
53
+ const home = env.HOME || homedir();
54
+ return path.join(home, APPLE_TOOLS_DIR_NAME);
55
+ }
56
+
57
+ /**
58
+ * @param {{ env?: NodeJS.ProcessEnv, homedir?: () => string }} [options]
59
+ * @returns {string}
60
+ */
61
+ export function getConfigPath(options = {}) {
62
+ return path.join(getAppleToolsDir(options), CONFIG_FILE_NAME);
63
+ }
64
+
65
+ /**
66
+ * Parse a millisecond count or human duration (`500ms`, `30s`, `1m`, `1h`).
67
+ * @param {unknown} value
68
+ * @returns {number|null} milliseconds, or null if unparseable
69
+ */
70
+ export function parseDuration(value) {
71
+ if (typeof value === "number") {
72
+ if (!Number.isFinite(value)) {
73
+ return null;
74
+ }
75
+ return value;
76
+ }
77
+ if (typeof value !== "string") {
78
+ return null;
79
+ }
80
+ const trimmed = value.trim();
81
+ if (trimmed.length === 0 || trimmed.length > MAX_DURATION_STRING_LENGTH) {
82
+ return null;
83
+ }
84
+ if (/^\d+$/.test(trimmed)) {
85
+ const n = Number(trimmed);
86
+ if (!Number.isSafeInteger(n)) {
87
+ return null;
88
+ }
89
+ return n;
90
+ }
91
+ const match = /^(\d+)(ms|s|m|h)$/i.exec(trimmed);
92
+ if (!match) {
93
+ return null;
94
+ }
95
+ const n = Number(match[1]);
96
+ if (!Number.isSafeInteger(n)) {
97
+ return null;
98
+ }
99
+ const unit = match[2].toLowerCase();
100
+ const multiplier = unit === "ms" ? 1
101
+ : unit === "s" ? 1000
102
+ : unit === "m" ? 60 * 1000
103
+ : 60 * 60 * 1000;
104
+ const result = n * multiplier;
105
+ if (!Number.isSafeInteger(result)) {
106
+ return null;
107
+ }
108
+ return result;
109
+ }
110
+
111
+ /**
112
+ * Compact human form for logs (`1m`, `30s`, `6h`, or `1500ms`).
113
+ * @param {number} ms
114
+ * @returns {string}
115
+ */
116
+ export function formatIntervalMs(ms) {
117
+ if (!Number.isFinite(ms)) {
118
+ return String(ms);
119
+ }
120
+ const rounded = Math.round(ms);
121
+ if (rounded % (60 * 60 * 1000) === 0) {
122
+ return `${rounded / (60 * 60 * 1000)}h`;
123
+ }
124
+ if (rounded % (60 * 1000) === 0) {
125
+ return `${rounded / (60 * 1000)}m`;
126
+ }
127
+ if (rounded % 1000 === 0) {
128
+ return `${rounded / 1000}s`;
129
+ }
130
+ return `${rounded}ms`;
131
+ }
132
+
133
+ /**
134
+ * Clamp a parsed millisecond value. Invalid input yields the default (not the min).
135
+ *
136
+ * @param {unknown} raw
137
+ * @param {{ defaultMs?: number, minMs?: number, maxMs?: number }} [bounds]
138
+ * @returns {{ ms: number, human: string, clamped: boolean, invalid: boolean, requestedMs: number|null }}
139
+ */
140
+ export function clampIndexInterval(raw, bounds = {}) {
141
+ const defaultMs = bounds.defaultMs ?? DEFAULT_INDEX_INTERVAL_MS;
142
+ const minMs = bounds.minMs ?? MIN_INDEX_INTERVAL_MS;
143
+ const maxMs = bounds.maxMs ?? MAX_INDEX_INTERVAL_MS;
144
+ const requestedMs = parseDuration(raw);
145
+
146
+ if (requestedMs === null) {
147
+ return {
148
+ ms: defaultMs,
149
+ human: formatIntervalMs(defaultMs),
150
+ clamped: false,
151
+ invalid: true,
152
+ requestedMs: null
153
+ };
154
+ }
155
+
156
+ const clampedMs = Math.min(maxMs, Math.max(minMs, requestedMs));
157
+ return {
158
+ ms: clampedMs,
159
+ human: formatIntervalMs(clampedMs),
160
+ clamped: clampedMs !== requestedMs,
161
+ invalid: false,
162
+ requestedMs
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Load ~/.apple-tools-mcp/config.json. Missing file is fine. Invalid JSON
168
+ * does not throw — callers get empty data plus a warn log.
169
+ *
170
+ * @param {{
171
+ * configPath?: string,
172
+ * env?: NodeJS.ProcessEnv,
173
+ * readFile?: (p: string) => string,
174
+ * exists?: (p: string) => boolean,
175
+ * warn?: (msg: string) => void
176
+ * }} [options]
177
+ * @returns {{ data: Record<string, unknown>, missing: boolean, invalid: boolean, path: string }}
178
+ */
179
+ export function loadConfigFile(options = {}) {
180
+ const warn = options.warn || defaultWarn;
181
+ const configPath = options.configPath || getConfigPath({ env: options.env });
182
+ const exists = options.exists || ((p) => fs.existsSync(p));
183
+ const readFile = options.readFile || ((p) => fs.readFileSync(p, "utf8"));
184
+
185
+ if (!exists(configPath)) {
186
+ return { data: {}, missing: true, invalid: false, path: configPath };
187
+ }
188
+
189
+ try {
190
+ const raw = readFile(configPath);
191
+ const parsed = JSON.parse(raw);
192
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
193
+ warn("Invalid config.json: expected a JSON object. Using defaults.");
194
+ return { data: {}, missing: false, invalid: true, path: configPath };
195
+ }
196
+ for (const key of Object.keys(parsed)) {
197
+ if (!KNOWN_CONFIG_KEYS.has(key)) {
198
+ warn(`Ignoring unknown config key: ${key}`);
199
+ }
200
+ }
201
+ return { data: parsed, missing: false, invalid: false, path: configPath };
202
+ } catch (err) {
203
+ const message = err && err.message ? err.message : "parse error";
204
+ warn(`Invalid config.json (${message}). Using defaults.`);
205
+ return { data: {}, missing: false, invalid: true, path: configPath };
206
+ }
207
+ }
208
+
209
+ function fileIntervalRaw(data) {
210
+ if (!data || typeof data !== "object") {
211
+ return undefined;
212
+ }
213
+ if (Object.prototype.hasOwnProperty.call(data, "indexInterval")) {
214
+ return data.indexInterval;
215
+ }
216
+ if (Object.prototype.hasOwnProperty.call(data, "indexIntervalMs")) {
217
+ return data.indexIntervalMs;
218
+ }
219
+ return undefined;
220
+ }
221
+
222
+ /**
223
+ * Resolve the index refresh interval once at process start.
224
+ *
225
+ * @param {{
226
+ * env?: NodeJS.ProcessEnv,
227
+ * configPath?: string,
228
+ * fileData?: Record<string, unknown>,
229
+ * warn?: (msg: string) => void
230
+ * }} [options]
231
+ * @returns {{
232
+ * ms: number,
233
+ * human: string,
234
+ * source: "env" | "config" | "default",
235
+ * clamped: boolean,
236
+ * invalid: boolean,
237
+ * requestedMs: number|null,
238
+ * raw: unknown
239
+ * }}
240
+ */
241
+ export function resolveIndexInterval(options = {}) {
242
+ const env = options.env || process.env;
243
+ const warn = options.warn || defaultWarn;
244
+ const envRaw = env.INDEX_INTERVAL_MS;
245
+ const envSet = envRaw !== undefined && envRaw !== "";
246
+
247
+ let source = "default";
248
+ let raw = DEFAULT_INDEX_INTERVAL_MS;
249
+
250
+ if (envSet) {
251
+ source = "env";
252
+ raw = envRaw;
253
+ } else {
254
+ const data = options.fileData !== undefined
255
+ ? options.fileData
256
+ : loadConfigFile({ configPath: options.configPath, env, warn }).data;
257
+ const fromFile = fileIntervalRaw(data);
258
+ if (fromFile !== undefined) {
259
+ source = "config";
260
+ raw = fromFile;
261
+ }
262
+ }
263
+
264
+ const clamped = clampIndexInterval(raw);
265
+ if (clamped.invalid && source !== "default") {
266
+ warn(`Invalid index interval ${JSON.stringify(raw)} from ${source}; using default ${clamped.human} (${clamped.ms} ms)`);
267
+ } else if (clamped.clamped) {
268
+ const fromHuman = formatIntervalMs(clamped.requestedMs);
269
+ warn(
270
+ `Index interval ${fromHuman} (${clamped.requestedMs} ms) from ${source} is outside ${formatIntervalMs(MIN_INDEX_INTERVAL_MS)}–${formatIntervalMs(MAX_INDEX_INTERVAL_MS)}; clamped to ${clamped.human} (${clamped.ms} ms)`
271
+ );
272
+ }
273
+
274
+ return {
275
+ ms: clamped.ms,
276
+ human: clamped.human,
277
+ source: clamped.invalid && source !== "default" ? "default" : source,
278
+ clamped: clamped.clamped,
279
+ invalid: clamped.invalid,
280
+ requestedMs: clamped.requestedMs,
281
+ raw
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Load config from disk (if present) and resolve the interval.
287
+ * @param {{ env?: NodeJS.ProcessEnv, configPath?: string, warn?: (msg: string) => void }} [options]
288
+ */
289
+ export function loadResolvedIndexInterval(options = {}) {
290
+ const warn = options.warn || defaultWarn;
291
+ const loaded = loadConfigFile({
292
+ configPath: options.configPath,
293
+ env: options.env,
294
+ warn
295
+ });
296
+ return resolveIndexInterval({
297
+ env: options.env,
298
+ fileData: loaded.data,
299
+ warn
300
+ });
301
+ }
302
+
303
+ /**
304
+ * Log the effective interval once. Human form and milliseconds.
305
+ * @param {{ ms: number, human: string, source: string, clamped: boolean }} resolved
306
+ * @param {{ log?: (msg: string) => void }} [options]
307
+ */
308
+ export function logResolvedInterval(resolved, options = {}) {
309
+ const log = options.log || defaultWarn;
310
+ const clampedNote = resolved.clamped ? ", clamped" : "";
311
+ log(`Effective index refresh interval: ${resolved.human} (${resolved.ms} ms) [source=${resolved.source}${clampedNote}]`);
312
+ }
@@ -0,0 +1,376 @@
1
+ /**
2
+ * indexer.lock acquire / release / heartbeat.
3
+ *
4
+ * A live holder is never displaced, even if the lock timestamp is old
5
+ * (blocked event loop, long index cycle). Dead-PID takeover never renames
6
+ * the live lock path. Waiters take a wx mutex, re-read, unlink only if the
7
+ * contents are still the expected dead lock, wx-create the new lock, then
8
+ * drop the mutex — so a peer's freshly created lock is never deleted.
9
+ * An orphaned takeover mutex is never stolen with compare-then-unlink of
10
+ * the live `.takeover` path: waiters wx a fence named by the dead PID.
11
+ * Empty or unparsable mutex/lock contents are unknown — do not fence or steal
12
+ * (covers wx open-then-write and heartbeat truncate-then-write windows).
13
+ */
14
+
15
+ import fs from "fs";
16
+ import path from "path";
17
+
18
+ export const DEFAULT_LOCK_TIMEOUT_MS = 30 * 60 * 1000;
19
+ export const DEFAULT_LOCK_HEARTBEAT_MS = 60 * 1000;
20
+ export const TAKEOVER_MUTEX_SUFFIX = ".takeover";
21
+ export const TAKEOVER_FENCE_DEPTH = 8;
22
+
23
+ /**
24
+ * @param {string} lockFile
25
+ * @returns {string}
26
+ */
27
+ export function getTakeoverMutexPath(lockFile) {
28
+ return `${lockFile}${TAKEOVER_MUTEX_SUFFIX}`;
29
+ }
30
+
31
+ /**
32
+ * wx fence used when indexer.lock.takeover is orphaned (dead PID).
33
+ * @param {string} lockFile
34
+ * @param {...number|string} deadIds
35
+ * @returns {string}
36
+ */
37
+ export function getTakeoverFencePath(lockFile, ...deadIds) {
38
+ let p = getTakeoverMutexPath(lockFile);
39
+ for (const id of deadIds) {
40
+ p = `${p}.${id}`;
41
+ }
42
+ return p;
43
+ }
44
+
45
+ /**
46
+ * @param {unknown} pid
47
+ * @param {(pid: number, signal: number) => void} [killFn]
48
+ * @returns {boolean}
49
+ */
50
+ export function isProcessAlive(pid, killFn = (p, signal) => process.kill(p, signal)) {
51
+ try {
52
+ killFn(pid, 0);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @param {string} text
61
+ * @returns {{ pid: number, timestamp: number, raw: string } | null}
62
+ */
63
+ export function parseLockData(text) {
64
+ if (typeof text !== "string" || text.length === 0) {
65
+ return null;
66
+ }
67
+ const [pidStr, timestampStr] = text.split(":");
68
+ const pid = parseInt(pidStr, 10);
69
+ const timestamp = parseInt(timestampStr, 10);
70
+ if (!Number.isInteger(pid) || pid <= 0) {
71
+ return null;
72
+ }
73
+ return {
74
+ pid,
75
+ timestamp: Number.isInteger(timestamp) ? timestamp : 0,
76
+ raw: text
77
+ };
78
+ }
79
+
80
+ /**
81
+ * @param {number} pid
82
+ * @param {number} timestamp
83
+ * @returns {string}
84
+ */
85
+ export function formatLockData(pid, timestamp) {
86
+ return `${pid}:${timestamp}`;
87
+ }
88
+
89
+ /**
90
+ * @param {{
91
+ * lockFile: string,
92
+ * pid?: number,
93
+ * now?: () => number,
94
+ * timeoutMs?: number,
95
+ * isAlive?: (pid: number) => boolean,
96
+ * fsApi?: Pick<typeof fs, "existsSync" | "readFileSync" | "writeFileSync" | "unlinkSync" | "mkdirSync">,
97
+ * log?: (msg: string) => void,
98
+ * setIntervalFn?: typeof setInterval,
99
+ * clearIntervalFn?: typeof clearInterval
100
+ * }} options
101
+ */
102
+ export function createIndexerLock(options) {
103
+ const lockFile = options.lockFile;
104
+ const mutexPath = getTakeoverMutexPath(lockFile);
105
+ const pid = options.pid ?? process.pid;
106
+ const now = options.now || (() => Date.now());
107
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
108
+ const isAlive = options.isAlive || ((holderPid) => isProcessAlive(holderPid));
109
+ const fsApi = options.fsApi || fs;
110
+ const log = options.log || ((msg) => console.error(msg));
111
+ const setIntervalFn = options.setIntervalFn || setInterval;
112
+ const clearIntervalFn = options.clearIntervalFn || clearInterval;
113
+
114
+ let ownsLock = false;
115
+ let heartbeatTimer = null;
116
+ let heldMutexPath = null;
117
+
118
+ function readLockFile() {
119
+ if (!fsApi.existsSync(lockFile)) {
120
+ return null;
121
+ }
122
+ return fsApi.readFileSync(lockFile, "utf8");
123
+ }
124
+
125
+ function skipLiveHolder(parsed) {
126
+ const lockAge = now() - parsed.timestamp;
127
+ if (lockAge > timeoutMs) {
128
+ log(
129
+ `Lock file is ${Math.round(lockAge / 60000)} minutes old, but PID ${parsed.pid} is still running. Not taking over.`
130
+ );
131
+ } else {
132
+ log(`Another indexing instance running (PID ${parsed.pid}). Skipping indexing.`);
133
+ }
134
+ ownsLock = false;
135
+ return false;
136
+ }
137
+
138
+ function refreshOwned() {
139
+ ownsLock = true;
140
+ try {
141
+ fsApi.writeFileSync(lockFile, formatLockData(pid, now()));
142
+ } catch {
143
+ // Heartbeat or the next cycle can retry the write.
144
+ }
145
+ return true;
146
+ }
147
+
148
+ function tryAcquireMutex() {
149
+ const token = formatLockData(pid, now());
150
+ let candidate = mutexPath;
151
+ try {
152
+ for (let depth = 0; depth < TAKEOVER_FENCE_DEPTH; depth++) {
153
+ let exists = false;
154
+ for (let spin = 0; spin < 4; spin++) {
155
+ try {
156
+ fsApi.writeFileSync(candidate, token, { flag: "wx" });
157
+ heldMutexPath = candidate;
158
+ return true;
159
+ } catch (err) {
160
+ if (err && err.code === "ENOENT") {
161
+ continue;
162
+ }
163
+ if (!err || err.code !== "EEXIST") {
164
+ throw err;
165
+ }
166
+ exists = true;
167
+ break;
168
+ }
169
+ }
170
+ if (!exists) {
171
+ return false;
172
+ }
173
+ let data;
174
+ try {
175
+ data = fsApi.readFileSync(candidate, "utf8");
176
+ } catch (err) {
177
+ if (err && err.code === "ENOENT") {
178
+ continue;
179
+ }
180
+ throw err;
181
+ }
182
+ const parsed = parseLockData(data);
183
+ if (!parsed) {
184
+ return false;
185
+ }
186
+ if (isAlive(parsed.pid)) {
187
+ return false;
188
+ }
189
+ candidate = `${candidate}.${parsed.pid}`;
190
+ }
191
+ return false;
192
+ } catch (err) {
193
+ if (err && err.code === "EEXIST") {
194
+ return false;
195
+ }
196
+ log(`Takeover mutex error: ${err.message}`);
197
+ return false;
198
+ }
199
+ }
200
+
201
+ function dropMutex() {
202
+ const held = heldMutexPath;
203
+ heldMutexPath = null;
204
+ if (!held) {
205
+ return;
206
+ }
207
+ try {
208
+ if (!fsApi.existsSync(held)) {
209
+ return;
210
+ }
211
+ const data = fsApi.readFileSync(held, "utf8");
212
+ const parsed = parseLockData(data);
213
+ if (parsed && parsed.pid === pid) {
214
+ fsApi.unlinkSync(held);
215
+ }
216
+ } catch (err) {
217
+ log(`Takeover mutex release error: ${err.message}`);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Under the wx mutex: re-read, unlink only the still-expected dead lock,
223
+ * then wx-create. Never rename the live path.
224
+ */
225
+ function finishAcquireUnderMutex(expectedStale) {
226
+ const current = readLockFile();
227
+
228
+ if (current !== null) {
229
+ const parsed = parseLockData(current);
230
+ if (!parsed) {
231
+ ownsLock = false;
232
+ return false;
233
+ }
234
+ if (parsed.pid === pid) {
235
+ return refreshOwned();
236
+ }
237
+ if (isAlive(parsed.pid)) {
238
+ return skipLiveHolder(parsed);
239
+ }
240
+ if (expectedStale === null || current !== expectedStale) {
241
+ ownsLock = false;
242
+ return false;
243
+ }
244
+ // Re-read immediately before unlink. Never delete a peer's wx lock.
245
+ const again = readLockFile();
246
+ if (again !== expectedStale) {
247
+ ownsLock = false;
248
+ return false;
249
+ }
250
+ try {
251
+ fsApi.unlinkSync(lockFile);
252
+ } catch (err) {
253
+ if (!err || err.code !== "ENOENT") {
254
+ throw err;
255
+ }
256
+ }
257
+ log(`Removing stale lock file (PID ${parsed ? parsed.pid : "unknown"} not running)`);
258
+ }
259
+
260
+ try {
261
+ fsApi.writeFileSync(lockFile, formatLockData(pid, now()), { flag: "wx" });
262
+ ownsLock = true;
263
+ return true;
264
+ } catch (err) {
265
+ if (err.code === "EEXIST") {
266
+ log("Another process acquired lock during race. Skipping indexing.");
267
+ ownsLock = false;
268
+ return false;
269
+ }
270
+ throw err;
271
+ }
272
+ }
273
+
274
+ function acquire() {
275
+ try {
276
+ const lockDir = path.dirname(lockFile);
277
+ if (!fsApi.existsSync(lockDir)) {
278
+ fsApi.mkdirSync(lockDir, { recursive: true });
279
+ }
280
+
281
+ const existing = readLockFile();
282
+ if (existing !== null) {
283
+ const parsed = parseLockData(existing);
284
+ if (!parsed) {
285
+ ownsLock = false;
286
+ return false;
287
+ }
288
+ if (parsed.pid === pid) {
289
+ return refreshOwned();
290
+ }
291
+ if (isAlive(parsed.pid)) {
292
+ return skipLiveHolder(parsed);
293
+ }
294
+ }
295
+
296
+ if (!tryAcquireMutex()) {
297
+ log("Another process acquired lock during race. Skipping indexing.");
298
+ ownsLock = false;
299
+ return false;
300
+ }
301
+ try {
302
+ return finishAcquireUnderMutex(existing);
303
+ } finally {
304
+ dropMutex();
305
+ }
306
+ } catch (e) {
307
+ log(`Lock file error: ${e.message}`);
308
+ ownsLock = false;
309
+ return false;
310
+ }
311
+ }
312
+
313
+ function release() {
314
+ try {
315
+ const lockData = readLockFile();
316
+ if (lockData) {
317
+ const parsed = parseLockData(lockData);
318
+ if (parsed && parsed.pid === pid) {
319
+ fsApi.unlinkSync(lockFile);
320
+ ownsLock = false;
321
+ log(`Released lock file (PID ${pid})`);
322
+ }
323
+ }
324
+ } catch (err) {
325
+ log(`Error releasing lock: ${err.message}`);
326
+ }
327
+ }
328
+
329
+ function refresh() {
330
+ try {
331
+ if (!ownsLock) {
332
+ return false;
333
+ }
334
+ const lockData = readLockFile();
335
+ if (!lockData) {
336
+ return false;
337
+ }
338
+ const parsed = parseLockData(lockData);
339
+ if (parsed && parsed.pid === pid) {
340
+ fsApi.writeFileSync(lockFile, formatLockData(pid, now()));
341
+ return true;
342
+ }
343
+ return false;
344
+ } catch (err) {
345
+ log(`Lock heartbeat error: ${err.message}`);
346
+ return false;
347
+ }
348
+ }
349
+
350
+ function startHeartbeat(intervalMs = DEFAULT_LOCK_HEARTBEAT_MS) {
351
+ if (heartbeatTimer) {
352
+ return;
353
+ }
354
+ heartbeatTimer = setIntervalFn(() => {
355
+ refresh();
356
+ }, intervalMs);
357
+ }
358
+
359
+ function stopHeartbeat() {
360
+ if (heartbeatTimer) {
361
+ clearIntervalFn(heartbeatTimer);
362
+ heartbeatTimer = null;
363
+ }
364
+ }
365
+
366
+ return {
367
+ acquire,
368
+ release,
369
+ refresh,
370
+ startHeartbeat,
371
+ stopHeartbeat,
372
+ get ownsLock() {
373
+ return ownsLock;
374
+ }
375
+ };
376
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Indexer daemon vs MCP stdio runtime helpers.
3
+ * Kept separate from index.js so daemon paths can be tested without
4
+ * starting MCP stdio or loading the embedding model.
5
+ */
6
+
7
+ /**
8
+ * @param {boolean} indexerMode
9
+ * @returns {boolean}
10
+ */
11
+ export function shouldConnectMcpStdio(indexerMode) {
12
+ return !indexerMode;
13
+ }
14
+
15
+ /**
16
+ * @param {boolean} indexerMode
17
+ * @returns {boolean}
18
+ */
19
+ export function shouldExitOnStdinClose(indexerMode) {
20
+ return !indexerMode;
21
+ }
22
+
23
+ /**
24
+ * Bind stdin `close` → callback only for MCP stdio clients.
25
+ * Indexer daemons must ignore stdin close (LaunchAgent often uses /dev/null).
26
+ *
27
+ * @param {{ on: (event: string, handler: () => void) => void }} stdin
28
+ * @param {boolean} indexerMode
29
+ * @param {() => void} onClose
30
+ * @returns {{ bound: boolean }}
31
+ */
32
+ export function bindStdinCloseExit(stdin, indexerMode, onClose) {
33
+ if (!shouldExitOnStdinClose(indexerMode)) {
34
+ return { bound: false };
35
+ }
36
+ stdin.on("close", onClose);
37
+ return { bound: true };
38
+ }
39
+
40
+ /**
41
+ * Start one index cycle, or skip if a cycle is already running.
42
+ *
43
+ * @param {boolean} indexingInProgress
44
+ * @param {(msg: string) => void} [log]
45
+ * @returns {{ started: boolean, indexingInProgress: boolean }}
46
+ */
47
+ export function beginIndexCycle(indexingInProgress, log = (msg) => console.error(msg)) {
48
+ if (indexingInProgress) {
49
+ log("Indexing already in progress, skipping cycle");
50
+ return { started: false, indexingInProgress: true };
51
+ }
52
+ return { started: true, indexingInProgress: true };
53
+ }
54
+
55
+ /**
56
+ * After a cycle: daemon keeps the lock; MCP local-fallback releases it.
57
+ *
58
+ * @param {{
59
+ * success: boolean,
60
+ * indexerMode: boolean,
61
+ * cycleEndFlags: (success: boolean) => {
62
+ * indexingInProgress: boolean,
63
+ * sessionIndexComplete: boolean,
64
+ * ownsIndexLock: boolean,
65
+ * isFirstEverRun?: boolean
66
+ * },
67
+ * releaseLock: () => void
68
+ * }} args
69
+ */
70
+ export function applyIndexerCycleEnd({ success, indexerMode, cycleEndFlags, releaseLock }) {
71
+ const flags = cycleEndFlags(success);
72
+ if (indexerMode) {
73
+ return {
74
+ indexingInProgress: flags.indexingInProgress,
75
+ sessionIndexComplete: flags.sessionIndexComplete,
76
+ ownsIndexLock: true,
77
+ isFirstEverRun: flags.isFirstEverRun,
78
+ released: false
79
+ };
80
+ }
81
+ releaseLock();
82
+ return {
83
+ indexingInProgress: flags.indexingInProgress,
84
+ sessionIndexComplete: flags.sessionIndexComplete,
85
+ ownsIndexLock: flags.ownsIndexLock,
86
+ isFirstEverRun: flags.isFirstEverRun,
87
+ released: true
88
+ };
89
+ }
90
+
91
+ /**
92
+ * MCP stdio startup: index locally only when the lock is free.
93
+ *
94
+ * @param {() => boolean} acquireLock
95
+ * @returns {{ startBackground: boolean, ownsIndexLock: boolean, startHeartbeat: boolean, reason: "local-fallback" | "lock-held" }}
96
+ */
97
+ export function mcpIndexingStartup(acquireLock) {
98
+ if (!acquireLock()) {
99
+ return { startBackground: false, ownsIndexLock: false, startHeartbeat: false, reason: "lock-held" };
100
+ }
101
+ return { startBackground: true, ownsIndexLock: true, startHeartbeat: true, reason: "local-fallback" };
102
+ }
103
+
104
+ /**
105
+ * Shared path once this process owns indexer.lock: heartbeat + background cycles.
106
+ * MCP local-fallback must use this too, not only the daemon.
107
+ *
108
+ * @param {{ startHeartbeat: () => void, startBackground: () => void }} steps
109
+ */
110
+ export function beginOwnedIndexing(steps) {
111
+ steps.startHeartbeat();
112
+ steps.startBackground();
113
+ }
114
+
115
+ /**
116
+ * Retry until the daemon owns indexer.lock, then run onAcquired.
117
+ *
118
+ * @param {() => boolean} acquireLock
119
+ * @param {{
120
+ * retryMs: number,
121
+ * onAcquired: () => void,
122
+ * log?: (msg: string) => void,
123
+ * setTimeoutFn?: typeof setTimeout
124
+ * }} options
125
+ */
126
+ export function waitForIndexerLock(acquireLock, options) {
127
+ const retryMs = options.retryMs;
128
+ const onAcquired = options.onAcquired;
129
+ const log = options.log || ((msg) => console.error(msg));
130
+ const setTimeoutFn = options.setTimeoutFn || setTimeout;
131
+
132
+ const tryAcquire = () => {
133
+ if (acquireLock()) {
134
+ log("Indexer daemon acquired indexer.lock");
135
+ onAcquired();
136
+ return;
137
+ }
138
+ log("Indexer daemon waiting for indexer.lock...");
139
+ setTimeoutFn(tryAcquire, retryMs);
140
+ };
141
+ tryAcquire();
142
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Process mode detection for apple-tools-mcp.
3
+ *
4
+ * Canonical indexer entrypoint: `node index.js --mode=indexer`
5
+ * Convenience bin: `apple-tools-indexer` (same file; detected via argv[1]).
6
+ * MCP stdio remains the default when neither is present.
7
+ */
8
+
9
+ import path from "path";
10
+
11
+ /**
12
+ * @param {string[]} [argv=process.argv]
13
+ * @returns {boolean}
14
+ */
15
+ export function isIndexerMode(argv = process.argv) {
16
+ if (!Array.isArray(argv) || argv.length === 0) {
17
+ return false;
18
+ }
19
+ if (argv.includes("--mode=indexer")) {
20
+ return true;
21
+ }
22
+ const modeIdx = argv.indexOf("--mode");
23
+ if (modeIdx !== -1 && argv[modeIdx + 1] === "indexer") {
24
+ return true;
25
+ }
26
+ const entry = argv[1] ? path.basename(argv[1]) : "";
27
+ return entry === "apple-tools-indexer";
28
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for semantic search across Apple Mail, Messages, and Calendar",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
8
- "apple-tools-mcp": "./index.js"
8
+ "apple-tools-mcp": "./index.js",
9
+ "apple-tools-indexer": "./index.js"
9
10
  },
10
11
  "author": "Peter Coates",
11
12
  "license": "MIT",
@@ -50,6 +51,7 @@
50
51
  ],
51
52
  "scripts": {
52
53
  "start": "node index.js",
54
+ "indexer": "node index.js --mode=indexer",
53
55
  "build-index": "node -e \"import('./indexer.js').then(i=>i.rebuildIndex()).catch(e=>{console.error(e.message);process.exit(1)})\"",
54
56
  "audit": "node scripts/audit-index.js",
55
57
  "test": "vitest run",