plan-annotator 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/README.md +203 -0
- package/bin/plan-annotator.mjs +18 -0
- package/dist/assets/css-DPfMkruS.js +1 -0
- package/dist/assets/diff-D97Zzqfu.js +1 -0
- package/dist/assets/github-dark-DHJKELXO.js +1 -0
- package/dist/assets/github-light-DAi9KRSo.js +1 -0
- package/dist/assets/go-CxLEBnE3.js +1 -0
- package/dist/assets/html-GMplVEZG.js +1 -0
- package/dist/assets/index-BpByFhhl.css +1 -0
- package/dist/assets/index-QhnyzoVj.js +276 -0
- package/dist/assets/javascript-wDzz0qaB.js +1 -0
- package/dist/assets/json-Cp-IABpG.js +1 -0
- package/dist/assets/jsx-g9-lgVsj.js +1 -0
- package/dist/assets/markdown-Cvjx9yec.js +1 -0
- package/dist/assets/python-B6aJPvgy.js +1 -0
- package/dist/assets/rust-B1yitclQ.js +1 -0
- package/dist/assets/shellscript-CEILq0vU.js +1 -0
- package/dist/assets/sql-BLtJtn59.js +1 -0
- package/dist/assets/toml-vGWfd6FD.js +1 -0
- package/dist/assets/tsx-COt5Ahok.js +1 -0
- package/dist/assets/typescript-BPQ3VLAy.js +1 -0
- package/dist/assets/yaml-Buea-lGh.js +1 -0
- package/dist/index.html +17 -0
- package/dist/vite.svg +1 -0
- package/package.json +57 -0
- package/server/index.ts +388 -0
package/dist/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Plan Annotator</title>
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
10
|
+
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=JetBrains+Mono:wght@400;500&family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&display=swap" rel="stylesheet" />
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-QhnyzoVj.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BpByFhhl.css">
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<div id="root"></div>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
package/dist/vite.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "plan-annotator",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Visual plan review UI for Claude Code — annotate AI-generated plans in the browser",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "kangju2000",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kangju2000/plan-annotator.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"claude-code",
|
|
14
|
+
"plan-review",
|
|
15
|
+
"annotation",
|
|
16
|
+
"ai",
|
|
17
|
+
"hook"
|
|
18
|
+
],
|
|
19
|
+
"bin": {
|
|
20
|
+
"plan-annotator": "bin/plan-annotator.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin/",
|
|
24
|
+
"dist/",
|
|
25
|
+
"server/"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"dev": "vite",
|
|
29
|
+
"build": "vite build",
|
|
30
|
+
"lint": "eslint .",
|
|
31
|
+
"preview": "vite preview",
|
|
32
|
+
"server": "tsx server/index.ts",
|
|
33
|
+
"prepublishOnly": "npm run build"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"tsx": "^4.19.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@eslint/js": "^9.39.1",
|
|
40
|
+
"@tailwindcss/vite": "^4.2.1",
|
|
41
|
+
"@types/node": "^24.10.1",
|
|
42
|
+
"@types/react": "^19.2.7",
|
|
43
|
+
"@types/react-dom": "^19.2.3",
|
|
44
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
45
|
+
"eslint": "^9.39.1",
|
|
46
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
47
|
+
"eslint-plugin-react-refresh": "^0.4.24",
|
|
48
|
+
"globals": "^16.5.0",
|
|
49
|
+
"react": "^19.2.0",
|
|
50
|
+
"react-dom": "^19.2.0",
|
|
51
|
+
"shiki": "^4.0.1",
|
|
52
|
+
"tailwindcss": "^4.2.1",
|
|
53
|
+
"typescript": "~5.9.3",
|
|
54
|
+
"typescript-eslint": "^8.48.0",
|
|
55
|
+
"vite": "^7.3.1"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/server/index.ts
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
// ── Constants ──────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
9
|
+
const HEARTBEAT_INTERVAL_MS = 3000;
|
|
10
|
+
const HEARTBEAT_MISS_LIMIT = 3; // 9 seconds without heartbeat → assume tab closed
|
|
11
|
+
const DIST_DIR = path.resolve(import.meta.dirname, '..', 'dist');
|
|
12
|
+
const FIXED_PORT = process.env.PLAN_ANNOTATOR_PORT
|
|
13
|
+
? parseInt(process.env.PLAN_ANNOTATOR_PORT, 10)
|
|
14
|
+
: 0; // 0 = random
|
|
15
|
+
|
|
16
|
+
const MIME_TYPES: Record<string, string> = {
|
|
17
|
+
'.html': 'text/html; charset=utf-8',
|
|
18
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
19
|
+
'.css': 'text/css; charset=utf-8',
|
|
20
|
+
'.json': 'application/json; charset=utf-8',
|
|
21
|
+
'.svg': 'image/svg+xml',
|
|
22
|
+
'.png': 'image/png',
|
|
23
|
+
'.ico': 'image/x-icon',
|
|
24
|
+
'.woff': 'font/woff',
|
|
25
|
+
'.woff2': 'font/woff2',
|
|
26
|
+
'.map': 'application/json',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ── Logging (stderr only — stdout reserved for hook response) ──────────
|
|
30
|
+
|
|
31
|
+
function log(...args: unknown[]) {
|
|
32
|
+
process.stderr.write(`[plan-annotator] ${args.join(' ')}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Read stdin (hook event JSON) ───────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function readStdin(): Promise<string> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const chunks: Buffer[] = [];
|
|
40
|
+
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
41
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
42
|
+
process.stdin.on('error', reject);
|
|
43
|
+
|
|
44
|
+
// If stdin is a TTY (manual testing), resolve after a short delay
|
|
45
|
+
if (process.stdin.isTTY) {
|
|
46
|
+
log('stdin is TTY — using sample plan for testing');
|
|
47
|
+
resolve('');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface HookEvent {
|
|
53
|
+
hook_event_name: string;
|
|
54
|
+
tool_name: string;
|
|
55
|
+
tool_input: {
|
|
56
|
+
description?: string;
|
|
57
|
+
command?: string;
|
|
58
|
+
[key: string]: unknown;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractPlanMarkdown(raw: string): string {
|
|
63
|
+
if (!raw.trim()) {
|
|
64
|
+
// Fallback for manual testing
|
|
65
|
+
return '# Sample Plan\n\nThis is a test plan for local development.\n\n## Step 1\n\nDo something.\n\n## Step 2\n\nDo something else.';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const event: HookEvent = JSON.parse(raw);
|
|
70
|
+
// Extract plan from tool_input.description (Task tool)
|
|
71
|
+
// or tool_input.command or stringify the whole input
|
|
72
|
+
const plan =
|
|
73
|
+
event.tool_input?.description ??
|
|
74
|
+
event.tool_input?.command ??
|
|
75
|
+
JSON.stringify(event.tool_input, null, 2);
|
|
76
|
+
return plan;
|
|
77
|
+
} catch {
|
|
78
|
+
// If it's not JSON, treat the whole input as markdown
|
|
79
|
+
return raw;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── CORS headers ───────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
function setCorsHeaders(res: http.ServerResponse) {
|
|
86
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
87
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
88
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Static file serving ────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
function serveStaticFile(res: http.ServerResponse, urlPath: string): boolean {
|
|
94
|
+
// Normalize: / → /index.html
|
|
95
|
+
let filePath = urlPath === '/' ? '/index.html' : urlPath;
|
|
96
|
+
|
|
97
|
+
// Security: prevent directory traversal
|
|
98
|
+
const resolved = path.resolve(DIST_DIR, '.' + filePath);
|
|
99
|
+
if (!resolved.startsWith(DIST_DIR)) {
|
|
100
|
+
res.writeHead(403);
|
|
101
|
+
res.end('Forbidden');
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!fs.existsSync(resolved)) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ext = path.extname(resolved);
|
|
110
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
111
|
+
|
|
112
|
+
const content = fs.readFileSync(resolved);
|
|
113
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
114
|
+
res.end(content);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Read POST body ─────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
function readBody(req: http.IncomingMessage, maxBytes = 1_048_576): Promise<string> {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const chunks: Buffer[] = [];
|
|
123
|
+
let totalBytes = 0;
|
|
124
|
+
req.on('data', (chunk: Buffer) => {
|
|
125
|
+
totalBytes += chunk.length;
|
|
126
|
+
if (totalBytes > maxBytes) {
|
|
127
|
+
req.destroy();
|
|
128
|
+
reject(new Error('Request body too large'));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
chunks.push(chunk);
|
|
132
|
+
});
|
|
133
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
134
|
+
req.on('error', reject);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Format hook response ───────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
interface Decision {
|
|
141
|
+
decision: 'approve' | 'request_changes';
|
|
142
|
+
annotations: Array<{
|
|
143
|
+
type: string;
|
|
144
|
+
blockId: string;
|
|
145
|
+
content: string;
|
|
146
|
+
selectedText?: string;
|
|
147
|
+
suggestion?: string;
|
|
148
|
+
}>;
|
|
149
|
+
blockStatuses: Record<string, string>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatHookResponse(decision: Decision): string {
|
|
153
|
+
if (decision.decision === 'approve') {
|
|
154
|
+
const annotationSummary =
|
|
155
|
+
decision.annotations.length > 0
|
|
156
|
+
? decision.annotations
|
|
157
|
+
.map((a) => `[${a.type}] ${a.content}`)
|
|
158
|
+
.join('; ')
|
|
159
|
+
: 'No annotations';
|
|
160
|
+
|
|
161
|
+
return JSON.stringify({
|
|
162
|
+
decision: 'approve',
|
|
163
|
+
reason: `Plan approved with annotations: ${annotationSummary}`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// request_changes → block
|
|
168
|
+
const changeDetails = decision.annotations
|
|
169
|
+
.map((a) => {
|
|
170
|
+
let detail = `- [${a.type}] ${a.content}`;
|
|
171
|
+
if (a.selectedText) detail += ` (on: "${a.selectedText}")`;
|
|
172
|
+
if (a.suggestion) detail += ` → "${a.suggestion}"`;
|
|
173
|
+
return detail;
|
|
174
|
+
})
|
|
175
|
+
.join('\n');
|
|
176
|
+
|
|
177
|
+
return JSON.stringify({
|
|
178
|
+
decision: 'block',
|
|
179
|
+
reason: `Changes requested:\n${changeDetails}`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Open browser ───────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function copyToClipboard(text: string) {
|
|
186
|
+
try {
|
|
187
|
+
execSync('pbcopy', { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
188
|
+
log('URL copied to clipboard');
|
|
189
|
+
} catch {
|
|
190
|
+
// silently fail on non-macOS
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function openBrowser(url: string) {
|
|
195
|
+
try {
|
|
196
|
+
// macOS
|
|
197
|
+
execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
198
|
+
log(`Opened browser: ${url}`);
|
|
199
|
+
} catch {
|
|
200
|
+
log(`Could not open browser automatically. Please visit: ${url}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
async function main() {
|
|
207
|
+
// 1. Read plan from stdin
|
|
208
|
+
log('Reading hook event from stdin...');
|
|
209
|
+
const raw = await readStdin();
|
|
210
|
+
const planMarkdown = extractPlanMarkdown(raw);
|
|
211
|
+
log(`Plan extracted (${planMarkdown.length} chars)`);
|
|
212
|
+
|
|
213
|
+
// 2. Verify dist directory exists
|
|
214
|
+
if (!fs.existsSync(DIST_DIR)) {
|
|
215
|
+
log(`ERROR: dist/ directory not found at ${DIST_DIR}`);
|
|
216
|
+
log('Run "npm run build" first to build the frontend.');
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 3. Create HTTP server
|
|
221
|
+
let resolveDecision: (value: Decision) => void;
|
|
222
|
+
const decisionPromise = new Promise<Decision>((resolve) => {
|
|
223
|
+
resolveDecision = resolve;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Heartbeat tracking — detect browser tab close
|
|
227
|
+
let lastHeartbeat = Date.now();
|
|
228
|
+
let heartbeatConnected = false;
|
|
229
|
+
const startTime = Date.now();
|
|
230
|
+
const sseClients = new Set<http.ServerResponse>();
|
|
231
|
+
|
|
232
|
+
const server = http.createServer(async (req, res) => {
|
|
233
|
+
setCorsHeaders(res);
|
|
234
|
+
|
|
235
|
+
// Handle CORS preflight
|
|
236
|
+
if (req.method === 'OPTIONS') {
|
|
237
|
+
res.writeHead(204);
|
|
238
|
+
res.end();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const url = new URL(req.url || '/', `http://localhost`);
|
|
243
|
+
|
|
244
|
+
// API: GET /api/plan
|
|
245
|
+
if (req.method === 'GET' && url.pathname === '/api/plan') {
|
|
246
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
247
|
+
res.end(JSON.stringify({ markdown: planMarkdown }));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// API: GET /api/heartbeat
|
|
252
|
+
if (req.method === 'GET' && url.pathname === '/api/heartbeat') {
|
|
253
|
+
lastHeartbeat = Date.now();
|
|
254
|
+
heartbeatConnected = true;
|
|
255
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
256
|
+
res.end(JSON.stringify({ ok: true }));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// API: GET /api/status (SSE — push countdown + connection state)
|
|
261
|
+
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
262
|
+
res.writeHead(200, {
|
|
263
|
+
'Content-Type': 'text/event-stream',
|
|
264
|
+
'Cache-Control': 'no-cache',
|
|
265
|
+
'Connection': 'keep-alive',
|
|
266
|
+
'Access-Control-Allow-Origin': '*',
|
|
267
|
+
});
|
|
268
|
+
res.write(':ok\n\n');
|
|
269
|
+
sseClients.add(res);
|
|
270
|
+
req.on('close', () => {
|
|
271
|
+
sseClients.delete(res);
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// API: POST /api/decision
|
|
277
|
+
if (req.method === 'POST' && url.pathname === '/api/decision') {
|
|
278
|
+
try {
|
|
279
|
+
const body = await readBody(req);
|
|
280
|
+
const decision: Decision = JSON.parse(body);
|
|
281
|
+
log(`Decision received: ${decision.decision}`);
|
|
282
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
283
|
+
res.end(JSON.stringify({ ok: true }));
|
|
284
|
+
resolveDecision!(decision);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
log(`Error parsing decision: ${err}`);
|
|
287
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
288
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Static files from dist/
|
|
294
|
+
if (req.method === 'GET') {
|
|
295
|
+
if (serveStaticFile(res, url.pathname)) return;
|
|
296
|
+
|
|
297
|
+
// SPA fallback: serve index.html for unmatched routes
|
|
298
|
+
if (serveStaticFile(res, '/')) return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
res.writeHead(404);
|
|
302
|
+
res.end('Not Found');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// 4. Start on configured or random port
|
|
306
|
+
await new Promise<void>((resolve) => {
|
|
307
|
+
server.listen(FIXED_PORT, '127.0.0.1', () => resolve());
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const addr = server.address();
|
|
311
|
+
if (!addr || typeof addr === 'string') {
|
|
312
|
+
log('ERROR: Could not determine server address');
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const url = `http://127.0.0.1:${addr.port}`;
|
|
317
|
+
log(`Server listening on ${url}`);
|
|
318
|
+
log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
|
319
|
+
log(` Plan Annotator: ${url}`);
|
|
320
|
+
log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
|
321
|
+
|
|
322
|
+
// 5. Open browser + copy URL to clipboard
|
|
323
|
+
copyToClipboard(url);
|
|
324
|
+
openBrowser(url);
|
|
325
|
+
|
|
326
|
+
// 6. Set up timeout
|
|
327
|
+
const timeout = setTimeout(() => {
|
|
328
|
+
log('Timeout reached (5 minutes). Shutting down.');
|
|
329
|
+
const response = JSON.stringify({
|
|
330
|
+
decision: 'block',
|
|
331
|
+
reason: 'Plan review timed out — no decision was made within 5 minutes.',
|
|
332
|
+
});
|
|
333
|
+
process.stdout.write(response);
|
|
334
|
+
server.close();
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}, TIMEOUT_MS);
|
|
337
|
+
|
|
338
|
+
// 6b. Heartbeat monitor — detect tab close + SSE countdown broadcast
|
|
339
|
+
const heartbeatCheck = setInterval(() => {
|
|
340
|
+
// Broadcast countdown to all SSE clients
|
|
341
|
+
const elapsed = Date.now() - startTime;
|
|
342
|
+
const remainingMs = Math.max(0, TIMEOUT_MS - elapsed);
|
|
343
|
+
const sseData = JSON.stringify({
|
|
344
|
+
remainingMs,
|
|
345
|
+
totalMs: TIMEOUT_MS,
|
|
346
|
+
connected: heartbeatConnected,
|
|
347
|
+
});
|
|
348
|
+
for (const client of sseClients) {
|
|
349
|
+
client.write(`data: ${sseData}\n\n`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (!heartbeatConnected) return; // wait for first heartbeat
|
|
353
|
+
const heartbeatElapsed = Date.now() - lastHeartbeat;
|
|
354
|
+
if (heartbeatElapsed > HEARTBEAT_INTERVAL_MS * HEARTBEAT_MISS_LIMIT) {
|
|
355
|
+
log('Browser tab closed (heartbeat lost). Shutting down.');
|
|
356
|
+
clearInterval(heartbeatCheck);
|
|
357
|
+
clearTimeout(timeout);
|
|
358
|
+
const response = JSON.stringify({
|
|
359
|
+
decision: 'block',
|
|
360
|
+
reason: 'Plan review cancelled — browser tab was closed.',
|
|
361
|
+
});
|
|
362
|
+
process.stdout.write(response);
|
|
363
|
+
server.close();
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
367
|
+
|
|
368
|
+
// 7. Wait for decision
|
|
369
|
+
const decision = await decisionPromise;
|
|
370
|
+
clearTimeout(timeout);
|
|
371
|
+
clearInterval(heartbeatCheck);
|
|
372
|
+
for (const client of sseClients) client.end();
|
|
373
|
+
sseClients.clear();
|
|
374
|
+
|
|
375
|
+
// 8. Output hook response to stdout
|
|
376
|
+
const hookResponse = formatHookResponse(decision);
|
|
377
|
+
process.stdout.write(hookResponse);
|
|
378
|
+
log('Hook response sent to stdout. Shutting down.');
|
|
379
|
+
|
|
380
|
+
// 9. Cleanup
|
|
381
|
+
server.close();
|
|
382
|
+
process.exit(0);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
main().catch((err) => {
|
|
386
|
+
log(`Fatal error: ${err}`);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
});
|