cursor-guard 4.9.9 → 4.9.12

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.
@@ -14,6 +14,7 @@ function coreDeps() {
14
14
  runDiagnostics: require('../lib/core/doctor').runDiagnostics,
15
15
  listBackups: require('../lib/core/backups').listBackups,
16
16
  getBackupFiles: require('../lib/core/backups').getBackupFiles,
17
+ clearAlert: require('../lib/core/anomaly').clearAlert,
17
18
  };
18
19
  }
19
20
 
@@ -196,7 +197,21 @@ function handleApi(pathname, query, registry, res, req) {
196
197
  return json(res, { error: `Project directory not accessible: ${project.pathLabel}` }, 500);
197
198
  }
198
199
 
199
- const { getDashboard, runDiagnostics, listBackups, getBackupFiles } = coreDeps();
200
+ const { getDashboard, runDiagnostics, listBackups, getBackupFiles, clearAlert } = coreDeps();
201
+
202
+ if (pathname === '/api/dismiss-alert') {
203
+ if (req.method !== 'POST') {
204
+ res.writeHead(405);
205
+ return res.end('Method Not Allowed');
206
+ }
207
+ try {
208
+ clearAlert(pp);
209
+ if (_instance?.hub && id) broadcastGuardChanged(_instance.hub, id);
210
+ return json(res, { ok: true });
211
+ } catch (e) {
212
+ return json(res, { ok: false, error: e.message }, 500);
213
+ }
214
+ }
200
215
 
