sentinel-mcp 0.2.0 → 0.3.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/index.js +57 -58
- 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"
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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";
|
|
@@ -142,6 +143,22 @@ async function mcpToolCall(toolName, args) {
|
|
|
142
143
|
return rpcResponse.result;
|
|
143
144
|
}
|
|
144
145
|
// ---------------------------------------------------------------------------
|
|
146
|
+
// Auto-push — deliver next event from queue when capacity is available
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
function autoPush() {
|
|
149
|
+
while (eventQueue.hasCapacity() && eventQueue.queueLength() > 0) {
|
|
150
|
+
const active = eventQueue.activate();
|
|
151
|
+
if (!active)
|
|
152
|
+
break;
|
|
153
|
+
updateEventStatus(active.usageId, "processing");
|
|
154
|
+
if (active.notification) {
|
|
155
|
+
const content = `SENTINEL_EVENT_ID=${active.id}\n${active.notification.content}`;
|
|
156
|
+
pushNotification(content, active.notification.meta);
|
|
157
|
+
}
|
|
158
|
+
log(`Activated event ${active.id} (#${active.issueNumber} in ${active.repo})`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
145
162
|
// WebSocket client — receives events from relay, pushes as channel notifications
|
|
146
163
|
// ---------------------------------------------------------------------------
|
|
147
164
|
const wsClient = new WsClient({
|
|
@@ -150,21 +167,30 @@ const wsClient = new WsClient({
|
|
|
150
167
|
repo: REPO_NAME || undefined,
|
|
151
168
|
log,
|
|
152
169
|
onEvent: (id, payload, notification, usageId) => {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
170
|
+
const result = eventQueue.enqueue(id, usageId ?? null, payload, notification ?? null);
|
|
171
|
+
switch (result.action) {
|
|
172
|
+
case "duplicate":
|
|
173
|
+
log(`Duplicate event ${id}, skipping`);
|
|
174
|
+
break;
|
|
175
|
+
case "bypass":
|
|
176
|
+
// Comment/lifecycle for active issue — push directly, no slot consumed
|
|
177
|
+
updateEventStatus(usageId ?? null, "processing");
|
|
178
|
+
if (result.event.notification) {
|
|
179
|
+
pushNotification(`SENTINEL_EVENT_ID=${result.event.id}\n${result.event.notification.content}`, result.event.notification.meta);
|
|
180
|
+
}
|
|
181
|
+
log(`Bypass event ${id} (follow-up for active issue #${result.event.issueNumber})`);
|
|
182
|
+
break;
|
|
183
|
+
case "dequeue":
|
|
184
|
+
log(`Dequeued event ${result.removedId} — issue resolved before processing`);
|
|
185
|
+
break;
|
|
186
|
+
case "enqueued":
|
|
187
|
+
updateEventStatus(usageId ?? null, "pending");
|
|
188
|
+
log(`Queued event ${id} (${payload.type} #${payload.type === "pr_merged" ? payload.issue_number : payload.issue?.number}) — queue: ${eventQueue.queueLength()}`);
|
|
189
|
+
autoPush();
|
|
190
|
+
break;
|
|
160
191
|
}
|
|
161
|
-
// ACK immediately
|
|
192
|
+
// ACK immediately
|
|
162
193
|
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
194
|
},
|
|
169
195
|
onDisplaced: (reason) => {
|
|
170
196
|
pushNotification(`Sentinel disconnected: ${reason}`, { type: "displaced" });
|
|
@@ -259,51 +285,6 @@ server.registerTool("get_skill", {
|
|
|
259
285
|
const result = await mcpToolCall("get_skill", { name });
|
|
260
286
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
261
287
|
});
|
|
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
288
|
// ---------------------------------------------------------------------------
|
|
308
289
|
// Startup
|
|
309
290
|
// ---------------------------------------------------------------------------
|
|
@@ -313,6 +294,23 @@ async function main() {
|
|
|
313
294
|
await refreshGithubToken();
|
|
314
295
|
tokenRefreshTimer = setInterval(refreshGithubToken, 50 * 60 * 1000);
|
|
315
296
|
wsClient.connect();
|
|
297
|
+
// Start file watcher for deterministic release
|
|
298
|
+
const completedDir = path.join(os.homedir(), ".sentinel", "completed");
|
|
299
|
+
eventQueue.startFileWatcher(completedDir, (eventId, usageId) => {
|
|
300
|
+
log(`Released event ${eventId} via hook signal`);
|
|
301
|
+
updateEventStatus(usageId, "delivered");
|
|
302
|
+
autoPush();
|
|
303
|
+
});
|
|
304
|
+
// Start timeout checker (every 60s, 30-min timeout)
|
|
305
|
+
setInterval(() => {
|
|
306
|
+
const released = eventQueue.checkTimeouts(30 * 60 * 1000);
|
|
307
|
+
for (const event of released) {
|
|
308
|
+
log(`Timeout: auto-released stale event ${event.id}`);
|
|
309
|
+
updateEventStatus(event.usageId, "delivered");
|
|
310
|
+
}
|
|
311
|
+
if (released.length > 0)
|
|
312
|
+
autoPush();
|
|
313
|
+
}, 60 * 1000);
|
|
316
314
|
log("Sentinel MCP bridge running");
|
|
317
315
|
}
|
|
318
316
|
main().catch((err) => {
|
|
@@ -326,6 +324,7 @@ function shutdown() {
|
|
|
326
324
|
log("Shutting down...");
|
|
327
325
|
if (tokenRefreshTimer)
|
|
328
326
|
clearInterval(tokenRefreshTimer);
|
|
327
|
+
eventQueue.stopFileWatcher();
|
|
329
328
|
wsClient.disconnect();
|
|
330
329
|
server.close().catch(() => { });
|
|
331
330
|
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.3.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",
|