sentinel-mcp 0.1.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/dist/image-server.d.ts +10 -0
- package/dist/image-server.js +98 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +236 -0
- package/dist/prompts.d.ts +19 -0
- package/dist/prompts.js +68 -0
- package/dist/session-manager.d.ts +30 -0
- package/dist/session-manager.js +221 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +2 -0
- package/dist/ws-client.d.ts +43 -0
- package/dist/ws-client.js +152 -0
- package/package.json +34 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
6
|
+
const ALLOWED_PATTERNS = [
|
|
7
|
+
/^https:\/\/github\.com\/user-attachments\/assets\//,
|
|
8
|
+
/^https:\/\/[a-z0-9-]+\.githubusercontent\.com\//,
|
|
9
|
+
];
|
|
10
|
+
export function isAllowedUrl(url) {
|
|
11
|
+
try {
|
|
12
|
+
const parsed = new URL(url);
|
|
13
|
+
if (parsed.protocol !== "https:")
|
|
14
|
+
return false;
|
|
15
|
+
return ALLOWED_PATTERNS.some((p) => p.test(url));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function fetchImage(url) {
|
|
22
|
+
if (!isAllowedUrl(url)) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
error: "URL not allowed. Only github.com/user-attachments/assets/* and *.githubusercontent.com/* URLs are permitted.",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
let res;
|
|
29
|
+
try {
|
|
30
|
+
res = await fetch(url);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
return { ok: false, error: `Failed to fetch image: ${err.message}` };
|
|
34
|
+
}
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
return { ok: false, error: `Failed to fetch image: HTTP ${res.status}` };
|
|
37
|
+
}
|
|
38
|
+
// Early size check from Content-Length header (avoid buffering large responses)
|
|
39
|
+
const contentLength = res.headers.get("content-length");
|
|
40
|
+
if (contentLength && parseInt(contentLength) > MAX_IMAGE_SIZE) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
error: `Image too large (${(parseInt(contentLength) / 1024 / 1024).toFixed(1)}MB). Max allowed: 10MB.`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const contentType = res.headers.get("content-type") ?? "image/png";
|
|
47
|
+
if (!contentType.startsWith("image/")) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: `Response is not an image (content-type: ${contentType})`,
|
|
51
|
+
};
|
|
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). Max allowed: 10MB.`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const base64 = Buffer.from(buffer).toString("base64");
|
|
61
|
+
const mimeType = contentType.split(";")[0].trim();
|
|
62
|
+
return { ok: true, data: base64, mimeType };
|
|
63
|
+
}
|
|
64
|
+
const server = new McpServer({
|
|
65
|
+
name: "sentinel-image",
|
|
66
|
+
version: "0.1.0",
|
|
67
|
+
});
|
|
68
|
+
server.registerTool("fetch_image", {
|
|
69
|
+
description: "Fetch an image from a GitHub issue. Pass the image URL from markdown  syntax.",
|
|
70
|
+
inputSchema: { url: z.string().url() },
|
|
71
|
+
}, async ({ url }) => {
|
|
72
|
+
const result = await fetchImage(url);
|
|
73
|
+
if (!result.ok) {
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: "text", text: result.error }],
|
|
76
|
+
isError: true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
content: [
|
|
81
|
+
{
|
|
82
|
+
type: "image",
|
|
83
|
+
data: result.data,
|
|
84
|
+
mimeType: result.mimeType,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
// Only start the server when run as a script, not when imported for testing
|
|
90
|
+
const isMain = process.argv[1] &&
|
|
91
|
+
import.meta.url === new URL(process.argv[1], "file://").href;
|
|
92
|
+
if (isMain) {
|
|
93
|
+
const transport = new StdioServerTransport();
|
|
94
|
+
server.connect(transport).catch((err) => {
|
|
95
|
+
console.error(`Fatal: ${err.message}`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
});
|
|
98
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { WsClient, decodeInstallationId } from "./ws-client.js";
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Config from environment
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const SENTINEL_TOKEN = process.env.SENTINEL_TOKEN;
|
|
13
|
+
if (!SENTINEL_TOKEN) {
|
|
14
|
+
console.error("FATAL: SENTINEL_TOKEN environment variable is required");
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
const RELAY_URL = process.env.SENTINEL_RELAY_URL ?? "wss://api.usesentinel.tools";
|
|
18
|
+
const REPO_PATH = process.env.SENTINEL_REPO_PATH ?? process.cwd();
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Logger — must use stderr (stdout is reserved for MCP stdio transport)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
function log(msg) {
|
|
23
|
+
console.error(`[sentinel] ${msg}`);
|
|
24
|
+
}
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Resolve repo name (owner/repo) from git remote
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
let REPO_NAME = "";
|
|
29
|
+
try {
|
|
30
|
+
const remoteUrl = execSync("git remote get-url origin", { cwd: REPO_PATH, encoding: "utf-8" }).trim();
|
|
31
|
+
const match = remoteUrl.match(/github\.com[:/]([^/]+\/[^/.]+)/);
|
|
32
|
+
if (match) {
|
|
33
|
+
REPO_NAME = match[1];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
log("WARNING: Could not resolve repo name from git remote — events will not be filtered by repo");
|
|
38
|
+
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// MCP server setup
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
const server = new McpServer({ name: "sentinel", version: "0.2.0" }, {
|
|
43
|
+
capabilities: {
|
|
44
|
+
experimental: { "claude/channel": {} },
|
|
45
|
+
},
|
|
46
|
+
instructions: "Events from this channel arrive as <channel source=\"sentinel\" ...>. They contain GitHub issue and PR events for repos you monitor. Act on them according to the instructions in the event content.",
|
|
47
|
+
});
|
|
48
|
+
const transport = new StdioServerTransport();
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Channel notifications — push events from relay → Claude Code via stdio
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
function pushNotification(content, meta) {
|
|
53
|
+
server.server
|
|
54
|
+
.notification({
|
|
55
|
+
method: "notifications/claude/channel",
|
|
56
|
+
params: { content, meta },
|
|
57
|
+
})
|
|
58
|
+
.catch((err) => {
|
|
59
|
+
log(`Failed to push notification: ${err.message}`);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// GitHub token management
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
let installationId;
|
|
66
|
+
try {
|
|
67
|
+
installationId = decodeInstallationId(SENTINEL_TOKEN);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
console.error(`FATAL: Cannot decode installation ID from token: ${err.message}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
let githubToken = "";
|
|
74
|
+
let tokenRefreshTimer = null;
|
|
75
|
+
async function refreshGithubToken() {
|
|
76
|
+
const httpUrl = RELAY_URL.replace("wss://", "https://").replace("ws://", "http://");
|
|
77
|
+
const tokenUrl = `${httpUrl}/api/token/${installationId}`;
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(tokenUrl, {
|
|
80
|
+
headers: { Authorization: `Bearer ${SENTINEL_TOKEN}` },
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
|
84
|
+
}
|
|
85
|
+
const data = (await res.json());
|
|
86
|
+
githubToken = data.token;
|
|
87
|
+
// Write token to ~/.sentinel/github-token for CLI tools
|
|
88
|
+
const dir = path.join(process.env.HOME ?? "~", ".sentinel");
|
|
89
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
90
|
+
fs.writeFileSync(path.join(dir, "github-token"), githubToken, { mode: 0o600 });
|
|
91
|
+
log("GitHub token refreshed");
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
log(`Failed to refresh GitHub token: ${err.message}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// API helper — proxy tool calls to remote worker API
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
async function apiCall(endpoint, body) {
|
|
101
|
+
const httpUrl = RELAY_URL.replace("wss://", "https://").replace("ws://", "http://");
|
|
102
|
+
const res = await fetch(`${httpUrl}${endpoint}`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: {
|
|
105
|
+
"Content-Type": "application/json",
|
|
106
|
+
Authorization: `Bearer ${SENTINEL_TOKEN}`,
|
|
107
|
+
},
|
|
108
|
+
body: JSON.stringify(body),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const text = await res.text();
|
|
112
|
+
throw new Error(`API error ${res.status}: ${text}`);
|
|
113
|
+
}
|
|
114
|
+
return res.json();
|
|
115
|
+
}
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// WebSocket client — receives events from relay, pushes as channel notifications
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
const wsClient = new WsClient({
|
|
120
|
+
token: SENTINEL_TOKEN,
|
|
121
|
+
relayUrl: RELAY_URL,
|
|
122
|
+
repo: REPO_NAME || undefined,
|
|
123
|
+
log,
|
|
124
|
+
onEvent: (id, payload, notification) => {
|
|
125
|
+
// Use pre-built notification from relay if available, otherwise build a simple one
|
|
126
|
+
if (notification) {
|
|
127
|
+
pushNotification(notification.content, notification.meta);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
const issueNum = payload.type === "pr_merged" ? payload.issue_number : payload.issue?.number;
|
|
131
|
+
pushNotification(`${payload.type} event for #${issueNum} in ${payload.repo}`, { type: payload.type, repo: payload.repo, issue: String(issueNum) });
|
|
132
|
+
}
|
|
133
|
+
wsClient.ack(id);
|
|
134
|
+
},
|
|
135
|
+
onDisplaced: (reason) => {
|
|
136
|
+
pushNotification(`Sentinel disconnected: ${reason}`, { type: "displaced" });
|
|
137
|
+
},
|
|
138
|
+
onLimitReached: (message) => {
|
|
139
|
+
pushNotification(message, { type: "limit_reached" });
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// MCP tools — proxy to remote API
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
server.registerTool("sentinel_status", {
|
|
146
|
+
description: "Get Sentinel connection status",
|
|
147
|
+
}, async () => {
|
|
148
|
+
return {
|
|
149
|
+
content: [{
|
|
150
|
+
type: "text",
|
|
151
|
+
text: JSON.stringify({
|
|
152
|
+
connected: wsClient.isConnected(),
|
|
153
|
+
relayUrl: RELAY_URL,
|
|
154
|
+
installationId,
|
|
155
|
+
repo: REPO_NAME,
|
|
156
|
+
}, null, 2),
|
|
157
|
+
}],
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
server.registerTool("post_issue_comment", {
|
|
161
|
+
description: "Post a comment on a GitHub issue",
|
|
162
|
+
inputSchema: {
|
|
163
|
+
repo: z.string().describe("Repository in owner/repo format"),
|
|
164
|
+
issue_number: z.number().describe("Issue number"),
|
|
165
|
+
body: z.string().describe("Comment body (markdown)"),
|
|
166
|
+
},
|
|
167
|
+
}, async ({ repo, issue_number, body }) => {
|
|
168
|
+
const result = await apiCall("/api/mcp/tools/post_issue_comment", { repo, issue_number, body });
|
|
169
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
170
|
+
});
|
|
171
|
+
server.registerTool("create_pull_request", {
|
|
172
|
+
description: "Create a pull request on GitHub",
|
|
173
|
+
inputSchema: {
|
|
174
|
+
repo: z.string().describe("Repository in owner/repo format"),
|
|
175
|
+
title: z.string().describe("PR title"),
|
|
176
|
+
body: z.string().describe("PR body (markdown)"),
|
|
177
|
+
head: z.string().describe("Branch name to merge from"),
|
|
178
|
+
base: z.string().describe("Branch name to merge into").default("main"),
|
|
179
|
+
},
|
|
180
|
+
}, async ({ repo, title, body, head, base }) => {
|
|
181
|
+
const result = await apiCall("/api/mcp/tools/create_pull_request", { repo, title, body, head, base });
|
|
182
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
183
|
+
});
|
|
184
|
+
server.registerTool("update_worktree", {
|
|
185
|
+
description: "Update worktree status for an issue (active, pr_open, cleaned_up)",
|
|
186
|
+
inputSchema: {
|
|
187
|
+
repo: z.string().describe("Repository in owner/repo format"),
|
|
188
|
+
issue_number: z.number().describe("Issue number"),
|
|
189
|
+
branch: z.string().describe("Branch name"),
|
|
190
|
+
worktree_path: z.string().describe("Local worktree path"),
|
|
191
|
+
status: z.enum(["active", "pr_open", "cleaned_up"]).describe("Worktree status"),
|
|
192
|
+
pr_number: z.number().optional().describe("PR number if status is pr_open"),
|
|
193
|
+
},
|
|
194
|
+
}, async ({ repo, issue_number, branch, worktree_path, status, pr_number }) => {
|
|
195
|
+
const result = await apiCall("/api/mcp/tools/update_worktree", {
|
|
196
|
+
repo, issue_number, branch, worktree_path, status, pr_number,
|
|
197
|
+
});
|
|
198
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
199
|
+
});
|
|
200
|
+
server.registerTool("get_skill", {
|
|
201
|
+
description: "Load a Sentinel skill's full instructions by name",
|
|
202
|
+
inputSchema: {
|
|
203
|
+
name: z.string().describe("Skill name"),
|
|
204
|
+
},
|
|
205
|
+
}, async ({ name }) => {
|
|
206
|
+
const result = await apiCall("/api/mcp/tools/get_skill", { name });
|
|
207
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
208
|
+
});
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Startup
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
async function main() {
|
|
213
|
+
await server.connect(transport);
|
|
214
|
+
log("MCP server connected via stdio");
|
|
215
|
+
await refreshGithubToken();
|
|
216
|
+
tokenRefreshTimer = setInterval(refreshGithubToken, 50 * 60 * 1000);
|
|
217
|
+
wsClient.connect();
|
|
218
|
+
log("Sentinel MCP bridge running");
|
|
219
|
+
}
|
|
220
|
+
main().catch((err) => {
|
|
221
|
+
log(`Fatal: ${err.message}`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
});
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Graceful shutdown
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
function shutdown() {
|
|
228
|
+
log("Shutting down...");
|
|
229
|
+
if (tokenRefreshTimer)
|
|
230
|
+
clearInterval(tokenRefreshTimer);
|
|
231
|
+
wsClient.disconnect();
|
|
232
|
+
server.close().catch(() => { });
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
process.on("SIGINT", shutdown);
|
|
236
|
+
process.on("SIGTERM", shutdown);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SentinelEvent } from "./types.js";
|
|
2
|
+
export interface PromptSettings {
|
|
3
|
+
behavior: "pr" | "push";
|
|
4
|
+
autoClose: boolean;
|
|
5
|
+
branchPattern: string;
|
|
6
|
+
claudeMdTemplate?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface IssuePromptInput {
|
|
9
|
+
repo: string;
|
|
10
|
+
issue: SentinelEvent["issue"];
|
|
11
|
+
settings: PromptSettings;
|
|
12
|
+
}
|
|
13
|
+
export interface CommentPromptInput {
|
|
14
|
+
repo: string;
|
|
15
|
+
issue: SentinelEvent["issue"];
|
|
16
|
+
comment: NonNullable<SentinelEvent["comment"]>;
|
|
17
|
+
}
|
|
18
|
+
export declare function buildIssuePrompt(input: IssuePromptInput): string;
|
|
19
|
+
export declare function buildCommentPrompt(input: CommentPromptInput): string;
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export function buildIssuePrompt(input) {
|
|
2
|
+
const { repo, issue, settings } = input;
|
|
3
|
+
const { behavior, autoClose, branchPattern, claudeMdTemplate } = settings;
|
|
4
|
+
const branchName = branchPattern.replace("{number}", String(issue.number));
|
|
5
|
+
const labelsText = issue.labels.length > 0 ? issue.labels.join(", ") : "(none)";
|
|
6
|
+
const deliveryInstructions = behavior === "pr"
|
|
7
|
+
? `- Create a new branch named \`${branchName}\` from the default branch.
|
|
8
|
+
- Commit your changes to that branch.
|
|
9
|
+
- Open a pull request using \`gh pr create\` targeting the default branch with a clear title and description linking to issue #${issue.number}.`
|
|
10
|
+
: `- Commit your changes and push directly to the default branch (do not open a pull request).`;
|
|
11
|
+
const closeInstructions = autoClose
|
|
12
|
+
? `- After the fix is merged/pushed, close the issue with \`gh issue close ${issue.number}\`.`
|
|
13
|
+
: `- After the fix is merged/pushed, post a brief summary comment on the issue explaining what was changed. Do NOT close the issue.`;
|
|
14
|
+
const claudeMdSection = claudeMdTemplate
|
|
15
|
+
? `\n## Project Guidelines (CLAUDE.md)\n\n${claudeMdTemplate}\n`
|
|
16
|
+
: "";
|
|
17
|
+
const toolsSection = `## Available Tools
|
|
18
|
+
|
|
19
|
+
- **fetch_image**: Before beginning your investigation, check if the issue body or comments contain any image markdown (\`\`). If so, call \`fetch_image\` for each image URL to view screenshots or visuals that help you understand the bug report.
|
|
20
|
+
|
|
21
|
+
`;
|
|
22
|
+
return `You are Sentinel, an autonomous bug-fixing agent. Your job is to investigate and fix the GitHub issue described below, then deliver the fix according to the configured workflow.
|
|
23
|
+
${claudeMdSection}
|
|
24
|
+
## Issue Details
|
|
25
|
+
|
|
26
|
+
- **Repository:** ${repo}
|
|
27
|
+
- **Issue:** #${issue.number} — ${issue.title}
|
|
28
|
+
- **Author:** @${issue.author}
|
|
29
|
+
- **Labels:** ${labelsText}
|
|
30
|
+
- **URL:** ${issue.url}
|
|
31
|
+
|
|
32
|
+
### Issue Body
|
|
33
|
+
|
|
34
|
+
${issue.body}
|
|
35
|
+
|
|
36
|
+
## Workflow Instructions
|
|
37
|
+
|
|
38
|
+
${deliveryInstructions}
|
|
39
|
+
${closeInstructions}
|
|
40
|
+
|
|
41
|
+
${toolsSection}
|
|
42
|
+
## General Rules
|
|
43
|
+
|
|
44
|
+
- If you need clarification before proceeding, ask ONE focused question by posting a comment with \`gh issue comment ${issue.number} --body "<your question>"\`. Never post more than one comment per turn.
|
|
45
|
+
- Do not ask for clarification if you already have enough information to begin.
|
|
46
|
+
- Always work inside the repository at \`${repo}\`.
|
|
47
|
+
- Write clean, minimal changes that address only the reported issue.
|
|
48
|
+
|
|
49
|
+
Begin by exploring the codebase to understand the problem, then implement the fix.`;
|
|
50
|
+
}
|
|
51
|
+
export function buildCommentPrompt(input) {
|
|
52
|
+
const { repo, issue, comment } = input;
|
|
53
|
+
return `You are Sentinel, an autonomous bug-fixing agent. You are continuing work on issue #${issue.number} in repository ${repo}.
|
|
54
|
+
|
|
55
|
+
## Human Reply
|
|
56
|
+
|
|
57
|
+
**@${comment.author}** commented:
|
|
58
|
+
|
|
59
|
+
${comment.body}
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Available Tools
|
|
64
|
+
|
|
65
|
+
- **fetch_image**: If the comment contains images (markdown \`\`), call \`fetch_image\` with the image URL to view them.
|
|
66
|
+
|
|
67
|
+
Review the comment above and continue fixing the issue. If the comment answers a previous question or provides new information, incorporate it into your approach and proceed with the fix. If you still need one more clarification, ask a single focused question using \`gh issue comment ${issue.number} --body "<your question>"\`. Never post more than one comment per turn.`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SentinelEvent } from "./types.js";
|
|
2
|
+
export interface SessionManagerOptions {
|
|
3
|
+
maxConcurrent: number;
|
|
4
|
+
repoPath: string;
|
|
5
|
+
githubToken: string;
|
|
6
|
+
log: (msg: string) => void;
|
|
7
|
+
onSessionUpdate?: (issue: number, sessionId: string) => void;
|
|
8
|
+
onSessionComplete?: (issue: number, success: boolean) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare class SessionManager {
|
|
11
|
+
private active;
|
|
12
|
+
private issueQueues;
|
|
13
|
+
private globalQueue;
|
|
14
|
+
private opts;
|
|
15
|
+
constructor(opts: SessionManagerOptions);
|
|
16
|
+
setGithubToken(token: string): void;
|
|
17
|
+
canSpawn(): boolean;
|
|
18
|
+
getActiveSessions(): Array<{
|
|
19
|
+
issue: number;
|
|
20
|
+
sessionId: string | null;
|
|
21
|
+
status: string;
|
|
22
|
+
}>;
|
|
23
|
+
getQueueLength(): number;
|
|
24
|
+
getIssueQueueLength(issue: number): number;
|
|
25
|
+
enqueue(event: SentinelEvent): void;
|
|
26
|
+
spawnSession(event: SentinelEvent): void;
|
|
27
|
+
killSession(issueNumber: number): void;
|
|
28
|
+
private drainGlobalQueue;
|
|
29
|
+
private postErrorComment;
|
|
30
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { buildIssuePrompt, buildCommentPrompt } from "./prompts.js";
|
|
7
|
+
export class SessionManager {
|
|
8
|
+
active = new Map();
|
|
9
|
+
issueQueues = new Map();
|
|
10
|
+
globalQueue = [];
|
|
11
|
+
opts;
|
|
12
|
+
constructor(opts) {
|
|
13
|
+
this.opts = opts;
|
|
14
|
+
}
|
|
15
|
+
setGithubToken(token) {
|
|
16
|
+
this.opts.githubToken = token;
|
|
17
|
+
}
|
|
18
|
+
canSpawn() {
|
|
19
|
+
return this.active.size < this.opts.maxConcurrent;
|
|
20
|
+
}
|
|
21
|
+
getActiveSessions() {
|
|
22
|
+
return Array.from(this.active.entries()).map(([issue, session]) => ({
|
|
23
|
+
issue,
|
|
24
|
+
sessionId: session.sessionId,
|
|
25
|
+
status: "running",
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
getQueueLength() {
|
|
29
|
+
return this.globalQueue.length;
|
|
30
|
+
}
|
|
31
|
+
getIssueQueueLength(issue) {
|
|
32
|
+
return this.issueQueues.get(issue)?.length ?? 0;
|
|
33
|
+
}
|
|
34
|
+
enqueue(event) {
|
|
35
|
+
const issueNumber = event.issue.number;
|
|
36
|
+
// Handle issue_closed: kill session + cleanup
|
|
37
|
+
if (event.type === "issue_closed") {
|
|
38
|
+
if (this.active.has(issueNumber)) {
|
|
39
|
+
this.killSession(issueNumber);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// If issue has an active session, queue per-issue
|
|
44
|
+
if (this.active.has(issueNumber)) {
|
|
45
|
+
const queue = this.issueQueues.get(issueNumber) ?? [];
|
|
46
|
+
queue.push(event);
|
|
47
|
+
this.issueQueues.set(issueNumber, queue);
|
|
48
|
+
this.opts.log(`Queued event per-issue for #${issueNumber} (queue size: ${queue.length})`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// If at capacity, queue globally
|
|
52
|
+
if (!this.canSpawn()) {
|
|
53
|
+
this.globalQueue.push(event);
|
|
54
|
+
this.opts.log(`Queued event globally for #${issueNumber} (global queue size: ${this.globalQueue.length})`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Otherwise spawn
|
|
58
|
+
this.spawnSession(event);
|
|
59
|
+
}
|
|
60
|
+
spawnSession(event) {
|
|
61
|
+
const issueNumber = event.issue.number;
|
|
62
|
+
const settings = {
|
|
63
|
+
behavior: "pr",
|
|
64
|
+
autoClose: true,
|
|
65
|
+
branchPattern: "sentinel/issue-{number}",
|
|
66
|
+
};
|
|
67
|
+
// Build prompt based on event type
|
|
68
|
+
let prompt;
|
|
69
|
+
if (event.type === "issue_comment" && event.comment) {
|
|
70
|
+
prompt = buildCommentPrompt({
|
|
71
|
+
repo: event.repo,
|
|
72
|
+
issue: event.issue,
|
|
73
|
+
comment: event.comment,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
prompt = buildIssuePrompt({
|
|
78
|
+
repo: event.repo,
|
|
79
|
+
issue: event.issue,
|
|
80
|
+
settings,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// Write temp MCP config for image server
|
|
84
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
85
|
+
const imageServerPath = path.join(__dirname, "image-server.js");
|
|
86
|
+
const mcpConfigPath = path.join(os.tmpdir(), `sentinel-mcp-${issueNumber}.json`);
|
|
87
|
+
fs.writeFileSync(mcpConfigPath, JSON.stringify({
|
|
88
|
+
mcpServers: {
|
|
89
|
+
"sentinel-image": {
|
|
90
|
+
command: "node",
|
|
91
|
+
args: [imageServerPath],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
}));
|
|
95
|
+
// Build spawn args
|
|
96
|
+
const args = [
|
|
97
|
+
"-p", prompt,
|
|
98
|
+
"--output-format", "json",
|
|
99
|
+
"--mcp-config", mcpConfigPath,
|
|
100
|
+
];
|
|
101
|
+
// Resume if we have a session ID from a previous run
|
|
102
|
+
const existing = this.active.get(issueNumber);
|
|
103
|
+
if (existing?.sessionId) {
|
|
104
|
+
args.push("--resume", existing.sessionId);
|
|
105
|
+
}
|
|
106
|
+
this.opts.log(`Spawning claude session for issue #${issueNumber}`);
|
|
107
|
+
const proc = spawn("claude", args, {
|
|
108
|
+
cwd: this.opts.repoPath,
|
|
109
|
+
env: {
|
|
110
|
+
...process.env,
|
|
111
|
+
GITHUB_TOKEN: this.opts.githubToken,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const session = {
|
|
115
|
+
process: proc,
|
|
116
|
+
sessionId: existing?.sessionId ?? null,
|
|
117
|
+
issueNumber,
|
|
118
|
+
stdout: "",
|
|
119
|
+
};
|
|
120
|
+
this.active.set(issueNumber, session);
|
|
121
|
+
// Collect stdout
|
|
122
|
+
proc.stdout?.on("data", (data) => {
|
|
123
|
+
session.stdout += data.toString();
|
|
124
|
+
});
|
|
125
|
+
proc.stderr?.on("data", (data) => {
|
|
126
|
+
this.opts.log(`[issue #${issueNumber} stderr] ${data.toString()}`);
|
|
127
|
+
});
|
|
128
|
+
proc.on("close", (code) => {
|
|
129
|
+
// Clean up temp MCP config
|
|
130
|
+
try {
|
|
131
|
+
fs.unlinkSync(mcpConfigPath);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// best-effort
|
|
135
|
+
}
|
|
136
|
+
const success = code === 0;
|
|
137
|
+
// Try to parse session_id from stdout
|
|
138
|
+
try {
|
|
139
|
+
const output = JSON.parse(session.stdout);
|
|
140
|
+
if (output.session_id) {
|
|
141
|
+
session.sessionId = output.session_id;
|
|
142
|
+
this.opts.onSessionUpdate?.(issueNumber, output.session_id);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// stdout may not be valid JSON, that's fine
|
|
147
|
+
}
|
|
148
|
+
// Remove from active
|
|
149
|
+
this.active.delete(issueNumber);
|
|
150
|
+
this.opts.log(`Session for issue #${issueNumber} closed (code=${code}, success=${success})`);
|
|
151
|
+
// Notify completion
|
|
152
|
+
this.opts.onSessionComplete?.(issueNumber, success);
|
|
153
|
+
// On failure, post error comment (best-effort)
|
|
154
|
+
if (!success) {
|
|
155
|
+
this.postErrorComment(issueNumber, event.repo);
|
|
156
|
+
}
|
|
157
|
+
// Process per-issue queue first (priority)
|
|
158
|
+
const issueQueue = this.issueQueues.get(issueNumber);
|
|
159
|
+
if (issueQueue && issueQueue.length > 0) {
|
|
160
|
+
const next = issueQueue.shift();
|
|
161
|
+
if (issueQueue.length === 0) {
|
|
162
|
+
this.issueQueues.delete(issueNumber);
|
|
163
|
+
}
|
|
164
|
+
this.spawnSession(next);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Then drain global queue
|
|
168
|
+
this.drainGlobalQueue();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
killSession(issueNumber) {
|
|
172
|
+
const session = this.active.get(issueNumber);
|
|
173
|
+
if (session) {
|
|
174
|
+
session.process.kill();
|
|
175
|
+
// Clean up temp MCP config (belt-and-suspenders — close handler also cleans up)
|
|
176
|
+
const configPath = path.join(os.tmpdir(), `sentinel-mcp-${issueNumber}.json`);
|
|
177
|
+
try {
|
|
178
|
+
fs.unlinkSync(configPath);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// best-effort
|
|
182
|
+
}
|
|
183
|
+
this.active.delete(issueNumber);
|
|
184
|
+
this.opts.log(`Killed session for issue #${issueNumber}`);
|
|
185
|
+
}
|
|
186
|
+
// Clear per-issue queue
|
|
187
|
+
this.issueQueues.delete(issueNumber);
|
|
188
|
+
// Drain global queue since a slot opened
|
|
189
|
+
this.drainGlobalQueue();
|
|
190
|
+
}
|
|
191
|
+
drainGlobalQueue() {
|
|
192
|
+
while (this.canSpawn() && this.globalQueue.length > 0) {
|
|
193
|
+
const next = this.globalQueue.shift();
|
|
194
|
+
this.spawnSession(next);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
postErrorComment(issueNumber, repo) {
|
|
198
|
+
try {
|
|
199
|
+
const proc = spawn("gh", [
|
|
200
|
+
"issue",
|
|
201
|
+
"comment",
|
|
202
|
+
String(issueNumber),
|
|
203
|
+
"--repo",
|
|
204
|
+
repo,
|
|
205
|
+
"--body",
|
|
206
|
+
`Sentinel encountered an error while processing this issue. A maintainer may need to investigate.`,
|
|
207
|
+
], {
|
|
208
|
+
env: {
|
|
209
|
+
...process.env,
|
|
210
|
+
GITHUB_TOKEN: this.opts.githubToken,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
proc.on("error", () => {
|
|
214
|
+
// best-effort, ignore
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// best-effort, ignore
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export interface IssueEvent {
|
|
2
|
+
type: "issue_opened" | "issue_comment" | "issue_closed";
|
|
3
|
+
repo: string;
|
|
4
|
+
issue: {
|
|
5
|
+
number: number;
|
|
6
|
+
title: string;
|
|
7
|
+
body: string;
|
|
8
|
+
author: string;
|
|
9
|
+
labels: string[];
|
|
10
|
+
url: string;
|
|
11
|
+
};
|
|
12
|
+
comment?: {
|
|
13
|
+
body: string;
|
|
14
|
+
author: string;
|
|
15
|
+
url: string;
|
|
16
|
+
};
|
|
17
|
+
timestamp: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PrMergedEvent {
|
|
20
|
+
type: "pr_merged";
|
|
21
|
+
repo: string;
|
|
22
|
+
pr_number: number;
|
|
23
|
+
branch: string;
|
|
24
|
+
issue_number: number;
|
|
25
|
+
timestamp: string;
|
|
26
|
+
}
|
|
27
|
+
export type SentinelEvent = IssueEvent | PrMergedEvent;
|
|
28
|
+
export type ServerMessage = {
|
|
29
|
+
type: "event";
|
|
30
|
+
id: string;
|
|
31
|
+
payload: SentinelEvent;
|
|
32
|
+
notification?: {
|
|
33
|
+
content: string;
|
|
34
|
+
meta: Record<string, string>;
|
|
35
|
+
};
|
|
36
|
+
} | {
|
|
37
|
+
type: "ping";
|
|
38
|
+
} | {
|
|
39
|
+
type: "displaced";
|
|
40
|
+
reason: string;
|
|
41
|
+
} | {
|
|
42
|
+
type: "limit_reached";
|
|
43
|
+
message: string;
|
|
44
|
+
};
|
|
45
|
+
export type ClientMessage = {
|
|
46
|
+
type: "auth";
|
|
47
|
+
token: string;
|
|
48
|
+
repo?: string;
|
|
49
|
+
} | {
|
|
50
|
+
type: "ack";
|
|
51
|
+
id: string;
|
|
52
|
+
} | {
|
|
53
|
+
type: "pong";
|
|
54
|
+
} | {
|
|
55
|
+
type: "session_update";
|
|
56
|
+
issue: number;
|
|
57
|
+
session_id: string;
|
|
58
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ClientMessage, ServerMessage } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Decode the installation ID from a JWT token.
|
|
4
|
+
* Extracts the `sub` claim from the JWT payload without verifying the signature.
|
|
5
|
+
*/
|
|
6
|
+
export declare function decodeInstallationId(jwt: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Decode the user ID from a JWT token.
|
|
9
|
+
* Extracts the `uid` claim from the JWT payload without verifying the signature.
|
|
10
|
+
*/
|
|
11
|
+
export declare function decodeUserId(jwt: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Parse a raw JSON string into a typed ServerMessage.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseServerMessage(raw: string): ServerMessage;
|
|
16
|
+
export interface WsClientOptions {
|
|
17
|
+
token: string;
|
|
18
|
+
relayUrl: string;
|
|
19
|
+
repo?: string;
|
|
20
|
+
onEvent: (id: string, payload: any, notification?: {
|
|
21
|
+
content: string;
|
|
22
|
+
meta: Record<string, string>;
|
|
23
|
+
}) => void;
|
|
24
|
+
onDisplaced: (reason: string) => void;
|
|
25
|
+
onLimitReached?: (message: string) => void;
|
|
26
|
+
log: (msg: string) => void;
|
|
27
|
+
}
|
|
28
|
+
export declare class WsClient {
|
|
29
|
+
private ws;
|
|
30
|
+
private reconnectDelay;
|
|
31
|
+
private maxReconnectDelay;
|
|
32
|
+
private shouldReconnect;
|
|
33
|
+
private userId;
|
|
34
|
+
private opts;
|
|
35
|
+
constructor(opts: WsClientOptions);
|
|
36
|
+
connect(): void;
|
|
37
|
+
send(msg: ClientMessage): void;
|
|
38
|
+
ack(eventId: string): void;
|
|
39
|
+
updateSession(issue: number, sessionId: string): void;
|
|
40
|
+
isConnected(): boolean;
|
|
41
|
+
disconnect(): void;
|
|
42
|
+
private scheduleReconnect;
|
|
43
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
/**
|
|
3
|
+
* Decode the installation ID from a JWT token.
|
|
4
|
+
* Extracts the `sub` claim from the JWT payload without verifying the signature.
|
|
5
|
+
*/
|
|
6
|
+
export function decodeInstallationId(jwt) {
|
|
7
|
+
const parts = jwt.split(".");
|
|
8
|
+
if (parts.length !== 3) {
|
|
9
|
+
throw new Error(`Invalid JWT format: expected 3 parts separated by '.', got ${parts.length}`);
|
|
10
|
+
}
|
|
11
|
+
const payloadPart = parts[1];
|
|
12
|
+
let parsed;
|
|
13
|
+
try {
|
|
14
|
+
const decoded = Buffer.from(payloadPart, "base64").toString("utf-8");
|
|
15
|
+
parsed = JSON.parse(decoded);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error("Invalid JWT: failed to decode or parse payload");
|
|
19
|
+
}
|
|
20
|
+
if (typeof parsed !== "object" ||
|
|
21
|
+
parsed === null ||
|
|
22
|
+
!("sub" in parsed) ||
|
|
23
|
+
typeof parsed.sub !== "string") {
|
|
24
|
+
throw new Error("Invalid JWT: missing or invalid 'sub' claim");
|
|
25
|
+
}
|
|
26
|
+
return parsed.sub;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Decode the user ID from a JWT token.
|
|
30
|
+
* Extracts the `uid` claim from the JWT payload without verifying the signature.
|
|
31
|
+
*/
|
|
32
|
+
export function decodeUserId(jwt) {
|
|
33
|
+
const parts = jwt.split(".");
|
|
34
|
+
if (parts.length !== 3) {
|
|
35
|
+
throw new Error(`Invalid JWT format: expected 3 parts separated by '.', got ${parts.length}`);
|
|
36
|
+
}
|
|
37
|
+
const payloadPart = parts[1];
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
const decoded = Buffer.from(payloadPart, "base64").toString("utf-8");
|
|
41
|
+
parsed = JSON.parse(decoded);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new Error("Invalid JWT: failed to decode or parse payload");
|
|
45
|
+
}
|
|
46
|
+
if (typeof parsed !== "object" ||
|
|
47
|
+
parsed === null ||
|
|
48
|
+
!("uid" in parsed) ||
|
|
49
|
+
typeof parsed.uid !== "string") {
|
|
50
|
+
throw new Error("Invalid JWT: missing or invalid 'uid' claim");
|
|
51
|
+
}
|
|
52
|
+
return parsed.uid;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Parse a raw JSON string into a typed ServerMessage.
|
|
56
|
+
*/
|
|
57
|
+
export function parseServerMessage(raw) {
|
|
58
|
+
return JSON.parse(raw);
|
|
59
|
+
}
|
|
60
|
+
export class WsClient {
|
|
61
|
+
ws = null;
|
|
62
|
+
reconnectDelay = 1000;
|
|
63
|
+
maxReconnectDelay = 60000;
|
|
64
|
+
shouldReconnect = true;
|
|
65
|
+
userId;
|
|
66
|
+
opts;
|
|
67
|
+
constructor(opts) {
|
|
68
|
+
this.opts = opts;
|
|
69
|
+
this.userId = decodeUserId(opts.token);
|
|
70
|
+
}
|
|
71
|
+
connect() {
|
|
72
|
+
const url = `${this.opts.relayUrl}/api/connect/${this.userId}`;
|
|
73
|
+
this.opts.log(`Connecting to ${url}`);
|
|
74
|
+
const ws = new WebSocket(url);
|
|
75
|
+
this.ws = ws;
|
|
76
|
+
ws.on("open", () => {
|
|
77
|
+
this.opts.log("WebSocket connected");
|
|
78
|
+
this.reconnectDelay = 1000;
|
|
79
|
+
this.send({ type: "auth", token: this.opts.token, repo: this.opts.repo });
|
|
80
|
+
});
|
|
81
|
+
ws.on("message", (data) => {
|
|
82
|
+
const raw = data.toString();
|
|
83
|
+
let msg;
|
|
84
|
+
try {
|
|
85
|
+
msg = parseServerMessage(raw);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
this.opts.log(`Failed to parse server message: ${raw}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (msg.type === "event") {
|
|
92
|
+
this.opts.onEvent(msg.id, msg.payload, msg.notification);
|
|
93
|
+
}
|
|
94
|
+
else if (msg.type === "ping") {
|
|
95
|
+
this.send({ type: "pong" });
|
|
96
|
+
}
|
|
97
|
+
else if (msg.type === "displaced") {
|
|
98
|
+
this.opts.log(`Displaced: ${msg.reason}`);
|
|
99
|
+
this.shouldReconnect = false;
|
|
100
|
+
this.opts.onDisplaced(msg.reason);
|
|
101
|
+
}
|
|
102
|
+
else if (msg.type === "limit_reached") {
|
|
103
|
+
this.opts.onLimitReached?.(msg.message);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
ws.on("close", (code, reason) => {
|
|
107
|
+
this.ws = null;
|
|
108
|
+
if (code === 4001) {
|
|
109
|
+
this.opts.log(`Connection closed with displacement code 4001: ${reason.toString()}`);
|
|
110
|
+
this.shouldReconnect = false;
|
|
111
|
+
this.opts.onDisplaced(reason.toString() || "Displaced by server (code 4001)");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.opts.log(`Connection closed (code ${code}). shouldReconnect=${this.shouldReconnect}`);
|
|
115
|
+
if (this.shouldReconnect) {
|
|
116
|
+
this.scheduleReconnect();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
ws.on("error", (err) => {
|
|
120
|
+
this.opts.log(`WebSocket error: ${err.message}`);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
send(msg) {
|
|
124
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
125
|
+
this.ws.send(JSON.stringify(msg));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
ack(eventId) {
|
|
129
|
+
this.send({ type: "ack", id: eventId });
|
|
130
|
+
}
|
|
131
|
+
updateSession(issue, sessionId) {
|
|
132
|
+
this.send({ type: "session_update", issue, session_id: sessionId });
|
|
133
|
+
}
|
|
134
|
+
isConnected() {
|
|
135
|
+
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
|
136
|
+
}
|
|
137
|
+
disconnect() {
|
|
138
|
+
this.shouldReconnect = false;
|
|
139
|
+
if (this.ws) {
|
|
140
|
+
this.ws.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
scheduleReconnect() {
|
|
144
|
+
this.opts.log(`Reconnecting in ${this.reconnectDelay}ms...`);
|
|
145
|
+
setTimeout(() => {
|
|
146
|
+
if (this.shouldReconnect) {
|
|
147
|
+
this.connect();
|
|
148
|
+
}
|
|
149
|
+
}, this.reconnectDelay);
|
|
150
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
|
151
|
+
}
|
|
152
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sentinel-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sentinel MCP server — connects GitHub issues to Claude Code sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"sentinel-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"dev": "tsx src/index.ts",
|
|
16
|
+
"prepublishOnly": "npm run build",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"test:watch": "vitest"
|
|
19
|
+
},
|
|
20
|
+
"keywords": ["mcp", "sentinel", "claude", "github", "automation"],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
24
|
+
"ws": "^8.18.0",
|
|
25
|
+
"zod": "^4.3.6"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.13.0",
|
|
29
|
+
"@types/ws": "^8.5.0",
|
|
30
|
+
"tsx": "^4.19.0",
|
|
31
|
+
"typescript": "^5.7.0",
|
|
32
|
+
"vitest": "^3.1.0"
|
|
33
|
+
}
|
|
34
|
+
}
|