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.
- package/lib/server.js +173 -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
|
-
|
|
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];
|