201
216
  if (pathname === '/api/page-data') {
202
217
  const scope = query.get('scope');
@@ -245,6 +260,164 @@ function handleApi(pathname, query, registry, res, req) {
245
260
  return notFound(res);
246
261
  }
247
262
 
263
+ /* ── Live push (SSE) + fs.watch on Guard refs / backups ─────── */
264
+
265
+ function broadcastGuardChanged(hub, projectId) {
266
+ if (!hub?.sseClients?.size) return;
267
+ const line = `data: ${JSON.stringify({ type: 'guard-changed', projectId })}\n\n`;
268
+ for (const c of hub.sseClients) {
269
+ try {
270
+ if (c.projectId && c.projectId !== projectId) continue;
271
+ c.res.write(line);
272
+ } catch {
273
+ hub.sseClients.delete(c);
274
+ }
275
+ }
276
+ }
277
+
278
+ function teardownGuardWatchersOnly(hub) {
279
+ if (hub._reattachTimer) {
280
+ clearTimeout(hub._reattachTimer);
281
+ hub._reattachTimer = null;
282
+ }
283
+ if (hub.guardDebounce) {
284
+ for (const t of hub.guardDebounce.values()) clearTimeout(t);
285
+ hub.guardDebounce.clear();
286
+ }
287
+ for (const w of hub.guardWatchers) {
288
+ try { w.close(); } catch { /* ignore */ }
289
+ }
290
+ hub.guardWatchers = [];
291
+ }
292
+
293
+ function tryWatchDirRecursive(dir, cb) {
294
+ try {
295
+ return fs.watch(dir, { recursive: true }, cb);
296
+ } catch {
297
+ try {
298
+ return fs.watch(dir, cb);
299
+ } catch {
300
+ return null;
301
+ }
302
+ }
303
+ }
304
+
305
+ /**
306
+ * When Git refs under refs/guard/, shadow backup dir, or alert/lock change, notify SSE clients.
307
+ */
308
+ function attachGuardWatchers(hub) {
309
+ const { isGitRepo, gitDir: getGitDir } = require('../lib/utils');
310
+ teardownGuardWatchersOnly(hub);
311
+ if (!hub.guardDebounce) hub.guardDebounce = new Map();
312
+
313
+ function schedule(projectId) {
314
+ const prev = hub.guardDebounce.get(projectId);
315
+ if (prev) clearTimeout(prev);
316
+ hub.guardDebounce.set(projectId, setTimeout(() => {
317
+ hub.guardDebounce.delete(projectId);
318
+ broadcastGuardChanged(hub, projectId);
319
+ }, 400));
320
+ }
321
+
322
+ for (const proj of hub.registry.values()) {
323
+ const pp = proj._path;
324
+ const pid = proj.id;
325
+
326
+ if (isGitRepo(pp)) {
327
+ const gDir = getGitDir(pp);
328
+ if (gDir && fs.existsSync(gDir)) {
329
+ try {
330
+ const w = fs.watch(gDir, (ev, fname) => {
331
+ if (fname === 'cursor-guard-alert.json' || fname === 'cursor-guard.lock') schedule(pid);
332
+ });
333
+ hub.guardWatchers.push(w);
334
+ } catch { /* ignore */ }
335
+
336
+ const refsDir = path.join(gDir, 'refs');
337
+ if (fs.existsSync(refsDir)) {
338
+ try {
339
+ const w = fs.watch(refsDir, (ev, fname) => {
340
+ if (!fname) return;
341
+ if (fname === 'guard' || fname.startsWith('guard')) {
342
+ schedule(pid);
343
+ if (!hub._reattachTimer) {
344
+ hub._reattachTimer = setTimeout(() => {
345
+ hub._reattachTimer = null;
346
+ attachGuardWatchers(hub);
347
+ }, 600);
348
+ }
349
+ }
350
+ });
351
+ hub.guardWatchers.push(w);
352
+ } catch { /* ignore */ }
353
+ }
354
+
355
+ const guardDir = path.join(gDir, 'refs', 'guard');
356
+ if (fs.existsSync(guardDir)) {
357
+ const w = tryWatchDirRecursive(guardDir, () => schedule(pid));
358
+ if (w) hub.guardWatchers.push(w);
359
+ const preRestoreDir = path.join(guardDir, 'pre-restore');
360
+ if (fs.existsSync(preRestoreDir)) {
361
+ const w2 = tryWatchDirRecursive(preRestoreDir, () => schedule(pid));
362
+ if (w2) hub.guardWatchers.push(w2);
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ const backupDir = path.join(pp, '.cursor-guard-backup');
369
+ if (fs.existsSync(backupDir)) {
370
+ const w = tryWatchDirRecursive(backupDir, () => schedule(pid));
371
+ if (w) hub.guardWatchers.push(w);
372
+ }
373
+ }
374
+ }
375
+
376
+ function teardownLivePush(hub) {
377
+ teardownGuardWatchersOnly(hub);
378
+ for (const c of hub.sseClients) {
379
+ try { c.res.end(); } catch { /* ignore */ }
380
+ }
381
+ hub.sseClients.clear();
382
+ if (hub.ssePingTimer) {
383
+ clearInterval(hub.ssePingTimer);
384
+ hub.ssePingTimer = null;
385
+ }
386
+ }
387
+
388
+ function openSseStream(query, hub, res, req) {
389
+ const subscribeId = query.get('id') || null;
390
+ res.writeHead(200, {
391
+ 'Content-Type': 'text/event-stream; charset=utf-8',
392
+ 'Cache-Control': 'no-store, no-cache',
393
+ 'Connection': 'keep-alive',
394
+ 'X-Accel-Buffering': 'no',
395
+ 'Access-Control-Allow-Origin': '*',
396
+ });
397
+ res.write(': connected\n\n');
398
+ const client = { res, projectId: subscribeId };
399
+ hub.sseClients.add(client);
400
+ req.on('close', () => {
401
+ hub.sseClients.delete(client);
402
+ if (hub.sseClients.size === 0 && hub.ssePingTimer) {
403
+ clearInterval(hub.ssePingTimer);
404
+ hub.ssePingTimer = null;
405
+ }
406
+ });
407
+ if (!hub.ssePingTimer) {
408
+ hub.ssePingTimer = setInterval(() => {
409
+ if (hub.sseClients.size === 0) return;
410
+ for (const c of hub.sseClients) {
411
+ try {
412
+ c.res.write(': ping\n\n');
413
+ } catch {
414
+ hub.sseClients.delete(c);
415
+ }
416
+ }
417
+ }, 25000);
418
+ }
419
+ }
420
+
248
421
  /* ── Server (singleton) ─────────────────────────────────────── */
249
422
 
250
423
  let _instance = null;
@@ -280,8 +453,11 @@ function _mergeProjects(registry, paths) {
280
453
  function startDashboardServer(paths, opts = {}) {
281
454
  if (_instance) {
282
455
  const added = _mergeProjects(_instance.registry, paths);
283
- if (added > 0 && !opts.silent) {
284
- console.log(` [dashboard] Hot-added ${added} project(s) — total: ${_instance.registry.size} on port ${_instance.port}`);
456
+ if (added > 0) {
457
+ attachGuardWatchers(_instance.hub);
458
+ if (!opts.silent) {
459
+ console.log(` [dashboard] Hot-added ${added} project(s) — total: ${_instance.registry.size} on port ${_instance.port}`);
460
+ }
285
461
  }
286
462
  return Promise.resolve(_instance);
287
463
  }
@@ -290,6 +466,14 @@ function startDashboardServer(paths, opts = {}) {
290
466
  const silent = opts.silent || false;
291
467
  const registry = buildRegistry(paths);
292
468
  const token = crypto.randomBytes(16).toString('hex');
469
+ const hub = {
470
+ registry,
471
+ token,
472
+ sseClients: new Set(),
473
+ guardWatchers: [],
474
+ guardDebounce: new Map(),
475
+ ssePingTimer: null,
476
+ };
293
477
 
294
478
  return new Promise((resolve, reject) => {
295
479
  let currentPort = port;
@@ -326,6 +510,13 @@ function startDashboardServer(paths, opts = {}) {
326
510
  res.writeHead(403);
327
511
  return res.end('Forbidden: invalid token');
328
512
  }
513
+ if (parsed.pathname === '/api/events') {
514
+ if (req.method !== 'GET') {
515
+ res.writeHead(405);
516
+ return res.end('Method Not Allowed');
517
+ }
518
+ return openSseStream(parsed.searchParams, hub, res, req);
519
+ }
329
520
  handleApi(parsed.pathname, parsed.searchParams, registry, res, req);
330
521
  } else {
331
522
  if (req.method !== 'GET') { res.writeHead(405); return res.end('Method Not Allowed'); }
@@ -345,7 +536,8 @@ function startDashboardServer(paths, opts = {}) {
345
536
 
346
537
  server.on('listening', () => {
347
538
  const addr = server.address();
348
- _instance = { server, port: addr.port, registry, token };
539
+ _instance = { server, port: addr.port, registry, token, hub };
540
+ attachGuardWatchers(hub);
349
541
  if (!silent) {
350
542
  console.log('');
351
543
  console.log(' Cursor Guard Dashboard');
@@ -371,6 +563,7 @@ async function restartDashboard() {
371
563
  const paths = [..._instance.registry.values()].map(p => p._path);
372
564
  const port = _instance.port;
373
565
 
566
+ if (_instance.hub) teardownLivePush(_instance.hub);
374
567
  _instance.server.close();
375
568
  _instance = null;
376
569
 
@@ -1 +1 @@
1
- {"version":"4.9.9"}
1
+ {"version":"4.9.12"}
@@ -51,6 +51,8 @@ const TRAILER_MAP = {
51
51
  'From': { key: 'from' },
52
52
  'Restore-To': { key: 'restoreTo' },
53
53
  'File': { key: 'restoreFile' },
54
+ 'Guard-Diff-Base': { key: 'guardDiffBase' },
55
+ 'Guard-Scope': { key: 'guardScope' },
54
56
  };
55
57
 
56
58
  function parseCommitTrailers(body) {
@@ -61,7 +63,9 @@ function parseCommitTrailers(body) {
61
63
  const m = line.match(pattern);
62
64
  if (m) {
63
65
  const def = TRAILER_MAP[m[1]];
64
- result[def.key] = def.parse ? def.parse(m[2]) : m[2];
66
+ const raw = m[2].replace(/\r/g, '');
67
+ const val = def.parse ? def.parse(raw) : raw.trim();
68
+ result[def.key] = typeof val === 'string' ? val.trim() : val;
65
69
  }
66
70
  }
67
71
  return result;
@@ -158,26 +162,37 @@ function listBackups(projectDir, opts = {}) {
158
162
  }
159
163
  }
160
164
 
161
- // Agent snapshot ref
162
- const snapshotHash = git(['rev-parse', '--verify', 'refs/guard/snapshot'], { cwd: projectDir, allowFail: true });
163
- if (snapshotHash) {
164
- const snapLog = git(['log', '-1', '--format=%aI\x1f%B', 'refs/guard/snapshot'], { cwd: projectDir, allowFail: true });
165
- const snapParts = snapLog ? snapLog.split('\x1f') : [];
166
- const ts = snapParts[0] || null;
167
- const snapBody = snapParts[1] || '';
168
- const snapTrailers = parseCommitTrailers(snapBody);
169
- const snapSubject = snapBody.split('\n')[0] || '';
170
- const include = !beforeDate || (ts && Date.parse(ts) <= beforeDate.getTime());
171
- if (include) {
172
- sources.push({
173
- type: 'git-snapshot',
174
- ref: 'refs/guard/snapshot',
175
- commitHash: snapshotHash,
176
- shortHash: snapshotHash.substring(0, 7),
177
- timestamp: ts,
178
- message: snapSubject || undefined,
179
- ...snapTrailers,
180
- });
165
+ // Manual / IDE snapshot ref (full history on dedicated ref; no subject grep so test/custom messages still list)
166
+ const snapRef = 'refs/guard/snapshot';
167
+ const snapshotExists = git(['rev-parse', '--verify', snapRef], { cwd: projectDir, allowFail: true });
168
+ if (snapshotExists) {
169
+ const snapLogArgs = ['log', snapRef, '--format=%H\x1f%aI\x1f%B\x1e', `-${limit}`];
170
+ if (opts.before) snapLogArgs.push(`--before=${opts.before}`);
171
+ if (opts.file) snapLogArgs.push('--', opts.file);
172
+ const snapOut = git(snapLogArgs, { cwd: projectDir, allowFail: true });
173
+ if (snapOut) {
174
+ for (const record of snapOut.split('\x1e').filter(r => r.trim())) {
175
+ const parts = record.split('\x1f');
176
+ if (parts.length < 3) continue;
177
+ const hash = parts[0].trim();
178
+ const timestamp = parts[1];
179
+ const body = parts[2];
180
+ const subject = body.split('\n')[0];
181
+ const trailers = parseCommitTrailers(body);
182
+ if (beforeDate && timestamp) {
183
+ const ms = Date.parse(timestamp);
184
+ if (!isNaN(ms) && ms > beforeDate.getTime()) continue;
185
+ }
186
+ sources.push({
187
+ type: 'git-snapshot',
188
+ ref: snapRef,
189
+ commitHash: hash,
190
+ shortHash: hash.substring(0, 7),
191
+ timestamp,
192
+ message: subject || undefined,
193
+ ...trailers,
194
+ });
195
+ }
181
196
  }
182
197
  }
183
198
  }
@@ -14,6 +14,36 @@ function formatTimestamp(d) {
14
14
  return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
15
15
  }
16
16
 
17
+ const REF_GUARD_AUTO_BACKUP = 'refs/guard/auto-backup';
18
+ const REF_GUARD_SNAPSHOT = 'refs/guard/snapshot';
19
+
20
+ /**
21
+ * Parent commit for the next Guard Git snapshot (first parent of `commit-tree`).
22
+ *
23
+ * For **refs/guard/auto-backup** and **refs/guard/snapshot** only: pick whichever tip is
24
+ * **newer in commit time** between the two refs. That matches the human reading: "changes
25
+ * since the **last** Guard backup, automatic or manual" — one shared baseline for +/- and file counts.
26
+ *
27
+ * For any **other** `branchRef` (e.g. tests using refs/guard/test-*): chain that ref only.
28
+ */
29
+ function resolveGuardParentHash(cwd, branchRef) {
30
+ if (branchRef !== REF_GUARD_AUTO_BACKUP && branchRef !== REF_GUARD_SNAPSHOT) {
31
+ return git(['rev-parse', '--verify', branchRef], { cwd, allowFail: true });
32
+ }
33
+ const autoH = git(['rev-parse', '--verify', REF_GUARD_AUTO_BACKUP], { cwd, allowFail: true });
34
+ const snapH = git(['rev-parse', '--verify', REF_GUARD_SNAPSHOT], { cwd, allowFail: true });
35
+ if (!autoH && !snapH) return null;
36
+ if (!autoH) return snapH;
37
+ if (!snapH) return autoH;
38
+ const commitUnix = h => {
39
+ const s = git(['log', '-1', '--format=%ct', h], { cwd, allowFail: true });
40
+ return s ? parseInt(String(s).trim(), 10) : 0;
41
+ };
42
+ const tAuto = commitUnix(autoH);
43
+ const tSnap = commitUnix(snapH);
44
+ return tSnap > tAuto ? snapH : autoH;
45
+ }
46
+
17
47
  function listIndexFiles(cwd, env) {
18
48
  try {
19
49
  const out = execFileSync('git', ['ls-files', '--cached'], {
@@ -94,6 +124,8 @@ function buildCommitMessage(ts, opts) {
94
124
  * @param {boolean} [opts.allowEmptyTree] - If true, still create a commit when the snapshot tree equals the previous ref (empty / bookmark commit). Auto-backup should omit this; explicit manual snapshots should set it.
95
125
  * @param {boolean} [opts.fullWorkspaceSnapshot] - If true, ignore `cfg.protect` when building the snapshot tree (still apply `ignore` / secrets). Use for IDE/MCP "snapshot everything" so edits outside protect patterns are not invisible to the snapshot.
96
126
  * @returns {{ status: 'created'|'skipped'|'error', commitHash?: string, shortHash?: string, fileCount?: number, reason?: string, error?: string, secretsExcluded?: string[] }}
127
+ * @remarks For refs/guard/auto-backup and refs/guard/snapshot, the first parent is always the
128
+ * newer of those two tips (by commit time), so incremental stats mean "since last Guard backup" in the human sense.
97
129
  */
98
130
  function createGitSnapshot(projectDir, cfg, opts = {}) {
99
131
  const branchRef = opts.branchRef || 'refs/guard/auto-backup';
@@ -111,7 +143,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
111
143
  try { fs.unlinkSync(guardIndexLock); } catch { /* doesn't exist */ }
112
144
 
113
145
  try {
114
- const parentHash = git(['rev-parse', '--verify', branchRef], { cwd, allowFail: true });
146
+ const parentHash = resolveGuardParentHash(cwd, branchRef);
115
147
 
116
148
  if (narrowProtect) {
117
149
  // protect uses strict matching (full path only, no basename fallback)
@@ -120,7 +152,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
120
152
  pruneIndexFiles(cwd, env, f => !matchesAny(cfg.protect, f, { strict: true }));
121
153
  } else {
122
154
  if (parentHash) {
123
- execFileSync('git', ['read-tree', branchRef], { cwd, env, stdio: 'pipe' });
155
+ execFileSync('git', ['read-tree', parentHash], { cwd, env, stdio: 'pipe' });
124
156
  }
125
157
  execFileSync('git', ['add', '-A'], { cwd, env, stdio: 'pipe' });
126
158
  }
@@ -133,7 +165,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
133
165
 
134
166
  const newTree = execFileSync('git', ['write-tree'], { cwd, env, stdio: 'pipe', encoding: 'utf-8' }).trim();
135
167
  const parentTree = parentHash
136
- ? git(['rev-parse', `${branchRef}^{tree}`], { cwd, allowFail: true })
168
+ ? git(['rev-parse', `${parentHash}^{tree}`], { cwd, allowFail: true })
137
169
  : null;
138
170
 
139
171
  if (newTree === parentTree && !opts.allowEmptyTree) {
@@ -246,7 +278,22 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
246
278
  }
247
279
 
248
280
  const ts = formatTimestamp(new Date());
249
- const msg = buildCommitMessage(ts, opts);
281
+ let msg = buildCommitMessage(ts, opts);
282
+
283
+ const autoTip = git(['rev-parse', '--verify', REF_GUARD_AUTO_BACKUP], { cwd, allowFail: true });
284
+ const snapTip = git(['rev-parse', '--verify', REF_GUARD_SNAPSHOT], { cwd, allowFail: true });
285
+ const autoTipTrim = autoTip ? String(autoTip).trim() : '';
286
+ const snapTipTrim = snapTip ? String(snapTip).trim() : '';
287
+ let diffBaseLabel = 'initial';
288
+ if (parentHash) {
289
+ if (parentHash === autoTipTrim) diffBaseLabel = 'auto-backup';
290
+ else if (parentHash === snapTipTrim) diffBaseLabel = 'snapshot';
291
+ else diffBaseLabel = 'other';
292
+ }
293
+ const scopeTrailer = narrowProtect ? 'narrow' : 'full';
294
+ const guardBlock = `Guard-Diff-Base: ${diffBaseLabel}\nGuard-Scope: ${scopeTrailer}`;
295
+ msg = msg.includes('\n\n') ? `${msg}\n${guardBlock}` : `${msg}\n\n${guardBlock}`;
296
+
250
297
  const commitArgs = parentHash
251
298
  ? ['commit-tree', newTree, '-p', parentHash, '-m', msg]
252
299
  : ['commit-tree', newTree, '-m', msg];
@@ -1,8 +1,23 @@
1
1
  'use strict';
2
2
 
3
- const vscode = require('vscode');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { guardPath } = require('./paths');
4
6
 
5
- const HEARTBEAT_INTERVAL = 30000;
7
+ /** Safety net if fs.watch misses (platform limits); primary updates are filesystem events. */
8
+ const FALLBACK_POLL_MS = 180000;
9
+
10
+ function tryWatchDirRecursive(dir, cb) {
11
+ try {
12
+ return fs.watch(dir, { recursive: true }, cb);
13
+ } catch {
14
+ try {
15
+ return fs.watch(dir, cb);
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+ }
6
21
 
7
22
  class Poller {
8
23
  constructor(dashMgr) {
@@ -11,6 +26,12 @@ class Poller {
11
26
  this._listeners = [];
12
27
  this._data = new Map();
13
28
  this._pollRunning = false;
29
+ this._pollAgainAfter = false;
30
+ this._pollWaiters = [];
31
+ this._fsWatchers = [];
32
+ this._watchedRegistryKey = '';
33
+ this._fsDebounceTimer = null;
34
+ this._reattachTimer = null;
14
35
  }
15
36
 
16
37
  get data() { return this._data; }
@@ -26,10 +47,107 @@ class Poller {
26
47
  }
27
48
  }
28
49
 
50
+ _teardownFsWatchers() {
51
+ if (this._reattachTimer) {
52
+ clearTimeout(this._reattachTimer);
53
+ this._reattachTimer = null;
54
+ }
55
+ if (this._fsDebounceTimer) {
56
+ clearTimeout(this._fsDebounceTimer);
57
+ this._fsDebounceTimer = null;
58
+ }
59
+ for (const w of this._fsWatchers) {
60
+ try { w.close(); } catch { /* ignore */ }
61
+ }
62
+ this._fsWatchers = [];
63
+ }
64
+
65
+ _scheduleFsRefresh() {
66
+ if (this._fsDebounceTimer) clearTimeout(this._fsDebounceTimer);
67
+ this._fsDebounceTimer = setTimeout(() => {
68
+ this._fsDebounceTimer = null;
69
+ void this.forceRefresh();
70
+ }, 400);
71
+ }
72
+
73
+ _syncFileWatchers() {
74
+ const reg = this._dashMgr?.registry;
75
+ if (!reg) return;
76
+ const key = [...reg.entries()].map(([k, v]) => `${k}:${v._path}`).sort().join('|');
77
+ if (key === this._watchedRegistryKey) return;
78
+ this._watchedRegistryKey = key;
79
+
80
+ this._teardownFsWatchers();
81
+
82
+ let utils;
83
+ try {
84
+ utils = require(guardPath('lib', 'utils'));
85
+ } catch {
86
+ return;
87
+ }
88
+ const { isGitRepo, gitDir: getGitDir } = utils;
89
+
90
+ for (const proj of reg.values()) {
91
+ const pp = proj._path;
92
+ const pid = proj.id;
93
+
94
+ if (isGitRepo(pp)) {
95
+ const gDir = getGitDir(pp);
96
+ if (gDir && fs.existsSync(gDir)) {
97
+ try {
98
+ const w = fs.watch(gDir, (ev, fname) => {
99
+ if (fname === 'cursor-guard-alert.json' || fname === 'cursor-guard.lock') {
100
+ this._scheduleFsRefresh();
101
+ }
102
+ });
103
+ this._fsWatchers.push(w);
104
+ } catch { /* ignore */ }
105
+
106
+ const refsDir = path.join(gDir, 'refs');
107
+ if (fs.existsSync(refsDir)) {
108
+ try {
109
+ const w = fs.watch(refsDir, (ev, fname) => {
110
+ if (!fname) return;
111
+ if (fname === 'guard' || fname.startsWith('guard')) {
112
+ this._scheduleFsRefresh();
113
+ if (!this._reattachTimer) {
114
+ this._reattachTimer = setTimeout(() => {
115
+ this._reattachTimer = null;
116
+ this._watchedRegistryKey = '';
117
+ this._syncFileWatchers();
118
+ }, 600);
119
+ }
120
+ }
121
+ });
122
+ this._fsWatchers.push(w);
123
+ } catch { /* ignore */ }
124
+ }
125
+
126
+ const guardDir = path.join(gDir, 'refs', 'guard');
127
+ if (fs.existsSync(guardDir)) {
128
+ const w = tryWatchDirRecursive(guardDir, () => this._scheduleFsRefresh());
129
+ if (w) this._fsWatchers.push(w);
130
+ const preRestoreDir = path.join(guardDir, 'pre-restore');
131
+ if (fs.existsSync(preRestoreDir)) {
132
+ const w2 = tryWatchDirRecursive(preRestoreDir, () => this._scheduleFsRefresh());
133
+ if (w2) this._fsWatchers.push(w2);
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ const backupDir = path.join(pp, '.cursor-guard-backup');
140
+ if (fs.existsSync(backupDir)) {
141
+ const w = tryWatchDirRecursive(backupDir, () => this._scheduleFsRefresh());
142
+ if (w) this._fsWatchers.push(w);
143
+ }
144
+ }
145
+ }
146
+
29
147
  start() {
30
148
  if (this._timer) return;
31
- this._poll();
32
- this._timer = setInterval(() => this._poll(), HEARTBEAT_INTERVAL);
149
+ void this._poll();
150
+ this._timer = setInterval(() => void this._poll(), FALLBACK_POLL_MS);
33
151
  }
34
152
 
35
153
  stop() {
@@ -37,33 +155,55 @@ class Poller {
37
155
  }
38
156
 
39
157
  async _poll() {
40
- if (this._pollRunning) return;
158
+ if (this._pollRunning) {
159
+ this._pollAgainAfter = true;
160
+ return;
161
+ }
41
162
  this._pollRunning = true;
42
163
  try {
43
- if (!this._dashMgr.running) return;
44
- const projects = await this._dashMgr.getProjects();
45
- if (!Array.isArray(projects)) return;
46
- for (const p of projects) {
47
- const fullData = await this._dashMgr.getFullPageData(p.id);
48
- this._data.set(p.id, {
49
- ...p,
50
- dashboard: fullData?.dashboard || null,
51
- backups: fullData?.backups || [],
52
- scope: fullData?.scope || null,
53
- doctor: fullData?.doctor || null,
54
- });
55
- }
56
- this._emit();
164
+ do {
165
+ this._pollAgainAfter = false;
166
+ if (!this._dashMgr.running) break;
167
+ const projects = await this._dashMgr.getProjects();
168
+ if (!Array.isArray(projects)) break;
169
+ for (const p of projects) {
170
+ const fullData = await this._dashMgr.getFullPageData(p.id);
171
+ this._data.set(p.id, {
172
+ ...p,
173
+ dashboard: fullData?.dashboard || null,
174
+ backups: fullData?.backups || [],
175
+ scope: fullData?.scope || null,
176
+ doctor: fullData?.doctor || null,
177
+ });
178
+ }
179
+ this._emit();
180
+ } while (this._pollAgainAfter);
57
181
  } catch { /* non-critical */ }
58
- finally { this._pollRunning = false; }
182
+ finally {
183
+ this._pollRunning = false;
184
+ const waiters = this._pollWaiters.splice(0);
185
+ for (const fn of waiters) {
186
+ try { fn(); } catch { /* ignore */ }
187
+ }
188
+ try {
189
+ this._syncFileWatchers();
190
+ } catch { /* ignore */ }
191
+ }
59
192
  }
60
193
 
61
194
  async forceRefresh() {
62
- await this._poll();
195
+ return new Promise(resolve => {
196
+ this._pollWaiters.push(resolve);
197
+ this._pollAgainAfter = true;
198
+ if (!this._pollRunning) {
199
+ void this._poll();
200
+ }
201
+ });
63
202
  }
64
203
 
65
204
  dispose() {
66
205
  this.stop();
206
+ this._teardownFsWatchers();
67
207
  this._listeners = [];
68
208
  }
69
209
  }