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.
@@ -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(/&amp;/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(/&amp;/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 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()}`);
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 (relay considers it delivered to MCP client)
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 mcpToolCall("fetch_image", { url, repo, issue_number });
239
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
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 { 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.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",