vibe-annotations-server 0.1.16 → 0.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.
Files changed (2) hide show
  1. package/lib/server.js +173 -2
  2. package/package.json +1 -1
package/lib/server.js CHANGED
@@ -47,9 +47,48 @@ class LocalAnnotationsServer {
47
47
  this.transports = {}; // Track transport sessions
48
48
  this.connections = new Set(); // Track HTTP connections
49
49
  this.saveLock = Promise.resolve(); // Serialize save operations to prevent race conditions
50
-
50
+ this.watchers = new Map(); // watcherId → { url, registeredAt, lastSeenAt, polling, abort }
51
+ this.WATCHER_GRACE_MS = 120_000; // Watcher stays "active" for 2min after last seen (covers agent processing)
52
+
51
53
  this.setupExpress();
52
54
  this.setupMCP();
55
+
56
+ // Periodic sweep: remove watchers whose grace period expired
57
+ this.watcherSweepInterval = setInterval(() => this.pruneStaleWatchers(), 15_000);
58
+ }
59
+
60
+ pruneStaleWatchers() {
61
+ const now = Date.now();
62
+ for (const [id, w] of this.watchers) {
63
+ const graceExpired = !w.polling && now - w.lastSeenAt > this.WATCHER_GRACE_MS;
64
+ // Hard max: force-kill watchers registered longer than their timeout + grace (orphaned loops)
65
+ const hardExpired = now - w.registeredAt > (w.timeoutMs || 300_000) + this.WATCHER_GRACE_MS;
66
+ if (graceExpired || hardExpired) {
67
+ if (w.abort) w.abort.abort();
68
+ this.watchers.delete(id);
69
+ console.log(`Pruned ${hardExpired ? 'expired' : 'stale'} watcher: ${id} (url: ${w.url})`);
70
+ }
71
+ }
72
+ }
73
+
74
+ stopAllWatchers() {
75
+ for (const [id, w] of this.watchers) {
76
+ if (w.abort) w.abort.abort();
77
+ console.log(`Stopped watcher: ${id} (url: ${w.url})`);
78
+ }
79
+ this.watchers.clear();
80
+ }
81
+
82
+ getActiveWatchers() {
83
+ const now = Date.now();
84
+ const active = [];
85
+ for (const [id, w] of this.watchers) {
86
+ // Active if loop is running OR within grace period after returning
87
+ if (w.polling || now - w.lastSeenAt <= this.WATCHER_GRACE_MS) {
88
+ active.push({ id, url: w.url, registeredAt: w.registeredAt });
89
+ }
90
+ }
91
+ return active;
53
92
  }
54
93
 
55
94
  setupExpress() {
@@ -227,6 +266,18 @@ class LocalAnnotationsServer {
227
266
  }
228
267
  });
229
268
 
269
+ // Watcher status endpoint (for extension to poll)
270
+ this.app.get('/api/watchers', (req, res) => {
271
+ const watchers = this.getActiveWatchers();
272
+ res.json({ watchers, watching: watchers.length > 0 });
273
+ });
274
+
275
+ // Stop all watchers (called by extension eye button)
276
+ this.app.post('/api/watchers/stop', (req, res) => {
277
+ this.stopAllWatchers();
278
+ res.json({ success: true });
279
+ });
280
+
230
281
  // SSE endpoint for MCP connection (proper MCP SSE transport)
231
282
  this.app.get('/sse', async (req, res) => {
232
283
  console.log('Received GET request to /sse (MCP SSE transport)');
@@ -465,6 +516,28 @@ class LocalAnnotationsServer {
465
516
  required: ['id'],
466
517
  additionalProperties: false
467
518
  }
519
+ },
520
+ {
521
+ name: 'watch_annotations',
522
+ description: 'Watch for new annotations on a localhost project. This tool blocks until pending annotations appear, then returns them. Use this in a loop for hands-free mode: call watch_annotations → implement each annotation → call delete_annotation → call watch_annotations again. The tool polls every 10 seconds and automatically stops after the timeout period (default 5 minutes) of no new annotations appearing. IMPORTANT: You must know the localhost URL of the project you are watching. If you do not know it, ask the user before calling this tool. Example: "http://localhost:3000/*" watches all pages on port 3000.',
523
+ inputSchema: {
524
+ type: 'object',
525
+ properties: {
526
+ url: {
527
+ type: 'string',
528
+ description: 'Localhost URL pattern to watch (e.g., "http://localhost:3000/*" or "http://localhost:5173/*"). Required — ask the user if unknown.'
529
+ },
530
+ timeout: {
531
+ type: 'number',
532
+ default: 300,
533
+ minimum: 30,
534
+ maximum: 1800,
535
+ description: 'Seconds of empty polls before auto-stopping (default: 300 = 5 minutes)'
536
+ }
537
+ },
538
+ required: ['url'],
539
+ additionalProperties: false
540
+ }
468
541
  }
469
542
  ]
470
543
  };
@@ -566,6 +639,25 @@ class LocalAnnotationsServer {
566
639
  };
567
640
  }
568
641
 
