sentinel-mcp 0.2.0 → 0.4.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/bin/release-hook.sh +20 -0
- package/dist/image-server.d.ts +16 -0
- package/dist/image-server.js +122 -0
- package/dist/index.js +68 -60
- package/dist/queue.d.ts +11 -4
- package/dist/queue.js +98 -17
- package/dist/types.d.ts +12 -0
- package/package.json +5 -3
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# SubagentStop hook — writes a signal file for the sentinel-mcp file watcher.
|
|
3
|
+
# Called by Claude Code when a background subagent finishes.
|
|
4
|
+
# Reads hook input JSON from stdin, extracts the event ID, writes signal file.
|
|
5
|
+
|
|
6
|
+
COMPLETED_DIR="$HOME/.sentinel/completed"
|
|
7
|
+
mkdir -p "$COMPLETED_DIR"
|
|
8
|
+
|
|
9
|
+
# Read hook input from stdin
|
|
10
|
+
INPUT=$(cat)
|
|
11
|
+
|
|
12
|
+
# Try to extract SENTINEL_EVENT_ID from the subagent's last message or transcript
|
|
13
|
+
EVENT_ID=$(echo "$INPUT" | grep -o 'SENTINEL_EVENT_ID=[^ "\\]*' | head -1 | cut -d= -f2)
|
|
14
|
+
|
|
15
|
+
if [ -z "$EVENT_ID" ]; then
|
|
16
|
+
EVENT_ID="unknown"
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
# Write signal file — filename is the event ID
|
|
20
|
+
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$COMPLETED_DIR/$EVENT_ID"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare function isAllowedUrl(url: string): boolean;
|
|
2
|
+
export declare function findImageInHtml(html: string, uuid: string): string | null;
|
|
3
|
+
interface IssueContext {
|
|
4
|
+
repo: string;
|
|
5
|
+
issueNumber: number;
|
|
6
|
+
}
|
|
7
|
+
type FetchResult = {
|
|
8
|
+
ok: true;
|
|
9
|
+
data: string;
|
|
10
|
+
mimeType: string;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
error: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function fetchImage(url: string, token?: string, context?: IssueContext): Promise<FetchResult>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const ALLOWED_PATTERNS = [
|
|
2
|
+
/^https:\/\/github\.com\/user-attachments\/assets\//,
|
|
3
|
+
/^https:\/\/[a-z0-9-]+\.githubusercontent\.com\//,
|
|
4
|
+
];
|
|
5
|
+
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
6
|
+
export function isAllowedUrl(url) {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = new URL(url);
|
|
9
|
+
if (parsed.protocol !== "https:")
|
|
10
|
+
return false;
|
|
11
|
+
return ALLOWED_PATTERNS.some((p) => p.test(url));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function findImageInHtml(html, uuid) {
|
|
18
|
+
const imgPattern = /<img[^>]+src="([^"]+)"/g;
|
|
19
|
+
let match;
|
|
20
|
+
// First pass: match by UUID
|
|
21
|
+
while ((match = imgPattern.exec(html)) !== null) {
|
|
22
|
+
const src = match[1].replace(/&/g, "&");
|
|
23
|
+
if (src.includes(uuid))
|
|
24
|
+
return src;
|
|
25
|
+
}
|
|
26
|
+
// Second pass: fallback to first private-user-images URL
|
|
27
|
+
imgPattern.lastIndex = 0;
|
|
28
|
+
while ((match = imgPattern.exec(html)) !== null) {
|
|
29
|
+
const src = match[1].replace(/&/g, "&");
|
|
30
|
+
if (src.includes("private-user-images.githubusercontent.com"))
|
|
31
|
+
return src;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
async function fetchAndEncode(url, headers) {
|
|
36
|
+
let res;
|
|
37
|
+
try {
|
|
38
|
+
res = await fetch(url, { headers });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
error: `Fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
return { ok: false, error: `HTTP ${res.status}` };
|
|
48
|
+
}
|
|
49
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
50
|
+
if (!contentType.startsWith("image/")) {
|
|
51
|
+
return { ok: false, error: `Response is not an image (${contentType})` };
|
|
52
|
+
}
|
|
53
|
+
const buffer = await res.arrayBuffer();
|
|
54
|
+
if (buffer.byteLength > MAX_IMAGE_SIZE) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
error: `Image too large (${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB)`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const data = Buffer.from(buffer).toString("base64");
|
|
61
|
+
const mimeType = contentType.split(";")[0].trim();
|
|
62
|
+
return { ok: true, data, mimeType };
|
|
63
|
+
}
|
|
64
|
+
async function resolveImageFromIssueHtml(targetUrl, repo, issueNumber, token) {
|
|
65
|
+
const headers = {
|
|
66
|
+
Authorization: `token ${token}`,
|
|
67
|
+
Accept: "application/vnd.github.full+json",
|
|
68
|
+
"User-Agent": "sentinel-mcp/1.0",
|
|
69
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
70
|
+
};
|
|
71
|
+
const uuidMatch = targetUrl.match(/\/assets\/([a-f0-9-]+)/);
|
|
72
|
+
if (!uuidMatch)
|
|
73
|
+
return null;
|
|
74
|
+
const uuid = uuidMatch[1];
|
|
75
|
+
const [issueRes, commentsRes] = await Promise.all([
|
|
76
|
+
fetch(`https://api.github.com/repos/${repo}/issues/${issueNumber}`, { headers }),
|
|
77
|
+
fetch(`https://api.github.com/repos/${repo}/issues/${issueNumber}/comments?per_page=100`, { headers }),
|
|
78
|
+
]);
|
|
79
|
+
if (issueRes.ok) {
|
|
80
|
+
const issue = (await issueRes.json());
|
|
81
|
+
if (issue.body_html) {
|
|
82
|
+
const found = findImageInHtml(issue.body_html, uuid);
|
|
83
|
+
if (found)
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (commentsRes.ok) {
|
|
88
|
+
const comments = (await commentsRes.json());
|
|
89
|
+
for (const comment of comments) {
|
|
90
|
+
if (comment.body_html) {
|
|
91
|
+
const found = findImageInHtml(comment.body_html, uuid);
|
|
92
|
+
if (found)
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
export async function fetchImage(url, token, context) {
|
|
100
|
+
if (!isAllowedUrl(url)) {
|
|
101
|
+
return { ok: false, error: "URL not allowed" };
|
|
102
|
+
}
|
|
103
|
+
const headers = {
|
|
104
|
+
"User-Agent": "sentinel-mcp/1.0",
|
|
105
|
+
};
|
|
106
|
+
if (token) {
|
|
107
|
+
headers["Authorization"] = `token ${token}`;
|
|
108
|
+
}
|
|
109
|
+
const result = await fetchAndEncode(url, headers);
|
|
110
|
+
// If direct fetch failed and it's a user-attachments URL, try resolving via GitHub Issues API
|
|
111
|
+
if (!result.ok &&
|
|
112
|
+
/github\.com\/user-attachments\/assets\//.test(url) &&
|
|
113
|
+
token &&
|
|
114
|
+
context?.repo &&
|
|
115
|
+
context?.issueNumber) {
|
|
116
|
+
const resolvedUrl = await resolveImageFromIssueHtml(url, context.repo, context.issueNumber, token);
|
|
117
|
+
if (resolvedUrl) {
|
|
118
|
+
return fetchAndEncode(resolvedUrl, { "User-Agent": "sentinel-mcp/1.0" });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
3
|
import fs from "fs";
|
|
4
|
+
import os from "os";
|
|
4
5
|
import path from "path";
|
|
5
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { WsClient, decodeInstallationId } from "./ws-client.js";
|
|
9
10
|
import { EventQueue } from "./queue.js";
|
|
11
|
+
import { fetchImage } from "./image-server.js";
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
11
13
|
// Config from environment
|
|
12
14
|
// ---------------------------------------------------------------------------
|
|
@@ -142,6 +144,22 @@ async function mcpToolCall(toolName, args) {
|
|
|
142
144
|
return rpcResponse.result;
|
|
143
145
|
}
|
|
144
146
|
// ---------------------------------------------------------------------------
|
|
147
|
+
// Auto-push — deliver next event from queue when capacity is available
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
function autoPush() {
|
|
150
|
+
while (eventQueue.hasCapacity() && eventQueue.queueLength() > 0) {
|
|
151
|
+
const active = eventQueue.activate();
|
|
152
|
+
if (!active)
|
|
153
|
+
break;
|
|
154
|
+
updateEventStatus(active.usageId, "processing");
|
|
155
|
+
if (active.notification) {
|
|
156
|
+
const content = `SENTINEL_EVENT_ID=${active.id}\n${active.notification.content}`;
|
|
157
|
+
pushNotification(content, active.notification.meta);
|
|
158
|
+
}
|
|
159
|
+
log(`Activated event ${active.id} (#${active.issueNumber} in ${active.repo})`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
145
163
|
// WebSocket client — receives events from relay, pushes as channel notifications
|
|
146
164
|
// ---------------------------------------------------------------------------
|
|
147
165
|
const wsClient = new WsClient({
|
|
@@ -150,21 +168,30 @@ const wsClient = new WsClient({
|
|
|
150
168
|
repo: REPO_NAME || undefined,
|
|
151
169
|
log,
|
|
152
170
|
onEvent: (id, payload, notification, usageId) => {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
171
|
+
const result = eventQueue.enqueue(id, usageId ?? null, payload, notification ?? null);
|
|
172
|
+
switch (result.action) {
|
|
173
|
+
case "duplicate":
|
|
174
|
+
log(`Duplicate event ${id}, skipping`);
|
|
175
|
+
break;
|
|
176
|
+
case "bypass":
|
|
177
|
+
// Comment/lifecycle for active issue — push directly, no slot consumed
|
|
178
|
+
updateEventStatus(usageId ?? null, "processing");
|
|
179
|
+
if (result.event.notification) {
|
|
180
|
+
pushNotification(`SENTINEL_EVENT_ID=${result.event.id}\n${result.event.notification.content}`, result.event.notification.meta);
|
|
181
|
+
}
|
|
182
|
+
log(`Bypass event ${id} (follow-up for active issue #${result.event.issueNumber})`);
|
|
183
|
+
break;
|
|
184
|
+
case "dequeue":
|
|
185
|
+
log(`Dequeued event ${result.removedId} — issue resolved before processing`);
|
|
186
|
+
break;
|
|
187
|
+
case "enqueued":
|
|
188
|
+
updateEventStatus(usageId ?? null, "pending");
|
|
189
|
+
log(`Queued event ${id} (${payload.type} #${payload.type === "pr_merged" ? payload.issue_number : payload.issue?.number}) — queue: ${eventQueue.queueLength()}`);
|
|
190
|
+
autoPush();
|
|
191
|
+
break;
|
|
160
192
|
}
|
|
161
|
-
// ACK immediately
|
|
193
|
+
// ACK immediately
|
|
162
194
|
wsClient.ack(id);
|
|
163
|
-
// If capacity available, nudge Claude
|
|
164
|
-
if (eventQueue.hasCapacity() && eventQueue.queueLength() > 0) {
|
|
165
|
-
const issueNum = payload.type === "pr_merged" ? payload.issue_number : payload.issue?.number;
|
|
166
|
-
pushNotification(`New event queued: issue #${issueNum} in ${payload.repo} (${payload.type}). Call claim_event to start processing.`, { type: "queue_nudge", queueLength: String(eventQueue.queueLength()) });
|
|
167
|
-
}
|
|
168
195
|
},
|
|
169
196
|
onDisplaced: (reason) => {
|
|
170
197
|
pushNotification(`Sentinel disconnected: ${reason}`, { type: "displaced" });
|
|
@@ -235,8 +262,16 @@ server.registerTool("fetch_image", {
|
|
|
235
262
|
issue_number: z.number().describe("Issue number where the image was posted"),
|
|
236
263
|
},
|
|
237
264
|
}, async ({ url, repo, issue_number }) => {
|
|
238
|
-
const result = await
|
|
239
|
-
|
|
265
|
+
const result = await fetchImage(url, githubToken, { repo, issueNumber: issue_number });
|
|
266
|
+
if (!result.ok) {
|
|
267
|
+
return {
|
|
268
|
+
content: [{ type: "text", text: `${result.error}. Ask the user to describe the image or paste it as text.` }],
|
|
269
|
+
isError: true,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
content: [{ type: "image", data: result.data, mimeType: result.mimeType }],
|
|
274
|
+
};
|
|
240
275
|
});
|
|
241
276
|
server.registerTool("register_worktree", {
|
|
242
277
|
description: "Register a new worktree for persistent subagent tracking. Call this after creating a worktree with git.",
|
|
@@ -259,51 +294,6 @@ server.registerTool("get_skill", {
|
|
|
259
294
|
const result = await mcpToolCall("get_skill", { name });
|
|
260
295
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
261
296
|
});
|
|
262
|
-
server.registerTool("claim_event", {
|
|
263
|
-
description: "Claim the next queued event for processing. Returns event details or null if queue is empty.",
|
|
264
|
-
}, async () => {
|
|
265
|
-
const event = eventQueue.claim();
|
|
266
|
-
if (!event) {
|
|
267
|
-
return { content: [{ type: "text", text: JSON.stringify(null) }] };
|
|
268
|
-
}
|
|
269
|
-
updateEventStatus(event.usageId, "processing");
|
|
270
|
-
return {
|
|
271
|
-
content: [{
|
|
272
|
-
type: "text",
|
|
273
|
-
text: JSON.stringify({
|
|
274
|
-
id: event.id,
|
|
275
|
-
usageId: event.usageId,
|
|
276
|
-
notification: event.notification,
|
|
277
|
-
payload: event.payload,
|
|
278
|
-
}),
|
|
279
|
-
}],
|
|
280
|
-
};
|
|
281
|
-
});
|
|
282
|
-
server.registerTool("release_event", {
|
|
283
|
-
description: "Release an event after processing completes (success or failure). Triggers next event if queue is non-empty.",
|
|
284
|
-
inputSchema: {
|
|
285
|
-
id: z.string().describe("Event ID returned by claim_event"),
|
|
286
|
-
},
|
|
287
|
-
}, async ({ id }) => {
|
|
288
|
-
const released = eventQueue.release(id);
|
|
289
|
-
if (!released) {
|
|
290
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: "Event not found in active list" }) }] };
|
|
291
|
-
}
|
|
292
|
-
// If queue has items and capacity available, nudge Claude
|
|
293
|
-
if (eventQueue.hasCapacity() && eventQueue.queueLength() > 0) {
|
|
294
|
-
const status = eventQueue.status();
|
|
295
|
-
const next = status.queue[0];
|
|
296
|
-
pushNotification(`Event released. Next in queue: issue #${next.issueNumber} in ${next.repo} (${next.eventType}). Call claim_event to process.`, { type: "queue_nudge", queueLength: String(status.queueLength) });
|
|
297
|
-
}
|
|
298
|
-
return { content: [{ type: "text", text: JSON.stringify({ released: true }) }] };
|
|
299
|
-
});
|
|
300
|
-
server.registerTool("queue_status", {
|
|
301
|
-
description: "Get the current event queue status — queue length, active count, and details.",
|
|
302
|
-
}, async () => {
|
|
303
|
-
return {
|
|
304
|
-
content: [{ type: "text", text: JSON.stringify(eventQueue.status(), null, 2) }],
|
|
305
|
-
};
|
|
306
|
-
});
|
|
307
297
|
// ---------------------------------------------------------------------------
|
|
308
298
|
// Startup
|
|
309
299
|
// ---------------------------------------------------------------------------
|
|
@@ -313,6 +303,23 @@ async function main() {
|
|
|
313
303
|
await refreshGithubToken();
|
|
314
304
|
tokenRefreshTimer = setInterval(refreshGithubToken, 50 * 60 * 1000);
|
|
315
305
|
wsClient.connect();
|
|
306
|
+
// Start file watcher for deterministic release
|
|
307
|
+
const completedDir = path.join(os.homedir(), ".sentinel", "completed");
|
|
308
|
+
eventQueue.startFileWatcher(completedDir, (eventId, usageId) => {
|
|
309
|
+
log(`Released event ${eventId} via hook signal`);
|
|
310
|
+
updateEventStatus(usageId, "delivered");
|
|
311
|
+
autoPush();
|
|
312
|
+
});
|
|
313
|
+
// Start timeout checker (every 60s, 30-min timeout)
|
|
314
|
+
setInterval(() => {
|
|
315
|
+
const released = eventQueue.checkTimeouts(30 * 60 * 1000);
|
|
316
|
+
for (const event of released) {
|
|
317
|
+
log(`Timeout: auto-released stale event ${event.id}`);
|
|
318
|
+
updateEventStatus(event.usageId, "delivered");
|
|
319
|
+
}
|
|
320
|
+
if (released.length > 0)
|
|
321
|
+
autoPush();
|
|
322
|
+
}, 60 * 1000);
|
|
316
323
|
log("Sentinel MCP bridge running");
|
|
317
324
|
}
|
|
318
325
|
main().catch((err) => {
|
|
@@ -326,6 +333,7 @@ function shutdown() {
|
|
|
326
333
|
log("Shutting down...");
|
|
327
334
|
if (tokenRefreshTimer)
|
|
328
335
|
clearInterval(tokenRefreshTimer);
|
|
336
|
+
eventQueue.stopFileWatcher();
|
|
329
337
|
wsClient.disconnect();
|
|
330
338
|
server.close().catch(() => { });
|
|
331
339
|
process.exit(0);
|
package/dist/queue.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ActiveEvent, SentinelEvent, EnqueueResult } from "./types.js";
|
|
2
2
|
export interface EventQueueOptions {
|
|
3
3
|
queueFilePath?: string;
|
|
4
4
|
maxConcurrency?: number;
|
|
@@ -7,13 +7,15 @@ export interface EventQueueOptions {
|
|
|
7
7
|
export declare class EventQueue {
|
|
8
8
|
private state;
|
|
9
9
|
private filePath;
|
|
10
|
+
private watcher;
|
|
11
|
+
private completedDir;
|
|
10
12
|
constructor(opts?: EventQueueOptions);
|
|
11
13
|
enqueue(id: string, usageId: string | null, payload: SentinelEvent, notification: {
|
|
12
14
|
content: string;
|
|
13
15
|
meta: Record<string, string>;
|
|
14
|
-
} | null):
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
} | null): EnqueueResult;
|
|
17
|
+
activate(): ActiveEvent | null;
|
|
18
|
+
releaseById(id: string): ActiveEvent | null;
|
|
17
19
|
hasCapacity(): boolean;
|
|
18
20
|
queueLength(): number;
|
|
19
21
|
status(): {
|
|
@@ -35,6 +37,11 @@ export declare class EventQueue {
|
|
|
35
37
|
startedAt: string;
|
|
36
38
|
}>;
|
|
37
39
|
};
|
|
40
|
+
getActiveEvents(): ActiveEvent[];
|
|
41
|
+
checkTimeouts(timeoutMs: number): ActiveEvent[];
|
|
42
|
+
startFileWatcher(completedDir: string, onRelease: (eventId: string, usageId: string | null) => void): void;
|
|
43
|
+
stopFileWatcher(): void;
|
|
44
|
+
private processSignalFiles;
|
|
38
45
|
private load;
|
|
39
46
|
private persist;
|
|
40
47
|
}
|
package/dist/queue.js
CHANGED
|
@@ -4,6 +4,8 @@ import os from "os";
|
|
|
4
4
|
export class EventQueue {
|
|
5
5
|
state;
|
|
6
6
|
filePath;
|
|
7
|
+
watcher = null;
|
|
8
|
+
completedDir = "";
|
|
7
9
|
constructor(opts = {}) {
|
|
8
10
|
this.filePath = opts.queueFilePath ?? path.join(os.homedir(), ".sentinel", "queue.json");
|
|
9
11
|
const maxConcurrency = opts.maxConcurrency ?? 3;
|
|
@@ -27,16 +29,17 @@ export class EventQueue {
|
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
31
|
enqueue(id, usageId, payload, notification) {
|
|
32
|
+
// Deduplicate
|
|
30
33
|
if (this.state.queue.some((e) => e.id === id) || this.state.active.some((e) => e.id === id)) {
|
|
31
|
-
return
|
|
34
|
+
return { action: "duplicate" };
|
|
32
35
|
}
|
|
33
36
|
const issueNumber = payload.type === "pr_merged" ? payload.issue_number : payload.issue.number;
|
|
34
|
-
const
|
|
35
|
-
const
|
|
37
|
+
const isActiveIssue = this.state.active.some((a) => a.issueNumber === issueNumber && a.repo === payload.repo);
|
|
38
|
+
const isQueuedIssue = this.state.queue.some((q) => q.issueNumber === issueNumber && q.repo === payload.repo);
|
|
36
39
|
const entry = {
|
|
37
40
|
id,
|
|
38
41
|
usageId,
|
|
39
|
-
priority,
|
|
42
|
+
priority: "normal",
|
|
40
43
|
eventType: payload.type,
|
|
41
44
|
repo: payload.repo,
|
|
42
45
|
issueNumber,
|
|
@@ -44,7 +47,21 @@ export class EventQueue {
|
|
|
44
47
|
payload,
|
|
45
48
|
enqueuedAt: new Date().toISOString(),
|
|
46
49
|
};
|
|
47
|
-
|
|
50
|
+
// Bypass: comment/lifecycle events for already-active issues — push directly, no slot
|
|
51
|
+
if (isActiveIssue && (payload.type === "issue_comment" || payload.type === "pr_merged" || payload.type === "issue_closed")) {
|
|
52
|
+
return { action: "bypass", event: entry };
|
|
53
|
+
}
|
|
54
|
+
// Dequeue: pr_merged/issue_closed for queued-but-not-active issues — remove the queued event
|
|
55
|
+
if (isQueuedIssue && (payload.type === "pr_merged" || payload.type === "issue_closed")) {
|
|
56
|
+
const idx = this.state.queue.findIndex((q) => q.issueNumber === issueNumber && q.repo === payload.repo);
|
|
57
|
+
const removed = this.state.queue[idx];
|
|
58
|
+
this.state.queue.splice(idx, 1);
|
|
59
|
+
this.persist();
|
|
60
|
+
return { action: "dequeue", removedId: removed.id };
|
|
61
|
+
}
|
|
62
|
+
// High priority: comment for queued-but-not-active issue
|
|
63
|
+
if (isQueuedIssue && payload.type === "issue_comment") {
|
|
64
|
+
entry.priority = "high";
|
|
48
65
|
const firstNormal = this.state.queue.findIndex((e) => e.priority === "normal");
|
|
49
66
|
if (firstNormal === -1) {
|
|
50
67
|
this.state.queue.push(entry);
|
|
@@ -52,18 +69,21 @@ export class EventQueue {
|
|
|
52
69
|
else {
|
|
53
70
|
this.state.queue.splice(firstNormal, 0, entry);
|
|
54
71
|
}
|
|
72
|
+
this.persist();
|
|
73
|
+
return { action: "enqueued", event: entry };
|
|
55
74
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
75
|
+
// Normal priority: new issue
|
|
76
|
+
this.state.queue.push(entry);
|
|
59
77
|
this.persist();
|
|
60
|
-
return
|
|
78
|
+
return { action: "enqueued", event: entry };
|
|
61
79
|
}
|
|
62
|
-
|
|
80
|
+
activate() {
|
|
63
81
|
if (this.state.queue.length === 0)
|
|
64
82
|
return null;
|
|
83
|
+
if (!this.hasCapacity())
|
|
84
|
+
return null;
|
|
65
85
|
const event = this.state.queue.shift();
|
|
66
|
-
|
|
86
|
+
const active = {
|
|
67
87
|
id: event.id,
|
|
68
88
|
usageId: event.usageId,
|
|
69
89
|
issueNumber: event.issueNumber,
|
|
@@ -71,17 +91,26 @@ export class EventQueue {
|
|
|
71
91
|
notification: event.notification,
|
|
72
92
|
payload: event.payload,
|
|
73
93
|
startedAt: new Date().toISOString(),
|
|
74
|
-
}
|
|
94
|
+
};
|
|
95
|
+
this.state.active.push(active);
|
|
75
96
|
this.persist();
|
|
76
|
-
return
|
|
97
|
+
return active;
|
|
77
98
|
}
|
|
78
|
-
|
|
99
|
+
releaseById(id) {
|
|
100
|
+
// Fallback: "unknown" releases oldest active event (FIFO)
|
|
101
|
+
if (id === "unknown") {
|
|
102
|
+
if (this.state.active.length === 0)
|
|
103
|
+
return null;
|
|
104
|
+
const released = this.state.active.shift();
|
|
105
|
+
this.persist();
|
|
106
|
+
return released;
|
|
107
|
+
}
|
|
79
108
|
const idx = this.state.active.findIndex((e) => e.id === id);
|
|
80
109
|
if (idx === -1)
|
|
81
|
-
return
|
|
82
|
-
this.state.active.splice(idx, 1);
|
|
110
|
+
return null;
|
|
111
|
+
const released = this.state.active.splice(idx, 1)[0];
|
|
83
112
|
this.persist();
|
|
84
|
-
return
|
|
113
|
+
return released;
|
|
85
114
|
}
|
|
86
115
|
hasCapacity() {
|
|
87
116
|
return this.state.active.length < this.state.maxConcurrency;
|
|
@@ -103,6 +132,58 @@ export class EventQueue {
|
|
|
103
132
|
})),
|
|
104
133
|
};
|
|
105
134
|
}
|
|
135
|
+
getActiveEvents() {
|
|
136
|
+
return [...this.state.active];
|
|
137
|
+
}
|
|
138
|
+
checkTimeouts(timeoutMs) {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const released = [];
|
|
141
|
+
const timedOut = this.state.active.filter((e) => now - new Date(e.startedAt).getTime() > timeoutMs);
|
|
142
|
+
for (const event of timedOut) {
|
|
143
|
+
const rel = this.releaseById(event.id);
|
|
144
|
+
if (rel)
|
|
145
|
+
released.push(rel);
|
|
146
|
+
}
|
|
147
|
+
return released;
|
|
148
|
+
}
|
|
149
|
+
startFileWatcher(completedDir, onRelease) {
|
|
150
|
+
this.completedDir = completedDir;
|
|
151
|
+
fs.mkdirSync(completedDir, { recursive: true });
|
|
152
|
+
// Process any existing signal files first
|
|
153
|
+
this.processSignalFiles(onRelease);
|
|
154
|
+
this.watcher = fs.watch(completedDir, () => {
|
|
155
|
+
this.processSignalFiles(onRelease);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
stopFileWatcher() {
|
|
159
|
+
if (this.watcher) {
|
|
160
|
+
this.watcher.close();
|
|
161
|
+
this.watcher = null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
processSignalFiles(onRelease) {
|
|
165
|
+
let files;
|
|
166
|
+
try {
|
|
167
|
+
files = fs.readdirSync(this.completedDir);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
for (const file of files) {
|
|
173
|
+
const eventId = file;
|
|
174
|
+
const filePath = path.join(this.completedDir, file);
|
|
175
|
+
const released = this.releaseById(eventId);
|
|
176
|
+
try {
|
|
177
|
+
fs.unlinkSync(filePath);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// File may already be deleted; ignore
|
|
181
|
+
}
|
|
182
|
+
if (released) {
|
|
183
|
+
onRelease(eventId, released.usageId);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
106
187
|
load(maxConcurrency) {
|
|
107
188
|
try {
|
|
108
189
|
const data = fs.readFileSync(this.filePath, "utf-8");
|
package/dist/types.d.ts
CHANGED
|
@@ -88,3 +88,15 @@ export interface QueueState {
|
|
|
88
88
|
active: ActiveEvent[];
|
|
89
89
|
maxConcurrency: number;
|
|
90
90
|
}
|
|
91
|
+
export type EnqueueResult = {
|
|
92
|
+
action: "enqueued";
|
|
93
|
+
event: QueuedEvent;
|
|
94
|
+
} | {
|
|
95
|
+
action: "duplicate";
|
|
96
|
+
} | {
|
|
97
|
+
action: "bypass";
|
|
98
|
+
event: QueuedEvent;
|
|
99
|
+
} | {
|
|
100
|
+
action: "dequeue";
|
|
101
|
+
removedId: string;
|
|
102
|
+
};
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sentinel-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Sentinel MCP server — connects GitHub issues to Claude Code sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"sentinel-mcp": "dist/index.js"
|
|
8
|
+
"sentinel-mcp": "dist/index.js",
|
|
9
|
+
"sentinel-mcp-release": "bin/release-hook.sh"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
|
-
"dist"
|
|
12
|
+
"dist",
|
|
13
|
+
"bin"
|
|
12
14
|
],
|
|
13
15
|
"scripts": {
|
|
14
16
|
"build": "tsc",
|