vibe-annotations-server 0.1.15 → 0.1.17
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/lib/server.js +177 -3
- 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() {
|
|
@@ -95,7 +134,10 @@ class LocalAnnotationsServer {
|
|
|
95
134
|
filtered = filtered.filter(a => a.url === url);
|
|
96
135
|
}
|
|
97
136
|
|
|
98
|
-
|
|
137
|
+
const parsedLimit = parseInt(limit);
|
|
138
|
+
if (parsedLimit > 0) {
|
|
139
|
+
filtered = filtered.slice(0, parsedLimit);
|
|
140
|
+
}
|
|
99
141
|
|
|
100
142
|
res.json({
|
|
101
143
|
annotations: filtered,
|
|
@@ -224,6 +266,18 @@ class LocalAnnotationsServer {
|
|
|
224
266
|
}
|
|
225
267
|
});
|
|
226
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
|
+
|
|
227
281
|
// SSE endpoint for MCP connection (proper MCP SSE transport)
|
|
228
282
|
this.app.get('/sse', async (req, res) => {
|
|
229
283
|
console.log('Received GET request to /sse (MCP SSE transport)');
|
|
@@ -462,6 +516,28 @@ class LocalAnnotationsServer {
|
|
|
462
516
|
required: ['id'],
|
|
463
517
|
additionalProperties: false
|
|
464
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
|
+
}
|
|
465
541
|
}
|
|
466
542
|
]
|
|
467
543
|
};
|
|
@@ -563,6 +639,25 @@ class LocalAnnotationsServer {
|
|
|
563
639
|
};
|
|
564
640
|
}
|
|
565
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
|
+
|
|
566
661
|
default:
|
|
567
662
|
throw new Error(`Unknown tool: ${name}`);
|
|
568
663
|
}
|
|
@@ -804,6 +899,84 @@ class LocalAnnotationsServer {
|
|
|
804
899
|
};
|
|
805
900
|
}
|
|
806
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
|
+
|
|
807
980
|
async deleteAnnotation(args) {
|
|
808
981
|
const { id } = args;
|
|
809
982
|
|
|
@@ -811,7 +984,8 @@ class LocalAnnotationsServer {
|
|
|
811
984
|
const index = annotations.findIndex(a => a.id === id);
|
|
812
985
|
|
|
813
986
|
if (index === -1) {
|
|
814
|
-
|
|
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` };
|
|
815
989
|
}
|
|
816
990
|
|
|
817
991
|
const deletedAnnotation = annotations[index];
|