@context-engine-bridge/context-engine-mcp-bridge 0.0.76 → 0.0.78

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.
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-engine-bridge/context-engine-mcp-bridge",
3
- "version": "0.0.76",
3
+ "version": "0.0.78",
4
4
  "description": "Context Engine MCP bridge (http/stdio proxy combining indexer + memory servers)",
5
5
  "bin": {
6
6
  "ctxce": "bin/ctxce.js",
Binary file
Binary file
Binary file
package/src/connectCli.js CHANGED
@@ -2,7 +2,8 @@ import process from "node:process";
2
2
  import path from "node:path";
3
3
  import fs from "node:fs";
4
4
  import { saveAuthEntry } from "./authConfig.js";
5
- import { indexWorkspace, loadGitignore, isCodeFile, collectGitState } from "./uploader.js";
5
+ import { indexWorkspace } from "./uploader.js";
6
+ import { startSyncDaemon } from "./syncDaemon.js";
6
7
 
7
8
  const SAAS_ENDPOINTS = {
8
9
  uploadEndpoint: "https://dev.context-engine.ai/upload",
@@ -12,7 +13,6 @@ const SAAS_ENDPOINTS = {
12
13
  };
13
14
 
14
15
  const DEFAULT_WATCH_INTERVAL_MS = 30000;
15
- const DEFAULT_DEBOUNCE_MS = 2000;
16
16
 
17
17
  function parseConnectArgs(args) {
18
18
  let apiKey = "";
@@ -164,181 +164,20 @@ function startWatcher(workspace, initialSessionId, authEntry, intervalMs) {
164
164
  console.error(`[ctxce] Starting file watcher (sync every ${intervalMs / 1000}s)...`);
165
165
  console.error("[ctxce] Press Ctrl+C to stop.");
166
166
 
167
- let isRunning = false;
168
- let pendingSync = false;
169
- let sessionId = initialSessionId;
170
- let pendingHistoryOnly = false;
171
- let lastKnownHead = "";
172
-
173
- async function refreshSessionIfNeeded() {
174
- // If the auth entry has an expiry and we're within 5 minutes of it,
175
- // re-authenticate using the stored API key.
176
- if (!authEntry || !authEntry.apiKey) return;
177
- const expiresAt = authEntry.expiresAt;
178
- if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) return;
179
- const nowSecs = Math.floor(Date.now() / 1000);
180
- const remainingSecs = expiresAt - nowSecs;
181
- if (remainingSecs > 300) return; // still valid for > 5 min
182
- console.error("[ctxce] Session approaching expiry, refreshing...");
183
- try {
184
- const refreshed = await authenticateWithApiKey(authEntry.apiKey);
185
- if (refreshed && refreshed.sessionId) {
186
- sessionId = refreshed.sessionId;
187
- authEntry = refreshed;
188
- console.error("[ctxce] Session refreshed successfully.");
189
- }
190
- } catch (err) {
191
- console.error(`[ctxce] Session refresh failed: ${err}`);
192
- }
193
- }
194
-
195
- const fileHashes = new Map();
196
-
197
- function getFileHash(filePath) {
198
- try {
199
- const stat = fs.statSync(filePath);
200
- return `${stat.mtime.getTime()}-${stat.size}`;
201
- } catch {
202
- return null;
203
- }
204
- }
205
-
206
- // Use gitignore from uploader.js so the watcher ignores the same files as
207
- // the bundle creator -- prevents redundant uploads for generated/ignored files.
208
- const _ig = loadGitignore(workspace);
209
-
210
- function scanDirectory(dir, files = []) {
211
- try {
212
- const entries = fs.readdirSync(dir, { withFileTypes: true });
213
- for (const entry of entries) {
214
- if (entry.isSymbolicLink()) continue;
215
- const fullPath = path.join(dir, entry.name);
216
- // Normalize to forward slashes for the `ignore` library (expects POSIX paths)
217
- const relPath = path.relative(workspace, fullPath).split(path.sep).join('/');
218
-
219
- // Use the same ignore rules as the bundle creator
220
- // Directories need a trailing slash for gitignore pattern matching
221
- if (entry.isDirectory()) {
222
- if (_ig.ignores(relPath + '/')) continue;
223
- scanDirectory(fullPath, files);
224
- } else if (entry.isFile()) {
225
- if (_ig.ignores(relPath)) continue;
226
- if (isCodeFile(fullPath)) {
227
- files.push(fullPath);
228
- }
229
- }
230
- }
231
- } catch {
232
- }
233
- return files;
234
- }
235
-
236
- function detectChanges() {
237
- const currentFiles = scanDirectory(workspace);
238
- let codeChanged = false;
239
-
240
- const currentPaths = new Set(currentFiles);
241
-
242
- for (const filePath of currentFiles) {
243
- const newHash = getFileHash(filePath);
244
- const oldHash = fileHashes.get(filePath);
245
-
246
- if (newHash !== oldHash) {
247
- codeChanged = true;
248
- fileHashes.set(filePath, newHash);
249
- }
250
- }
251
-
252
- for (const oldPath of fileHashes.keys()) {
253
- if (!currentPaths.has(oldPath)) {
254
- codeChanged = true;
255
- fileHashes.delete(oldPath);
256
- }
257
- }
167
+ const handle = startSyncDaemon({
168
+ workspace,
169
+ sessionId: initialSessionId,
170
+ authEntry,
171
+ uploadEndpoint: SAAS_ENDPOINTS.uploadEndpoint,
172
+ intervalMs,
173
+ log: console.error,
174
+ });
258
175
 
259
- let historyChanged = false;
176
+ const cleanup = () => {
260
177
  try {
261
- const gitState = collectGitState(workspace);
262
- const currentHead = gitState && gitState.head ? gitState.head : "";
263
- if (currentHead && currentHead !== lastKnownHead) {
264
- historyChanged = true;
265
- }
178
+ handle.cleanup();
266
179
  } catch {
267
180
  }
268
-
269
- return { codeChanged, historyChanged };
270
- }
271
-
272
- async function doSync(historyOnly = false) {
273
- if (isRunning) {
274
- pendingSync = true;
275
- pendingHistoryOnly = pendingHistoryOnly || historyOnly;
276
- return;
277
- }
278
-
279
- isRunning = true;
280
- const now = new Date().toLocaleTimeString();
281
- console.error(`[ctxce] [${now}] Syncing changes...`);
282
-
283
- try {
284
- // Refresh session before upload if approaching expiry
285
- await refreshSessionIfNeeded();
286
-
287
- const result = await indexWorkspace(
288
- workspace,
289
- SAAS_ENDPOINTS.uploadEndpoint,
290
- sessionId,
291
- {
292
- log: console.error,
293
- orgId: authEntry?.org_id,
294
- orgSlug: authEntry?.org_slug,
295
- historyOnly,
296
- }
297
- );
298
- if (result.success) {
299
- try {
300
- const gitState = collectGitState(workspace);
301
- lastKnownHead = gitState && gitState.head ? gitState.head : lastKnownHead;
302
- } catch {
303
- }
304
- console.error(`[ctxce] [${now}] Sync complete.`);
305
- } else {
306
- console.error(`[ctxce] [${now}] Sync failed: ${result.error}`);
307
- }
308
- } catch (err) {
309
- console.error(`[ctxce] [${now}] Sync error: ${err}`);
310
- }
311
-
312
- isRunning = false;
313
-
314
- if (pendingSync) {
315
- const nextHistoryOnly = pendingHistoryOnly;
316
- pendingSync = false;
317
- pendingHistoryOnly = false;
318
- setTimeout(() => {
319
- doSync(nextHistoryOnly);
320
- }, DEFAULT_DEBOUNCE_MS);
321
- }
322
- }
323
-
324
- scanDirectory(workspace).forEach(f => {
325
- fileHashes.set(f, getFileHash(f));
326
- });
327
- try {
328
- const gitState = collectGitState(workspace);
329
- lastKnownHead = gitState && gitState.head ? gitState.head : "";
330
- } catch {
331
- }
332
-
333
- const intervalId = setInterval(() => {
334
- const changeState = detectChanges();
335
- if (changeState.codeChanged || changeState.historyChanged) {
336
- doSync(changeState.historyChanged && !changeState.codeChanged);
337
- }
338
- }, intervalMs);
339
-
340
- const cleanup = () => {
341
- clearInterval(intervalId);
342
181
  console.error("\n[ctxce] Watcher stopped.");
343
182
  };
344
183
 
@@ -352,7 +191,7 @@ function startWatcher(workspace, initialSessionId, authEntry, intervalMs) {
352
191
  process.exit(0);
353
192
  });
354
193
 
355
- return { intervalId, cleanup };
194
+ return handle;
356
195
  }
357
196
 
358
197
  function printSuccess() {
package/src/mcpServer.js CHANGED
@@ -13,6 +13,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
13
13
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
14
14
  import { loadAnyAuthEntry, loadAuthEntry, readConfig, saveAuthEntry } from "./authConfig.js";
15
15
  import { maybeRemapToolArgs, maybeRemapToolResult } from "./resultPathMapping.js";
16
+ import { startSyncDaemon } from "./syncDaemon.js";
16
17
  import * as oauthHandler from "./oauthHandler.js";
17
18
 
18
19
  const LSP_CONN_CACHE_TTL = 5000;
@@ -846,6 +847,34 @@ async function createBridgeServer(options) {
846
847
  }
847
848
  }
848
849
 
850
+ const syncDaemonEnabled = process.env.CTXCE_SYNC_DAEMON !== "0";
851
+ const syncUploadEndpoint = uploadServiceUrl
852
+ ? `${String(uploadServiceUrl).replace(/\/+$/, "")}/upload`
853
+ : "";
854
+ let syncAuthEntry = null;
855
+ try {
856
+ syncAuthEntry = backendHint ? loadAuthEntry(backendHint) : null;
857
+ } catch {
858
+ syncAuthEntry = null;
859
+ }
860
+ if (
861
+ syncDaemonEnabled &&
862
+ syncUploadEndpoint &&
863
+ sessionId &&
864
+ !sessionId.startsWith("ctxce-")
865
+ ) {
866
+ try {
867
+ startSyncDaemon({
868
+ workspace,
869
+ sessionId,
870
+ authEntry: syncAuthEntry || { sessionId },
871
+ uploadEndpoint: syncUploadEndpoint,
872
+ });
873
+ } catch (err) {
874
+ debugLog("[ctxce] Failed to start sync daemon: " + String(err));
875
+ }
876
+ }
877
+
849
878
  // Best-effort: inform the indexer of default collection and session.
850
879
  // If this fails we still proceed, falling back to per-call injection.
851
880
  const defaultsPayload = { session: sessionId };
@@ -2136,4 +2165,3 @@ function detectRepoName(workspace, config) {
2136
2165
  const leaf = workspace ? path.basename(workspace) : "";
2137
2166
  return leaf && SLUGGED_REPO_RE.test(leaf) ? leaf : null;
2138
2167
  }
2139
-
@@ -0,0 +1,437 @@
1
+ /**
2
+ * syncDaemon.js -- process-level singleton file-watcher / upload sync daemon.
3
+ *
4
+ * Can be started from any entry point (connect CLI, mcp-serve stdio, mcp-http-serve).
5
+ * Only one watcher per resolved workspace path is allowed per process.
6
+ *
7
+ * Exports:
8
+ * startSyncDaemon(options) -> { intervalId, cleanup }
9
+ * stopSyncDaemon(workspace) -> void
10
+ */
11
+
12
+ import path from "node:path";
13
+ import fs from "node:fs";
14
+ import { saveAuthEntry } from "./authConfig.js";
15
+ import { indexWorkspace, loadGitignore, isCodeFile, collectGitState } from "./uploader.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Constants
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const DEFAULT_WATCH_INTERVAL_MS = 30000;
22
+ const DEFAULT_DEBOUNCE_MS = 2000;
23
+
24
+ // No-op logger used as the default in MCP mode so the daemon is silent.
25
+ const noop = () => {};
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Process-level singleton registry
29
+ // keyed by path.resolve(workspace) -> { intervalId, cleanup }
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const _activeDaemons = new Map();
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Session refresh helper (self-contained, no side-effects on the caller's env)
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Derive the auth backend URL from the upload endpoint.
40
+ * Convention: the upload endpoint is `<authBackendUrl>/upload`.
41
+ * Strip the trailing `/upload` path segment if present, otherwise use as-is.
42
+ *
43
+ * @param {string} uploadEndpoint
44
+ * @returns {string}
45
+ */
46
+ function deriveAuthBackendUrl(uploadEndpoint) {
47
+ try {
48
+ const u = new URL(uploadEndpoint);
49
+ // Remove the last path segment only if it is "upload"
50
+ if (u.pathname.endsWith("/upload")) {
51
+ u.pathname = u.pathname.slice(0, -"/upload".length) || "/";
52
+ }
53
+ // Normalise trailing slash away
54
+ u.pathname = u.pathname.replace(/\/$/, "") || "/";
55
+ return u.origin + (u.pathname === "/" ? "" : u.pathname);
56
+ } catch {
57
+ // Fallback: simple string surgery
58
+ return uploadEndpoint.replace(/\/upload\/?$/, "");
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Attempt to obtain a fresh session by posting to `<authBackendUrl>/auth/login`.
64
+ * Returns the new auth entry on success, or null on failure.
65
+ *
66
+ * @param {string} apiKey
67
+ * @param {string} authBackendUrl
68
+ * @param {string} workspace
69
+ * @param {function} log
70
+ * @returns {Promise<object|null>}
71
+ */
72
+ async function _refreshSession(apiKey, authBackendUrl, workspace, log) {
73
+ const authUrl = `${authBackendUrl}/auth/login`;
74
+ const body = {
75
+ client: "ctxce-cli",
76
+ token: apiKey,
77
+ workspace,
78
+ };
79
+
80
+ let resp;
81
+ try {
82
+ resp = await fetch(authUrl, {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify(body),
86
+ });
87
+ } catch (err) {
88
+ log(`[syncDaemon] Session refresh request failed: ${err}`);
89
+ return null;
90
+ }
91
+
92
+ if (!resp || !resp.ok) {
93
+ const status = resp ? resp.status : "<no-response>";
94
+ log(`[syncDaemon] Session refresh HTTP error: ${status}`);
95
+ return null;
96
+ }
97
+
98
+ let data;
99
+ try {
100
+ data = await resp.json();
101
+ } catch {
102
+ data = {};
103
+ }
104
+
105
+ const sessionId = data.session_id || data.sessionId || null;
106
+ const userId = data.user_id || data.userId || null;
107
+ const expiresAt = data.expires_at || data.expiresAt || null;
108
+ const orgId = data.org_id || data.orgId || null;
109
+ const orgSlug = data.org_slug || data.orgSlug || null;
110
+
111
+ if (!sessionId) {
112
+ log("[syncDaemon] Session refresh response missing session ID.");
113
+ return null;
114
+ }
115
+
116
+ const entry = {
117
+ sessionId,
118
+ userId,
119
+ expiresAt,
120
+ org_id: orgId,
121
+ org_slug: orgSlug,
122
+ apiKey,
123
+ };
124
+
125
+ // Persist the refreshed entry so other callers can pick it up.
126
+ saveAuthEntry(authBackendUrl, entry);
127
+
128
+ return entry;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Core daemon factory
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Start the sync daemon for the given workspace.
137
+ *
138
+ * If a daemon is already running for this workspace (same process), this
139
+ * function returns the existing handle WITHOUT starting a second one.
140
+ *
141
+ * @param {object} options
142
+ * @param {string} options.workspace Absolute or relative path to the workspace root.
143
+ * @param {string} options.sessionId Initial session ID for uploads.
144
+ * @param {object} options.authEntry Auth entry object (may contain apiKey, expiresAt, org_id, org_slug).
145
+ * @param {string} options.uploadEndpoint Full upload endpoint URL (e.g. "https://dev.context-engine.ai/upload").
146
+ * @param {number} [options.intervalMs] Poll interval in milliseconds (default: 30000).
147
+ * @param {function} [options.log] Logger function. Defaults to a no-op (silent in MCP mode).
148
+ * Pass `console.error` for interactive CLI output.
149
+ * @returns {{ intervalId: NodeJS.Timeout, cleanup: function }}
150
+ */
151
+ export function startSyncDaemon(options) {
152
+ const {
153
+ workspace,
154
+ sessionId: initialSessionId,
155
+ authEntry: initialAuthEntry,
156
+ uploadEndpoint,
157
+ intervalMs = DEFAULT_WATCH_INTERVAL_MS,
158
+ log = noop,
159
+ } = options;
160
+
161
+ const resolvedWorkspace = path.resolve(workspace);
162
+
163
+ // Singleton guard: return existing daemon if already running.
164
+ if (_activeDaemons.has(resolvedWorkspace)) {
165
+ log(`[syncDaemon] Daemon already running for ${resolvedWorkspace}, reusing.`);
166
+ return _activeDaemons.get(resolvedWorkspace);
167
+ }
168
+
169
+ log(`[syncDaemon] Starting file watcher for ${resolvedWorkspace} (interval: ${intervalMs / 1000}s)`);
170
+
171
+ // Mutable state captured by the daemon closures.
172
+ let isRunning = false;
173
+ let pendingSync = false;
174
+ let pendingHistoryOnly = true;
175
+ let sessionId = initialSessionId;
176
+ let authEntry = initialAuthEntry;
177
+ let lastKnownHead = "";
178
+
179
+ const authBackendUrl = deriveAuthBackendUrl(uploadEndpoint);
180
+
181
+ // -------------------------------------------------------------------------
182
+ // Session refresh
183
+ // -------------------------------------------------------------------------
184
+
185
+ async function refreshSessionIfNeeded() {
186
+ if (!authEntry || !authEntry.apiKey) return;
187
+ const expiresAt = authEntry.expiresAt;
188
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) return;
189
+ const nowSecs = Math.floor(Date.now() / 1000);
190
+ const remainingSecs = expiresAt - nowSecs;
191
+ if (remainingSecs > 300) return; // still valid for > 5 minutes
192
+
193
+ log("[syncDaemon] Session approaching expiry, refreshing...");
194
+ try {
195
+ const refreshed = await _refreshSession(
196
+ authEntry.apiKey,
197
+ authBackendUrl,
198
+ resolvedWorkspace,
199
+ log
200
+ );
201
+ if (refreshed && refreshed.sessionId) {
202
+ sessionId = refreshed.sessionId;
203
+ authEntry = refreshed;
204
+ log("[syncDaemon] Session refreshed successfully.");
205
+ }
206
+ } catch (err) {
207
+ log(`[syncDaemon] Session refresh failed: ${err}`);
208
+ }
209
+ }
210
+
211
+ // -------------------------------------------------------------------------
212
+ // File-system scanning
213
+ // -------------------------------------------------------------------------
214
+
215
+ const fileHashes = new Map();
216
+ const _ig = loadGitignore(resolvedWorkspace);
217
+
218
+ function getFileHash(filePath) {
219
+ try {
220
+ const stat = fs.statSync(filePath);
221
+ return `${stat.mtime.getTime()}-${stat.size}`;
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+
227
+ function scanDirectory(dir, files = []) {
228
+ try {
229
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
230
+ for (const entry of entries) {
231
+ if (entry.isSymbolicLink()) continue;
232
+ const fullPath = path.join(dir, entry.name);
233
+ // Normalise to forward slashes for the `ignore` library (expects POSIX paths).
234
+ const relPath = path.relative(resolvedWorkspace, fullPath).split(path.sep).join("/");
235
+
236
+ if (entry.isDirectory()) {
237
+ if (_ig.ignores(relPath + "/")) continue;
238
+ scanDirectory(fullPath, files);
239
+ } else if (entry.isFile()) {
240
+ if (_ig.ignores(relPath)) continue;
241
+ if (isCodeFile(fullPath)) {
242
+ files.push(fullPath);
243
+ }
244
+ }
245
+ }
246
+ } catch {
247
+ // Ignore unreadable directories.
248
+ }
249
+ return files;
250
+ }
251
+
252
+ function snapshotFileHashes() {
253
+ const nextHashes = new Map();
254
+ scanDirectory(resolvedWorkspace).forEach((filePath) => {
255
+ nextHashes.set(filePath, getFileHash(filePath));
256
+ });
257
+ return nextHashes;
258
+ }
259
+
260
+ // -------------------------------------------------------------------------
261
+ // Change detection
262
+ // -------------------------------------------------------------------------
263
+
264
+ function detectChanges() {
265
+ const currentHashes = snapshotFileHashes();
266
+ let codeChanged = false;
267
+
268
+ for (const [filePath, newHash] of currentHashes.entries()) {
269
+ const oldHash = fileHashes.get(filePath);
270
+ if (newHash !== oldHash) {
271
+ codeChanged = true;
272
+ }
273
+ }
274
+
275
+ for (const oldPath of fileHashes.keys()) {
276
+ if (!currentHashes.has(oldPath)) {
277
+ codeChanged = true;
278
+ }
279
+ }
280
+
281
+ let historyChanged = false;
282
+ try {
283
+ const gitState = collectGitState(resolvedWorkspace);
284
+ const currentHead = gitState && gitState.head ? gitState.head : "";
285
+ if (currentHead && currentHead !== lastKnownHead) {
286
+ historyChanged = true;
287
+ // NOTE: lastKnownHead is updated only on a successful sync so that a
288
+ // failed upload doesn't suppress the next attempt.
289
+ }
290
+ } catch {
291
+ // Git not available or not a git repo -- ignore.
292
+ }
293
+
294
+ return { codeChanged, historyChanged };
295
+ }
296
+
297
+ // -------------------------------------------------------------------------
298
+ // Sync execution
299
+ // -------------------------------------------------------------------------
300
+
301
+ async function doSync(historyOnly = false) {
302
+ if (isRunning) {
303
+ // Another sync is in flight; queue one more run after it completes.
304
+ pendingSync = true;
305
+ // If the pending request is a full sync, don't downgrade it to history-only.
306
+ pendingHistoryOnly = pendingHistoryOnly && historyOnly;
307
+ return;
308
+ }
309
+
310
+ isRunning = true;
311
+ const now = new Date().toLocaleTimeString();
312
+ log(`[syncDaemon] [${now}] Syncing changes (historyOnly=${historyOnly})...`);
313
+
314
+ try {
315
+ await refreshSessionIfNeeded();
316
+
317
+ const result = await indexWorkspace(
318
+ resolvedWorkspace,
319
+ uploadEndpoint,
320
+ sessionId,
321
+ {
322
+ log,
323
+ orgId: authEntry?.org_id,
324
+ orgSlug: authEntry?.org_slug,
325
+ historyOnly,
326
+ }
327
+ );
328
+
329
+ if (result.success) {
330
+ const latestHashes = snapshotFileHashes();
331
+ fileHashes.clear();
332
+ for (const [filePath, hash] of latestHashes.entries()) {
333
+ fileHashes.set(filePath, hash);
334
+ }
335
+
336
+ // Update lastKnownHead only after a successful upload so a transient
337
+ // network failure doesn't prevent retrying the history sync.
338
+ try {
339
+ const gitState = collectGitState(resolvedWorkspace);
340
+ lastKnownHead = gitState && gitState.head ? gitState.head : lastKnownHead;
341
+ } catch {
342
+ // Ignore.
343
+ }
344
+ log(`[syncDaemon] [${now}] Sync complete.`);
345
+ } else {
346
+ log(`[syncDaemon] [${now}] Sync failed: ${result.error}`);
347
+ }
348
+ } catch (err) {
349
+ log(`[syncDaemon] [${now}] Sync error: ${err}`);
350
+ }
351
+
352
+ isRunning = false;
353
+
354
+ if (pendingSync) {
355
+ const nextHistoryOnly = pendingHistoryOnly;
356
+ pendingSync = false;
357
+ pendingHistoryOnly = true;
358
+ setTimeout(() => {
359
+ doSync(nextHistoryOnly);
360
+ }, DEFAULT_DEBOUNCE_MS);
361
+ }
362
+ }
363
+
364
+ // -------------------------------------------------------------------------
365
+ // Initialisation: snapshot current file state and git HEAD
366
+ // -------------------------------------------------------------------------
367
+
368
+ for (const [filePath, hash] of snapshotFileHashes().entries()) {
369
+ fileHashes.set(filePath, hash);
370
+ }
371
+
372
+ try {
373
+ const gitState = collectGitState(resolvedWorkspace);
374
+ lastKnownHead = gitState && gitState.head ? gitState.head : "";
375
+ } catch {
376
+ // Not a git repo or git unavailable.
377
+ }
378
+
379
+ // Perform an initial history-only sync so that commit history is up-to-date
380
+ // without triggering a potentially expensive full code reindex on every
381
+ // startup (important for MCP mode where the daemon may restart frequently).
382
+ if (lastKnownHead) {
383
+ log("[syncDaemon] Performing initial git history sync...");
384
+ doSync(true /* historyOnly */);
385
+ }
386
+
387
+ // -------------------------------------------------------------------------
388
+ // Polling interval
389
+ // -------------------------------------------------------------------------
390
+
391
+ const intervalId = setInterval(() => {
392
+ const changeState = detectChanges();
393
+ if (changeState.codeChanged || changeState.historyChanged) {
394
+ // historyOnly = true only when git HEAD changed but NO code files changed.
395
+ doSync(changeState.historyChanged && !changeState.codeChanged);
396
+ }
397
+ }, intervalMs);
398
+
399
+ // -------------------------------------------------------------------------
400
+ // Cleanup
401
+ // -------------------------------------------------------------------------
402
+
403
+ const cleanup = () => {
404
+ clearInterval(intervalId);
405
+ _activeDaemons.delete(resolvedWorkspace);
406
+ log(`[syncDaemon] Watcher stopped for ${resolvedWorkspace}.`);
407
+ };
408
+
409
+ // Register in singleton map BEFORE returning so concurrent callers see it.
410
+ const handle = { intervalId, cleanup };
411
+ _activeDaemons.set(resolvedWorkspace, handle);
412
+
413
+ return handle;
414
+ }
415
+
416
+ /**
417
+ * Stop the sync daemon for the given workspace (if one is running).
418
+ *
419
+ * @param {string} workspace Workspace path (resolved internally).
420
+ */
421
+ export function stopSyncDaemon(workspace) {
422
+ const resolvedWorkspace = path.resolve(workspace);
423
+ const handle = _activeDaemons.get(resolvedWorkspace);
424
+ if (handle) {
425
+ handle.cleanup();
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Return true if a daemon is currently active for the given workspace.
431
+ *
432
+ * @param {string} workspace
433
+ * @returns {boolean}
434
+ */
435
+ export function isSyncDaemonRunning(workspace) {
436
+ return _activeDaemons.has(path.resolve(workspace));
437
+ }
Binary file
Binary file