sentinel-mcp 0.1.2 → 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.
@@ -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,11 +1,13 @@
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";
10
+ import { EventQueue } from "./queue.js";
9
11
  // ---------------------------------------------------------------------------
10
12
  // Config from environment
11
13
  // ---------------------------------------------------------------------------
@@ -36,6 +38,8 @@ try {
36
38
  catch {
37
39
  log("WARNING: Could not resolve repo name from git remote — events will not be filtered by repo");
38
40
  }
41
+ const MAX_CONCURRENCY = parseInt(process.env.SENTINEL_MAX_CONCURRENCY ?? "3", 10);
42
+ const eventQueue = new EventQueue({ maxConcurrency: MAX_CONCURRENCY, recoverActive: true });
39
43
  // ---------------------------------------------------------------------------
40
44
  // MCP server setup
41
45
  // ---------------------------------------------------------------------------
@@ -59,6 +63,21 @@ function pushNotification(content, meta) {
59
63
  log(`Failed to push notification: ${err.message}`);
60
64
  });
61
65
  }
66
+ function updateEventStatus(usageId, status) {
67
+ if (!usageId)
68
+ return;
69
+ const httpUrl = RELAY_URL.replace("wss://", "https://").replace("ws://", "http://");
70
+ fetch(`${httpUrl}/api/dashboard/events/${usageId}/status`, {
71
+ method: "PUT",
72
+ headers: {
73
+ "Content-Type": "application/json",
74
+ Authorization: `Bearer ${SENTINEL_TOKEN}`,
75
+ },
76
+ body: JSON.stringify({ status }),
77
+ }).catch((err) => {
78
+ log(`Failed to update event status: ${err.message}`);
79
+ });
80
+ }
62
81
  // ---------------------------------------------------------------------------
63
82
  // GitHub token management
64
83
  // ---------------------------------------------------------------------------
@@ -124,6 +143,22 @@ async function mcpToolCall(toolName, args) {
124
143
  return rpcResponse.result;
125
144
  }
126
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
+ // ---------------------------------------------------------------------------
127
162
  // WebSocket client — receives events from relay, pushes as channel notifications
128
163
  // ---------------------------------------------------------------------------