642
+ case 'watch_annotations': {
643
+ const result = await this.watchAnnotations(args || {});
644
+ return {
645
+ content: [
646
+ {
647
+ type: 'text',
648
+ text: JSON.stringify({
649
+ tool: 'watch_annotations',
650
+ status: result.timeout ? 'timeout' : 'success',
651
+ data: result.annotations || [],
652
+ count: result.annotations?.length || 0,
653
+ message: result.message,
654
+ timestamp: new Date().toISOString()
655
+ }, null, 2)
656
+ }
657
+ ]
658
+ };
659
+ }
660
+
569
661
  default:
570
662
  throw new Error(`Unknown tool: ${name}`);
571
663
  }
@@ -807,6 +899,84 @@ class LocalAnnotationsServer {
807
899
  };
808
900
  }
809
901
 
902
+ async watchAnnotations(args) {
903
+ const { url, timeout = 300 } = args;
904
+ if (!url) throw new Error('url parameter is required');
905
+
906
+ // Abort any existing watchers for the same URL (prevents accumulation)
907
+ for (const [id, w] of this.watchers) {
908
+ if (w.url === url) {
909
+ if (w.abort) w.abort.abort();
910
+ this.watchers.delete(id);
911
+ console.log(`Replaced watcher ${id} for ${url}`);
912
+ }
913
+ }
914
+
915
+ const watcherId = randomUUID();
916
+ const ac = new AbortController();
917
+ const now = Date.now();
918
+ const timeoutMs = timeout * 1000;
919
+ this.watchers.set(watcherId, { url, registeredAt: now, lastSeenAt: now, polling: true, abort: ac, timeoutMs });
920
+ console.log(`Watcher started: ${watcherId} watching ${url} (timeout: ${timeout}s)`);
921
+
922
+ const pollIntervalMs = 10_000; // 10 seconds
923
+ const startTime = Date.now();
924
+
925
+ // URL filter helper (same logic as readAnnotations)
926
+ const matchesUrl = (annotationUrl) => {
927
+ if (url.includes('*') || url.endsWith('/')) {
928
+ const baseUrl = url.replace('*', '').replace(/\/$/, '');
929
+ return annotationUrl.startsWith(baseUrl);
930
+ }
931
+ return annotationUrl === url;
932
+ };
933
+
934
+ // Abortable sleep
935
+ const sleep = (ms) => new Promise((resolve, reject) => {
936
+ const timer = setTimeout(resolve, ms);
937
+ ac.signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('aborted')); }, { once: true });
938
+ });
939
+
940
+ try {
941
+ while (Date.now() - startTime < timeoutMs) {
942
+ if (ac.signal.aborted) break;
943
+
944
+ const annotations = await this.loadAnnotations();
945
+ const pending = annotations.filter(a => a.status === 'pending' && matchesUrl(a.url));
946
+
947
+ if (pending.length > 0) {
948
+ // Mark not polling, update lastSeenAt — grace period covers agent processing time
949
+ const w = this.watchers.get(watcherId);
950
+ if (w) { w.polling = false; w.lastSeenAt = Date.now(); }
951
+ console.log(`Watcher ${watcherId}: found ${pending.length} annotations`);
952
+
953
+ // Strip screenshots, add flag (same as readAnnotations)
954
+ const cleaned = pending.map(({ screenshot, ...rest }) => ({
955
+ ...rest,
956
+ has_screenshot: !!(screenshot && screenshot.data_url)
957
+ }));
958
+
959
+ return { annotations: cleaned, message: `Found ${cleaned.length} pending annotations` };
960
+ }
961
+
962
+ // Don't update lastSeenAt during idle polling — let the sweep detect abandoned watchers
963
+ await sleep(pollIntervalMs);
964
+ }
965
+
966
+ // Timeout — no annotations found, fully remove watcher
967
+ this.watchers.delete(watcherId);
968
+ console.log(`Watcher ${watcherId}: timed out after ${timeout}s`);
969
+ return { timeout: true, annotations: [], message: `No new annotations for ${timeout} seconds, watch stopped. Call watch_annotations again to resume.` };
970
+ } catch (error) {
971
+ this.watchers.delete(watcherId); // Fully remove on error
972
+ if (error.message === 'aborted') {
973
+ console.log(`Watcher ${watcherId}: aborted`);
974
+ return { timeout: true, annotations: [], message: 'Watch aborted' };
975
+ }
976
+ throw error;
977
+ }
978
+ }
979
+
810
980
  async deleteAnnotation(args) {
811
981
  const { id } = args;
812
982
 
@@ -814,7 +984,8 @@ class LocalAnnotationsServer {
814
984
  const index = annotations.findIndex(a => a.id === id);
815
985
 
816
986
  if (index === -1) {
817
- throw new Error(`Annotation with id ${id} not found`);
987
+ // Already gone — treat as success (extension may have synced/removed it)
988
+ return { id, deleted: true, message: `Annotation ${id} already deleted or not found` };
818
989
  }
819
990
 
820
991
  const deletedAnnotation = annotations[index];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibe-annotations-server",
3
- "version": "0.1.16",
3
+ "version": "0.2.0",
4
4
  "description": "Global MCP server for Vibe Annotations browser extension",
5
5
  "main": "lib/server.js",
6
6
  "type": "module",