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.
@@ -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 added = eventQueue.enqueue(id, usageId ?? null, payload, notification ?? null);
154
- if (!added) {
155
- log(`Duplicate event ${id}, skipping`);
156
- }
157
- else {
158
- updateEventStatus(usageId ?? null, "pending");
159
- log(`Queued event ${id} (${payload.type} #${payload.type === "pr_merged" ? payload.issue_number : payload.issue?.number}) — queue: ${eventQueue.queueLength()}`);
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 (relay considers it delivered to MCP client)
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 { QueuedEvent, SentinelEvent } from "./types.js";
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): boolean;
15
- claim(): QueuedEvent | null;
16
- release(id: string): boolean;
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 false;
34
+ return { action: "duplicate" };
32
35
  }
33
36
  const issueNumber = payload.type === "pr_merged" ? payload.issue_number : payload.issue.number;
34
- const isFollowUp = this.state.active.some((a) => a.issueNumber === issueNumber && a.repo === payload.repo);
35
- const priority = isFollowUp ? "high" : "normal";
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
- if (priority === "high") {
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
- else {
57
- this.state.queue.push(entry);
58
- }
75
+ // Normal priority: new issue
76
+ this.state.queue.push(entry);
59
77
  this.persist();
60
- return true;
78
+ return { action: "enqueued", event: entry };
61
79
  }
62
- claim() {
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
- this.state.active.push({
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 event;
97
+ return active;
77
98
  }
78
- release(id) {
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 false;
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 true;
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.2.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",