129
164
  const wsClient = new WsClient({
@@ -131,15 +166,30 @@ const wsClient = new WsClient({
131
166
  relayUrl: RELAY_URL,
132
167
  repo: REPO_NAME || undefined,
133
168
  log,
134
- onEvent: (id, payload, notification) => {
135
- // Use pre-built notification from relay if available, otherwise build a simple one
136
- if (notification) {
137
- pushNotification(notification.content, notification.meta);
138
- }
139
- else {
140
- const issueNum = payload.type === "pr_merged" ? payload.issue_number : payload.issue?.number;
141
- pushNotification(`${payload.type} event for #${issueNum} in ${payload.repo}`, { type: payload.type, repo: payload.repo, issue: String(issueNum) });
169
+ onEvent: (id, payload, notification, usageId) => {
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;
142
191
  }
192
+ // ACK immediately
143
193
  wsClient.ack(id);
144
194
  },
145
195
  onDisplaced: (reason) => {
@@ -244,6 +294,23 @@ async function main() {
244
294
  await refreshGithubToken();
245
295
  tokenRefreshTimer = setInterval(refreshGithubToken, 50 * 60 * 1000);
246
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);
247
314
  log("Sentinel MCP bridge running");
248
315
  }
249
316
  main().catch((err) => {
@@ -257,6 +324,7 @@ function shutdown() {
257
324
  log("Shutting down...");
258
325
  if (tokenRefreshTimer)
259
326
  clearInterval(tokenRefreshTimer);
327
+ eventQueue.stopFileWatcher();
260
328
  wsClient.disconnect();
261
329
  server.close().catch(() => { });
262
330
  process.exit(0);
@@ -0,0 +1,47 @@
1
+ import type { ActiveEvent, SentinelEvent, EnqueueResult } from "./types.js";
2
+ export interface EventQueueOptions {
3
+ queueFilePath?: string;
4
+ maxConcurrency?: number;
5
+ recoverActive?: boolean;
6
+ }
7
+ export declare class EventQueue {
8
+ private state;
9
+ private filePath;
10
+ private watcher;
11
+ private completedDir;
12
+ constructor(opts?: EventQueueOptions);
13
+ enqueue(id: string, usageId: string | null, payload: SentinelEvent, notification: {
14
+ content: string;
15
+ meta: Record<string, string>;
16
+ } | null): EnqueueResult;
17
+ activate(): ActiveEvent | null;
18
+ releaseById(id: string): ActiveEvent | null;
19
+ hasCapacity(): boolean;
20
+ queueLength(): number;
21
+ status(): {
22
+ queueLength: number;
23
+ activeCount: number;
24
+ maxConcurrency: number;
25
+ queue: Array<{
26
+ id: string;
27
+ eventType: string;
28
+ repo: string;
29
+ issueNumber: number;
30
+ priority: string;
31
+ enqueuedAt: string;
32
+ }>;
33
+ active: Array<{
34
+ id: string;
35
+ repo: string;
36
+ issueNumber: number;
37
+ startedAt: string;
38
+ }>;
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;
45
+ private load;
46
+ private persist;
47
+ }
package/dist/queue.js ADDED
@@ -0,0 +1,204 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ export class EventQueue {
5
+ state;
6
+ filePath;
7
+ watcher = null;
8
+ completedDir = "";
9
+ constructor(opts = {}) {
10
+ this.filePath = opts.queueFilePath ?? path.join(os.homedir(), ".sentinel", "queue.json");
11
+ const maxConcurrency = opts.maxConcurrency ?? 3;
12
+ this.state = this.load(maxConcurrency);
13
+ if (opts.recoverActive && this.state.active.length > 0) {
14
+ for (const active of this.state.active) {
15
+ this.state.queue.unshift({
16
+ id: active.id,
17
+ usageId: active.usageId,
18
+ priority: "high",
19
+ eventType: active.payload.type,
20
+ repo: active.repo,
21
+ issueNumber: active.issueNumber,
22
+ notification: active.notification,
23
+ payload: active.payload,
24
+ enqueuedAt: new Date().toISOString(),
25
+ });
26
+ }
27
+ this.state.active = [];
28
+ this.persist();
29
+ }
30
+ }
31
+ enqueue(id, usageId, payload, notification) {
32
+ // Deduplicate
33
+ if (this.state.queue.some((e) => e.id === id) || this.state.active.some((e) => e.id === id)) {
34
+ return { action: "duplicate" };
35
+ }
36
+ const issueNumber = payload.type === "pr_merged" ? payload.issue_number : payload.issue.number;
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);
39
+ const entry = {
40
+ id,
41
+ usageId,
42
+ priority: "normal",
43
+ eventType: payload.type,
44
+ repo: payload.repo,
45
+ issueNumber,
46
+ notification,
47
+ payload,
48
+ enqueuedAt: new Date().toISOString(),
49
+ };
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";
65
+ const firstNormal = this.state.queue.findIndex((e) => e.priority === "normal");
66
+ if (firstNormal === -1) {
67
+ this.state.queue.push(entry);
68
+ }
69
+ else {
70
+ this.state.queue.splice(firstNormal, 0, entry);
71
+ }
72
+ this.persist();
73
+ return { action: "enqueued", event: entry };
74
+ }
75
+ // Normal priority: new issue
76
+ this.state.queue.push(entry);
77
+ this.persist();
78
+ return { action: "enqueued", event: entry };
79
+ }
80
+ activate() {
81
+ if (this.state.queue.length === 0)
82
+ return null;
83
+ if (!this.hasCapacity())
84
+ return null;
85
+ const event = this.state.queue.shift();
86
+ const active = {
87
+ id: event.id,
88
+ usageId: event.usageId,
89
+ issueNumber: event.issueNumber,
90
+ repo: event.repo,
91
+ notification: event.notification,
92
+ payload: event.payload,
93
+ startedAt: new Date().toISOString(),
94
+ };
95
+ this.state.active.push(active);
96
+ this.persist();
97
+ return active;
98
+ }
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
+ }
108
+ const idx = this.state.active.findIndex((e) => e.id === id);
109
+ if (idx === -1)
110
+ return null;
111
+ const released = this.state.active.splice(idx, 1)[0];
112
+ this.persist();
113
+ return released;
114
+ }
115
+ hasCapacity() {
116
+ return this.state.active.length < this.state.maxConcurrency;
117
+ }
118
+ queueLength() {
119
+ return this.state.queue.length;
120
+ }
121
+ status() {
122
+ return {
123
+ queueLength: this.state.queue.length,
124
+ activeCount: this.state.active.length,
125
+ maxConcurrency: this.state.maxConcurrency,
126
+ queue: this.state.queue.map((e) => ({
127
+ id: e.id, eventType: e.eventType, repo: e.repo,
128
+ issueNumber: e.issueNumber, priority: e.priority, enqueuedAt: e.enqueuedAt,
129
+ })),
130
+ active: this.state.active.map((e) => ({
131
+ id: e.id, repo: e.repo, issueNumber: e.issueNumber, startedAt: e.startedAt,
132
+ })),
133
+ };
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
+ }
187
+ load(maxConcurrency) {
188
+ try {
189
+ const data = fs.readFileSync(this.filePath, "utf-8");
190
+ const parsed = JSON.parse(data);
191
+ return { ...parsed, maxConcurrency: parsed.maxConcurrency ?? maxConcurrency };
192
+ }
193
+ catch {
194
+ return { queue: [], active: [], maxConcurrency };
195
+ }
196
+ }
197
+ persist() {
198
+ const dir = path.dirname(this.filePath);
199
+ fs.mkdirSync(dir, { recursive: true });
200
+ const tmpPath = this.filePath + ".tmp";
201
+ fs.writeFileSync(tmpPath, JSON.stringify(this.state, null, 2));
202
+ fs.renameSync(tmpPath, this.filePath);
203
+ }
204
+ }
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export interface IssueEvent {
2
- type: "issue_opened" | "issue_comment" | "issue_closed";
2
+ type: "issue_opened" | "issue_labeled" | "issue_comment" | "issue_closed";
3
3
  repo: string;
4
4
  issue: {
5
5
  number: number;
@@ -29,6 +29,7 @@ export type ServerMessage = {
29
29
  type: "event";
30
30
  id: string;
31
31
  payload: SentinelEvent;
32
+ usageId?: string;
32
33
  notification?: {
33
34
  content: string;
34
35
  meta: Record<string, string>;
@@ -56,3 +57,46 @@ export type ClientMessage = {
56
57
  issue: number;
57
58
  session_id: string;
58
59
  };
60
+ export interface QueuedEvent {
61
+ id: string;
62
+ usageId: string | null;
63
+ priority: "high" | "normal";
64
+ eventType: string;
65
+ repo: string;
66
+ issueNumber: number;
67
+ notification: {
68
+ content: string;
69
+ meta: Record<string, string>;
70
+ } | null;
71
+ payload: SentinelEvent;
72
+ enqueuedAt: string;
73
+ }
74
+ export interface ActiveEvent {
75
+ id: string;
76
+ usageId: string | null;
77
+ issueNumber: number;
78
+ repo: string;
79
+ notification: {
80
+ content: string;
81
+ meta: Record<string, string>;
82
+ } | null;
83
+ payload: SentinelEvent;
84
+ startedAt: string;
85
+ }
86
+ export interface QueueState {
87
+ queue: QueuedEvent[];
88
+ active: ActiveEvent[];
89
+ maxConcurrency: number;
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
+ };
@@ -20,7 +20,7 @@ export interface WsClientOptions {
20
20
  onEvent: (id: string, payload: any, notification?: {
21
21
  content: string;
22
22
  meta: Record<string, string>;
23
- }) => void;
23
+ }, usageId?: string) => void;
24
24
  onDisplaced: (reason: string) => void;
25
25
  onLimitReached?: (message: string) => void;
26
26
  log: (msg: string) => void;
package/dist/ws-client.js CHANGED
@@ -89,7 +89,7 @@ export class WsClient {
89
89
  return;
90
90
  }
91
91
  if (msg.type === "event") {
92
- this.opts.onEvent(msg.id, msg.payload, msg.notification);
92
+ this.opts.onEvent(msg.id, msg.payload, msg.notification, msg.usageId);
93
93
  }
94
94
  else if (msg.type === "ping") {
95
95
  this.send({ type: "pong" });
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "sentinel-mcp",
3
- "version": "0.1.2",
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",