@delete101020/aidlc 4.0.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/AUTOMATION.md +148 -0
- package/README.md +684 -0
- package/dist/assets/annotate-artifact.skill.md +121 -0
- package/dist/assets/epic-context.skill.md +55 -0
- package/dist/bundle.js +49470 -0
- package/dist/templates/ainative/agents/native-engineer.md +66 -0
- package/dist/templates/ainative/agents/native-operator.md +71 -0
- package/dist/templates/ainative/agents/native-originator.md +56 -0
- package/dist/templates/ainative/agents/native-product-owner.md +68 -0
- package/dist/templates/ainative/agents/native-reviewer.md +65 -0
- package/dist/templates/ainative/agents/native-verifier.md +58 -0
- package/dist/templates/ainative/artifacts/build-plan.md +56 -0
- package/dist/templates/ainative/artifacts/implement.md +55 -0
- package/dist/templates/ainative/artifacts/intent.md +50 -0
- package/dist/templates/ainative/artifacts/maintain.md +85 -0
- package/dist/templates/ainative/artifacts/review.md +68 -0
- package/dist/templates/ainative/artifacts/spec.md +69 -0
- package/dist/templates/ainative/artifacts/verify.md +55 -0
- package/dist/templates/ainative/skills/native-build-plan.md +74 -0
- package/dist/templates/ainative/skills/native-implement.md +62 -0
- package/dist/templates/ainative/skills/native-intent.md +50 -0
- package/dist/templates/ainative/skills/native-maintain.md +94 -0
- package/dist/templates/ainative/skills/native-review.md +82 -0
- package/dist/templates/ainative/skills/native-spec.md +83 -0
- package/dist/templates/ainative/skills/native-verify.md +60 -0
- package/dist/templates/sdlc/agents/designer.md +87 -0
- package/dist/templates/sdlc/agents/developer.md +178 -0
- package/dist/templates/sdlc/agents/example-with-skills.md +59 -0
- package/dist/templates/sdlc/agents/orchestrator.md +186 -0
- package/dist/templates/sdlc/agents/po.md +115 -0
- package/dist/templates/sdlc/agents/qa.md +141 -0
- package/dist/templates/sdlc/agents/tech-lead.md +124 -0
- package/dist/templates/sdlc/artifacts/design.md +130 -0
- package/dist/templates/sdlc/artifacts/execute-test.md +64 -0
- package/dist/templates/sdlc/artifacts/implement.backend.md +96 -0
- package/dist/templates/sdlc/artifacts/implement.md +70 -0
- package/dist/templates/sdlc/artifacts/implement.web-react.md +109 -0
- package/dist/templates/sdlc/artifacts/implement.web.md +99 -0
- package/dist/templates/sdlc/artifacts/plan.md +97 -0
- package/dist/templates/sdlc/artifacts/prototype.md +141 -0
- package/dist/templates/sdlc/artifacts/test-cases.md +47 -0
- package/dist/templates/sdlc/artifacts/test-plan.md +100 -0
- package/dist/templates/sdlc/artifacts/unit-test.md +32 -0
- package/dist/templates/sdlc/profiles/agile-lite.yaml +29 -0
- package/dist/templates/sdlc/profiles/hybrid.yaml +43 -0
- package/dist/templates/sdlc/profiles/iso-ieee.yaml +67 -0
- package/dist/templates/sdlc/profiles/none.yaml +21 -0
- package/dist/templates/sdlc/skills/advance-epic.md +77 -0
- package/dist/templates/sdlc/skills/analyze-requirements.md +279 -0
- package/dist/templates/sdlc/skills/discovery-gate.md +118 -0
- package/dist/templates/sdlc/skills/execute-test.md +74 -0
- package/dist/templates/sdlc/skills/generate-test-cases.md +77 -0
- package/dist/templates/sdlc/skills/implement.md +74 -0
- package/dist/templates/sdlc/skills/prd.md +114 -0
- package/dist/templates/sdlc/skills/prototype.md +128 -0
- package/dist/templates/sdlc/skills/tech-design.md +134 -0
- package/dist/templates/sdlc/skills/test-plan.md +115 -0
- package/dist/templates/sdlc/skills/test-report.md +38 -0
- package/dist/templates/sdlc/skills/unit-test.md +43 -0
- package/dist/templates/sdlc/validators/ci.mjs +49 -0
- package/dist/templates/sdlc/validators/traceability.mjs +244 -0
- package/dist/templates/speckit/agents/analyst.md +42 -0
- package/dist/templates/speckit/agents/speckit-developer.md +36 -0
- package/dist/templates/speckit/agents/speckit-qa.md +38 -0
- package/dist/templates/speckit/agents/speckit-tech-lead.md +40 -0
- package/dist/templates/speckit/artifacts/analyze.md +48 -0
- package/dist/templates/speckit/artifacts/clarify.md +64 -0
- package/dist/templates/speckit/artifacts/plan.md +57 -0
- package/dist/templates/speckit/artifacts/specify.md +64 -0
- package/dist/templates/speckit/artifacts/tasks.md +33 -0
- package/dist/templates/speckit/skills/analyze.md +50 -0
- package/dist/templates/speckit/skills/clarify.md +31 -0
- package/dist/templates/speckit/skills/plan.md +56 -0
- package/dist/templates/speckit/skills/specify.md +52 -0
- package/dist/templates/speckit/skills/speckit-implement.md +33 -0
- package/dist/templates/speckit/skills/tasks.md +38 -0
- package/dist/tools/epic-memory-hook.mjs +78 -0
- package/dist/tools/epic-memory.mjs +130 -0
- package/dist/tools/md-to-html.mjs +478 -0
- package/dist/tools/vendor/marked.esm.mjs +2432 -0
- package/dist/vendor/annotron/LICENSE +21 -0
- package/dist/vendor/annotron/README.md +213 -0
- package/dist/vendor/annotron/bin/annotron +467 -0
- package/dist/vendor/annotron/commands/annotron.md +11 -0
- package/dist/vendor/annotron/hooks/annotron-hook.sh +38 -0
- package/dist/vendor/annotron/hooks/hooks.json +50 -0
- package/dist/vendor/annotron/package.json +72 -0
- package/dist/vendor/annotron/skills/annotron-review/SKILL.md +86 -0
- package/dist/vendor/annotron/src/chrome.html +2029 -0
- package/dist/vendor/annotron/src/mdRender.js +208 -0
- package/dist/vendor/annotron/src/sdk.js +1042 -0
- package/dist/vendor/annotron/src/server.js +780 -0
- package/package.json +60 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import { execFile } from 'child_process';
|
|
7
|
+
import { promisify } from 'util';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { renderMarkdown } from './mdRender.js';
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const execFileP = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
// A source Markdown artifact is rendered to HTML on the fly; the .md stays the
|
|
15
|
+
// source of truth and is what the Save button writes back to.
|
|
16
|
+
const isMarkdown = (f) => /\.(md|markdown)$/i.test(f);
|
|
17
|
+
|
|
18
|
+
// --- Reviewer identity ---------------------------------------------------------
|
|
19
|
+
// Who is leaving comments? Prefer the GitHub login (via `gh`), then the git
|
|
20
|
+
// user.name/email, then the OS user@hostname. Computed once and cached — this is
|
|
21
|
+
// a single-user local tool. PATH is augmented so `gh`/`git` resolve even when
|
|
22
|
+
// the server was spawned from a GUI (Dock) with a minimal PATH.
|
|
23
|
+
let _identityPromise = null;
|
|
24
|
+
function identityEnv() {
|
|
25
|
+
const home = process.env.HOME || '';
|
|
26
|
+
const extra = ['/opt/homebrew/bin', '/usr/local/bin', `${home}/.local/bin`].filter(Boolean).join(':');
|
|
27
|
+
return { ...process.env, PATH: `${process.env.PATH || ''}:${extra}` };
|
|
28
|
+
}
|
|
29
|
+
async function runId(cmd, args, cwd) {
|
|
30
|
+
try {
|
|
31
|
+
const { stdout } = await execFileP(cmd, args, { cwd, timeout: 2500, env: identityEnv() });
|
|
32
|
+
return stdout.toString().trim();
|
|
33
|
+
} catch { return ''; }
|
|
34
|
+
}
|
|
35
|
+
async function computeIdentity(cwd) {
|
|
36
|
+
const [gitName, gitEmail, login] = await Promise.all([
|
|
37
|
+
runId('git', ['config', 'user.name'], cwd),
|
|
38
|
+
runId('git', ['config', 'user.email'], cwd),
|
|
39
|
+
runId('gh', ['api', 'user', '--jq', '.login'], cwd),
|
|
40
|
+
]);
|
|
41
|
+
let user = '', host = '';
|
|
42
|
+
try { user = os.userInfo().username; } catch {}
|
|
43
|
+
try { host = os.hostname().replace(/\.local$/, ''); } catch {}
|
|
44
|
+
const name = login || gitName || user || 'Reviewer';
|
|
45
|
+
const userHost = user && host ? `${user}@${host}` : (host || user);
|
|
46
|
+
const detail = login ? (gitEmail || userHost) : (gitEmail || userHost);
|
|
47
|
+
return { name, detail, login, gitName, gitEmail, user, host };
|
|
48
|
+
}
|
|
49
|
+
function getIdentity(cwd) {
|
|
50
|
+
if (!_identityPromise) _identityPromise = computeIdentity(cwd);
|
|
51
|
+
return _identityPromise;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const PORT = parseInt(process.env.ANNOTRON_PORT || '7321', 10);
|
|
55
|
+
const HOST = process.env.ANNOTRON_HOST || '127.0.0.1';
|
|
56
|
+
|
|
57
|
+
// --- State ---
|
|
58
|
+
const sessions = new Map(); // filePath -> { hash, watchers, sseClients, pollWaiters, agentReplies }
|
|
59
|
+
const allowList = new Set(); // registered canonical paths
|
|
60
|
+
|
|
61
|
+
function getSession(file) {
|
|
62
|
+
if (!sessions.has(file)) {
|
|
63
|
+
sessions.set(file, {
|
|
64
|
+
hash: null,
|
|
65
|
+
sseClients: new Set(),
|
|
66
|
+
pollWaiters: [],
|
|
67
|
+
pendingFeedback: null,
|
|
68
|
+
finalized: false,
|
|
69
|
+
cancelRequested: false,
|
|
70
|
+
working: false,
|
|
71
|
+
remoteApprove: false, // route permission prompts to the browser
|
|
72
|
+
autoAllowTools: new Set(), // tools the user chose "allow always" for
|
|
73
|
+
permWaiters: new Map(), // requestId -> resolve(decisionObj|null)
|
|
74
|
+
pendingPerms: new Map(), // requestId -> {tool, summary} (for reconnecting clients)
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return sessions.get(file);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- File watcher ---
|
|
81
|
+
const watchIntervals = new Map();
|
|
82
|
+
|
|
83
|
+
function watchFile(file) {
|
|
84
|
+
if (watchIntervals.has(file)) return;
|
|
85
|
+
let lastHash = hashFile(file);
|
|
86
|
+
const iv = setInterval(() => {
|
|
87
|
+
const h = hashFile(file);
|
|
88
|
+
if (h && h !== lastHash) {
|
|
89
|
+
lastHash = h;
|
|
90
|
+
const sess = sessions.get(file);
|
|
91
|
+
if (!sess) return;
|
|
92
|
+
// Only trigger reload if not a programmatic finalize write
|
|
93
|
+
if (sess._suppressReload) { sess._suppressReload = false; return; }
|
|
94
|
+
broadcastSSE(file, 'reload', '{}');
|
|
95
|
+
}
|
|
96
|
+
}, 800);
|
|
97
|
+
watchIntervals.set(file, iv);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hashFile(file) {
|
|
101
|
+
try {
|
|
102
|
+
const buf = fs.readFileSync(file);
|
|
103
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
104
|
+
} catch { return null; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sidecarPath(file) {
|
|
108
|
+
const ext = path.extname(file);
|
|
109
|
+
return file.slice(0, -ext.length) + '.annotron.json';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readSidecar(file) {
|
|
113
|
+
try {
|
|
114
|
+
const raw = fs.readFileSync(sidecarPath(file), 'utf8');
|
|
115
|
+
return JSON.parse(raw);
|
|
116
|
+
} catch { return { version: 1, annotations: [], rounds: [] }; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function writeSidecar(file, data) {
|
|
120
|
+
fs.writeFileSync(sidecarPath(file), JSON.stringify(data, null, 2), 'utf8');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- SSE broadcast ---
|
|
124
|
+
function broadcastSSE(file, event, data) {
|
|
125
|
+
const sess = sessions.get(file);
|
|
126
|
+
if (!sess) return;
|
|
127
|
+
const msg = `event: ${event}\ndata: ${data}\n\n`;
|
|
128
|
+
for (const res of sess.sseClients) {
|
|
129
|
+
try { res.write(msg); } catch {}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- Poll wake ---
|
|
134
|
+
function wakePoll(file, payload) {
|
|
135
|
+
const sess = sessions.get(file);
|
|
136
|
+
if (!sess) return;
|
|
137
|
+
for (const resolve of sess.pollWaiters) resolve(payload);
|
|
138
|
+
sess.pollWaiters = [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- Hook helpers ---
|
|
142
|
+
// Correlate an incoming hook event (which only knows cwd) to the annotron
|
|
143
|
+
// session it belongs to. Prefer a session that is actively working; if several,
|
|
144
|
+
// disambiguate by cwd containment.
|
|
145
|
+
function findActiveSession(cwd) {
|
|
146
|
+
const working = [];
|
|
147
|
+
for (const [f, s] of sessions) if (s.working) working.push([f, s]);
|
|
148
|
+
if (working.length === 0) return null;
|
|
149
|
+
if (working.length === 1) return working[0];
|
|
150
|
+
const norm = cwd ? cwd.replace(/\/+$/, '') + '/' : '';
|
|
151
|
+
const match = norm && working.find(([f]) => f.startsWith(norm));
|
|
152
|
+
return match || working[0];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Like findActiveSession but also matches an idle (non-working) session under
|
|
156
|
+
// cwd — used for status events (idle/permission notifications) that arrive when
|
|
157
|
+
// the agent is between rounds.
|
|
158
|
+
function findSessionByCwd(cwd) {
|
|
159
|
+
const active = findActiveSession(cwd);
|
|
160
|
+
if (active) return active;
|
|
161
|
+
const norm = cwd ? cwd.replace(/\/+$/, '') + '/' : '';
|
|
162
|
+
let best = null;
|
|
163
|
+
for (const [f, s] of sessions) {
|
|
164
|
+
if (!norm || f.startsWith(norm)) best = [f, s];
|
|
165
|
+
}
|
|
166
|
+
return best;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Turn a tool call into a short, CLI-like activity line.
|
|
170
|
+
function formatActivity(tool, input) {
|
|
171
|
+
input = input || {};
|
|
172
|
+
const base = p => (typeof p === 'string' ? p.split('/').pop() : '');
|
|
173
|
+
const clip = (s, n) => (typeof s === 'string' ? (s.length > n ? s.slice(0, n) + '…' : s) : '');
|
|
174
|
+
switch (tool) {
|
|
175
|
+
case 'Read': return `Read ${base(input.file_path)}`;
|
|
176
|
+
case 'Edit':
|
|
177
|
+
case 'MultiEdit': return `Edit ${base(input.file_path)}`;
|
|
178
|
+
case 'Write': return `Write ${base(input.file_path)}`;
|
|
179
|
+
case 'NotebookEdit': return `Edit ${base(input.notebook_path || input.file_path)}`;
|
|
180
|
+
case 'Bash': return `Bash: ${clip(input.command, 70)}`;
|
|
181
|
+
case 'Grep': return `Search "${clip(input.pattern, 40)}"`;
|
|
182
|
+
case 'Glob': return `Find ${clip(input.pattern, 40)}`;
|
|
183
|
+
case 'WebFetch': return `Fetch ${clip(input.url, 50)}`;
|
|
184
|
+
case 'WebSearch': return `Web search "${clip(input.query, 40)}"`;
|
|
185
|
+
case 'Task': return `Delegate: ${clip(input.description, 40)}`;
|
|
186
|
+
default: return tool || 'Working…';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Permission-decision JSON the PreToolUse hook echoes back to Claude Code.
|
|
191
|
+
function permJSON(decision, reason) {
|
|
192
|
+
return JSON.stringify({
|
|
193
|
+
hookSpecificOutput: {
|
|
194
|
+
hookEventName: 'PreToolUse',
|
|
195
|
+
permissionDecision: decision,
|
|
196
|
+
...(reason ? { permissionDecisionReason: reason } : {}),
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- SDK injection ---
|
|
202
|
+
const SDK_PATH = path.join(__dirname, 'sdk.js');
|
|
203
|
+
const CHROME_PATH = path.join(__dirname, 'chrome.html');
|
|
204
|
+
const CHROME_HTML = fs.readFileSync(CHROME_PATH, 'utf8');
|
|
205
|
+
|
|
206
|
+
function injectSDK(html) {
|
|
207
|
+
const sdkJs = fs.readFileSync(SDK_PATH, 'utf8');
|
|
208
|
+
const tag = `<script data-annotron="1">\n${sdkJs}\n</script>`;
|
|
209
|
+
if (html.includes('</body>')) return html.replace('</body>', tag + '\n</body>');
|
|
210
|
+
return html + '\n' + tag;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- Request helpers ---
|
|
214
|
+
function readBody(req) {
|
|
215
|
+
return new Promise((resolve, reject) => {
|
|
216
|
+
let data = '';
|
|
217
|
+
req.on('data', c => data += c);
|
|
218
|
+
req.on('end', () => { try { resolve(JSON.parse(data || '{}')); } catch { resolve({}); } });
|
|
219
|
+
req.on('error', reject);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function send(res, status, body, ct = 'application/json') {
|
|
224
|
+
const s = typeof body === 'string' ? body : JSON.stringify(body);
|
|
225
|
+
res.writeHead(status, { 'Content-Type': ct, 'Access-Control-Allow-Origin': '*' });
|
|
226
|
+
res.end(s);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- Router ---
|
|
230
|
+
async function handler(req, res) {
|
|
231
|
+
const u = new URL(req.url, `http://${req.headers.host}`);
|
|
232
|
+
const method = req.method.toUpperCase();
|
|
233
|
+
const pathname = u.pathname;
|
|
234
|
+
|
|
235
|
+
// CORS preflight
|
|
236
|
+
if (method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST', 'Access-Control-Allow-Headers': 'Content-Type' }); res.end(); return; }
|
|
237
|
+
|
|
238
|
+
if (pathname === '/health' && method === 'GET') {
|
|
239
|
+
return send(res, 200, { ok: true });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (pathname === '/session' && method === 'POST') {
|
|
243
|
+
const { file } = await readBody(req);
|
|
244
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
245
|
+
const abs = path.resolve(file);
|
|
246
|
+
if (!fs.existsSync(abs)) return send(res, 404, { error: 'file not found' });
|
|
247
|
+
if (!abs.endsWith('.html') && !isMarkdown(abs)) return send(res, 400, { error: 'must be .html or .md' });
|
|
248
|
+
allowList.add(abs);
|
|
249
|
+
getSession(abs);
|
|
250
|
+
watchFile(abs);
|
|
251
|
+
return send(res, 200, { ok: true, file: abs });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (pathname === '/' && method === 'GET') {
|
|
255
|
+
const html = fs.readFileSync(CHROME_PATH, 'utf8');
|
|
256
|
+
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });
|
|
257
|
+
res.end(html);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (pathname === '/sdk.js' && method === 'GET') {
|
|
262
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store' });
|
|
263
|
+
res.end(fs.readFileSync(SDK_PATH, 'utf8'));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (pathname === '/artifact' && method === 'GET') {
|
|
268
|
+
const file = u.searchParams.get('file');
|
|
269
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
270
|
+
const abs = path.resolve(file);
|
|
271
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
272
|
+
let raw;
|
|
273
|
+
try { raw = fs.readFileSync(abs, 'utf8'); } catch { return send(res, 404, { error: 'not found' }); }
|
|
274
|
+
// Markdown source is rendered to HTML (with merslim diagrams) on the fly;
|
|
275
|
+
// HTML artifacts are served as-is. Either way the SDK is injected.
|
|
276
|
+
let html;
|
|
277
|
+
if (isMarkdown(abs)) {
|
|
278
|
+
try { html = await renderMarkdown(raw, { title: path.basename(abs) }); }
|
|
279
|
+
catch (e) { return send(res, 500, { error: 'markdown render failed: ' + e.message }); }
|
|
280
|
+
} else {
|
|
281
|
+
html = raw;
|
|
282
|
+
}
|
|
283
|
+
const injected = injectSDK(html);
|
|
284
|
+
// Never cache: the artifact changes as the agent edits it, and the injected
|
|
285
|
+
// SDK changes across versions — a cached copy would serve a stale preview
|
|
286
|
+
// (and stale annotation overlays) even after a reload.
|
|
287
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
288
|
+
res.end(injected);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Raw Markdown source (for the in-browser editor pane, Markdown mode only).
|
|
293
|
+
if (pathname === '/source' && method === 'GET') {
|
|
294
|
+
const file = u.searchParams.get('file');
|
|
295
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
296
|
+
const abs = path.resolve(file);
|
|
297
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
298
|
+
if (!isMarkdown(abs)) return send(res, 400, { error: 'not a markdown file' });
|
|
299
|
+
let text;
|
|
300
|
+
try { text = fs.readFileSync(abs, 'utf8'); } catch { return send(res, 404, { error: 'not found' }); }
|
|
301
|
+
return send(res, 200, { file: abs, markdown: text });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Save the edited Markdown back to the .md source. The file watcher then
|
|
305
|
+
// fires a reload, re-rendering the preview (diagrams included).
|
|
306
|
+
if (pathname === '/save-md' && method === 'POST') {
|
|
307
|
+
const body = await readBody(req);
|
|
308
|
+
const file = body.file;
|
|
309
|
+
if (!file || typeof body.markdown !== 'string') return send(res, 400, { error: 'missing file or markdown' });
|
|
310
|
+
const abs = path.resolve(file);
|
|
311
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
312
|
+
if (!isMarkdown(abs)) return send(res, 400, { error: 'not a markdown file' });
|
|
313
|
+
try { fs.writeFileSync(abs, body.markdown, 'utf8'); } catch (e) { return send(res, 500, { error: 'write failed: ' + e.message }); }
|
|
314
|
+
return send(res, 200, { ok: true });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Inline edit: replace one exact run of text in the .md source with new text
|
|
318
|
+
// (empty newText = delete). Used by the "Edit" affordance on a text selection
|
|
319
|
+
// in Annotate mode — a direct, agent-free tweak. The client only offers Edit
|
|
320
|
+
// when it can pin a single occurrence, and passes that `index`; the server
|
|
321
|
+
// re-verifies the slice still equals `oldText` (falls back to a unique search
|
|
322
|
+
// if the file shifted underneath) before writing, so it never edits blind.
|
|
323
|
+
if (pathname === '/edit-text' && method === 'POST') {
|
|
324
|
+
const body = await readBody(req);
|
|
325
|
+
const { file, oldText, index } = body;
|
|
326
|
+
const newText = typeof body.newText === 'string' ? body.newText : '';
|
|
327
|
+
if (!file || typeof oldText !== 'string' || !oldText) return send(res, 400, { error: 'missing file or oldText' });
|
|
328
|
+
const abs = path.resolve(file);
|
|
329
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
330
|
+
if (!isMarkdown(abs)) return send(res, 400, { error: 'not a markdown file' });
|
|
331
|
+
let md;
|
|
332
|
+
try { md = fs.readFileSync(abs, 'utf8'); } catch { return send(res, 404, { error: 'not found' }); }
|
|
333
|
+
// Prefer the client-provided index (verified), else a unique match.
|
|
334
|
+
let at = -1;
|
|
335
|
+
if (Number.isInteger(index) && md.slice(index, index + oldText.length) === oldText) {
|
|
336
|
+
at = index;
|
|
337
|
+
} else {
|
|
338
|
+
const first = md.indexOf(oldText);
|
|
339
|
+
if (first !== -1 && md.indexOf(oldText, first + 1) === -1) at = first; // unique only
|
|
340
|
+
}
|
|
341
|
+
if (at === -1) return send(res, 409, { error: 'text not found or ambiguous — file changed' });
|
|
342
|
+
const updated = md.slice(0, at) + newText + md.slice(at + oldText.length);
|
|
343
|
+
try { fs.writeFileSync(abs, updated, 'utf8'); } catch (e) { return send(res, 500, { error: 'write failed: ' + e.message }); }
|
|
344
|
+
return send(res, 200, { ok: true });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (pathname === '/annotations' && method === 'GET') {
|
|
348
|
+
const file = u.searchParams.get('file');
|
|
349
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
350
|
+
const abs = path.resolve(file);
|
|
351
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
352
|
+
return send(res, 200, readSidecar(abs));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Who is reviewing? Used by the browser to label comment authors.
|
|
356
|
+
if (pathname === '/whoami' && method === 'GET') {
|
|
357
|
+
const file = u.searchParams.get('file');
|
|
358
|
+
const cwd = file ? path.dirname(path.resolve(file)) : process.cwd();
|
|
359
|
+
return send(res, 200, await getIdentity(cwd));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (pathname === '/annotations' && method === 'POST') {
|
|
363
|
+
const body = await readBody(req);
|
|
364
|
+
const file = body.file;
|
|
365
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
366
|
+
const abs = path.resolve(file);
|
|
367
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
368
|
+
const current = readSidecar(abs);
|
|
369
|
+
current.annotations = body.annotations || current.annotations;
|
|
370
|
+
if (body.rounds) current.rounds = body.rounds;
|
|
371
|
+
writeSidecar(abs, current);
|
|
372
|
+
return send(res, 200, { ok: true });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (pathname === '/feedback' && method === 'POST') {
|
|
376
|
+
const body = await readBody(req);
|
|
377
|
+
const file = body.file;
|
|
378
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
379
|
+
const abs = path.resolve(file);
|
|
380
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
381
|
+
// Persist annotations to sidecar, stamped with the reviewer's identity.
|
|
382
|
+
if (body.items && body.items.length > 0) {
|
|
383
|
+
const who = await getIdentity(path.dirname(abs));
|
|
384
|
+
const now = () => new Date().toISOString();
|
|
385
|
+
const humanMsg = (message) => ({ role: 'human', author: who.name, authorDetail: who.detail, message, timestamp: now() });
|
|
386
|
+
const sidecar = readSidecar(abs);
|
|
387
|
+
for (const item of body.items) {
|
|
388
|
+
const existing = item.id ? sidecar.annotations.find(a => a.id === item.id) : null;
|
|
389
|
+
if (existing) {
|
|
390
|
+
// Add new message to existing thread
|
|
391
|
+
if (!existing.thread) existing.thread = [];
|
|
392
|
+
// A retry re-sends the original instruction (already in the thread) —
|
|
393
|
+
// just clear the failed flag; don't append a duplicate human message.
|
|
394
|
+
if (item.retry) delete existing.applyFailed;
|
|
395
|
+
else if (item.note) existing.thread.push(humanMsg(item.note));
|
|
396
|
+
if (item.kind === 'text') {
|
|
397
|
+
existing.textStart = Number.isInteger(item.textStart) ? item.textStart : existing.textStart ?? null;
|
|
398
|
+
existing.textEnd = Number.isInteger(item.textEnd) ? item.textEnd : existing.textEnd ?? null;
|
|
399
|
+
existing.textPrefix = item.textPrefix || existing.textPrefix || null;
|
|
400
|
+
existing.textSuffix = item.textSuffix || existing.textSuffix || null;
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
// New annotation
|
|
404
|
+
const ann = {
|
|
405
|
+
id: item.id || ('ann_' + Math.random().toString(36).slice(2, 9)),
|
|
406
|
+
kind: item.kind,
|
|
407
|
+
selector: item.selector || null,
|
|
408
|
+
label: item.label || null,
|
|
409
|
+
text: item.text || null,
|
|
410
|
+
textStart: Number.isInteger(item.textStart) ? item.textStart : null,
|
|
411
|
+
textEnd: Number.isInteger(item.textEnd) ? item.textEnd : null,
|
|
412
|
+
textPrefix: item.textPrefix || null,
|
|
413
|
+
textSuffix: item.textSuffix || null,
|
|
414
|
+
author: who.name,
|
|
415
|
+
authorDetail: who.detail,
|
|
416
|
+
thread: item.note ? [humanMsg(item.note)] : [],
|
|
417
|
+
createdAt: now(),
|
|
418
|
+
status: 'open',
|
|
419
|
+
};
|
|
420
|
+
item.id = ann.id;
|
|
421
|
+
sidecar.annotations.push(ann);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
writeSidecar(abs, sidecar);
|
|
425
|
+
}
|
|
426
|
+
const sess = getSession(abs);
|
|
427
|
+
sess.pendingFeedback = body;
|
|
428
|
+
sess.finalized = false;
|
|
429
|
+
sess.cancelRequested = false;
|
|
430
|
+
sess.selectedModel = body.model || null;
|
|
431
|
+
broadcastSSE(abs, 'agent-thinking', '{}');
|
|
432
|
+
wakePoll(abs, { feedback: body, finalized: false });
|
|
433
|
+
return send(res, 200, { ok: true });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (pathname === '/poll' && method === 'GET') {
|
|
437
|
+
const file = u.searchParams.get('file');
|
|
438
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
439
|
+
const abs = path.resolve(file);
|
|
440
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
441
|
+
const sess = getSession(abs);
|
|
442
|
+
|
|
443
|
+
// If there's already feedback waiting, return it immediately
|
|
444
|
+
if (sess.pendingFeedback) {
|
|
445
|
+
const payload = { feedback: sess.pendingFeedback, finalized: sess.finalized };
|
|
446
|
+
sess.pendingFeedback = null;
|
|
447
|
+
sess.working = true; // agent is now processing a round
|
|
448
|
+
return send(res, 200, payload);
|
|
449
|
+
}
|
|
450
|
+
if (sess.finalized) {
|
|
451
|
+
sess.finalized = false;
|
|
452
|
+
sess.working = false;
|
|
453
|
+
return send(res, 200, { finalized: true });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Long-poll
|
|
457
|
+
const timeout = setTimeout(() => {
|
|
458
|
+
sess.pollWaiters = sess.pollWaiters.filter(r => r !== resolve);
|
|
459
|
+
send(res, 200, { feedback: null, finalized: false });
|
|
460
|
+
}, 25000);
|
|
461
|
+
|
|
462
|
+
let resolve;
|
|
463
|
+
const p = new Promise(r => { resolve = r; });
|
|
464
|
+
sess.pollWaiters.push(resolve);
|
|
465
|
+
const payload = await p;
|
|
466
|
+
clearTimeout(timeout);
|
|
467
|
+
sess.pendingFeedback = null;
|
|
468
|
+
if (payload && payload.feedback) sess.working = true; // handed a new round to the agent
|
|
469
|
+
else if (payload && payload.finalized) sess.working = false;
|
|
470
|
+
return send(res, 200, payload);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (pathname === '/finalize' && method === 'POST') {
|
|
474
|
+
const body = await readBody(req);
|
|
475
|
+
const file = body.file;
|
|
476
|
+
if (!file || !body.html) return send(res, 400, { error: 'missing file or html' });
|
|
477
|
+
const abs = path.resolve(file);
|
|
478
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
479
|
+
// Never overwrite a Markdown source with rendered HTML — use /save-md.
|
|
480
|
+
if (isMarkdown(abs)) return send(res, 400, { error: 'markdown source: use Save (writes .md), not Finalize' });
|
|
481
|
+
const sess = getSession(abs);
|
|
482
|
+
sess._suppressReload = true;
|
|
483
|
+
fs.writeFileSync(abs, body.html, 'utf8');
|
|
484
|
+
sess.finalized = true;
|
|
485
|
+
wakePoll(abs, { finalized: true });
|
|
486
|
+
return send(res, 200, { ok: true });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (pathname === '/done' && method === 'POST') {
|
|
490
|
+
// Mark the review finished: no file is written (Save/Finalize already did
|
|
491
|
+
// that) — this just ends the agent's poll loop cleanly so it can exit, and
|
|
492
|
+
// tells the browser the session is over. Callers usually follow with /stop.
|
|
493
|
+
const body = await readBody(req);
|
|
494
|
+
const file = body.file;
|
|
495
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
496
|
+
const abs = path.resolve(file);
|
|
497
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
498
|
+
const sess = getSession(abs);
|
|
499
|
+
sess.finalized = true;
|
|
500
|
+
sess.working = false;
|
|
501
|
+
wakePoll(abs, { finalized: true });
|
|
502
|
+
broadcastSSE(abs, 'session-done', '{}');
|
|
503
|
+
return send(res, 200, { ok: true });
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (pathname === '/agent-reply' && method === 'POST') {
|
|
507
|
+
const body = await readBody(req);
|
|
508
|
+
const file = body.file;
|
|
509
|
+
if (!file || !body.message) return send(res, 400, { error: 'missing file or message' });
|
|
510
|
+
const abs = path.resolve(file);
|
|
511
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
512
|
+
// Agent is reporting back → the round is done, it's no longer actively working
|
|
513
|
+
getSession(abs).working = false;
|
|
514
|
+
// Persist reply into sidecar thread if annotationId given
|
|
515
|
+
if (body.annotationId) {
|
|
516
|
+
const sidecar = readSidecar(abs);
|
|
517
|
+
const ann = sidecar.annotations.find(a => a.id === body.annotationId);
|
|
518
|
+
if (ann) {
|
|
519
|
+
if (!ann.thread) ann.thread = [];
|
|
520
|
+
const now = new Date().toISOString();
|
|
521
|
+
ann.thread.push({ role: 'agent', message: body.message, timestamp: now });
|
|
522
|
+
// A comment the agent successfully acted on is considered resolved — the
|
|
523
|
+
// UI moves it to the History tab, timestamped so the reviewer can track
|
|
524
|
+
// it. A failed apply (body.failed) stays open and is flagged so the UI
|
|
525
|
+
// can red-border it and offer a Retry.
|
|
526
|
+
if (!body.failed) {
|
|
527
|
+
ann.status = 'resolved';
|
|
528
|
+
ann.resolvedAt = now;
|
|
529
|
+
delete ann.applyFailed;
|
|
530
|
+
} else {
|
|
531
|
+
ann.applyFailed = true;
|
|
532
|
+
}
|
|
533
|
+
writeSidecar(abs, sidecar);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
broadcastSSE(abs, 'agent-reply', JSON.stringify({ message: body.message, annotationId: body.annotationId || null, failed: !!body.failed }));
|
|
537
|
+
return send(res, 200, { ok: true });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (pathname === '/upload' && method === 'POST') {
|
|
541
|
+
const body = await readBody(req);
|
|
542
|
+
const file = body.file;
|
|
543
|
+
if (!file || !body.dataUrl) return send(res, 400, { error: 'missing file or dataUrl' });
|
|
544
|
+
const abs = path.resolve(file);
|
|
545
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
546
|
+
const m = /^data:(image\/[a-zA-Z0-9.+-]+);base64,([\s\S]+)$/.exec(body.dataUrl);
|
|
547
|
+
if (!m) return send(res, 400, { error: 'invalid image data' });
|
|
548
|
+
const mime = m[1];
|
|
549
|
+
let buf;
|
|
550
|
+
try { buf = Buffer.from(m[2], 'base64'); } catch { return send(res, 400, { error: 'invalid base64' }); }
|
|
551
|
+
const MAX = 20 * 1024 * 1024;
|
|
552
|
+
if (buf.length > MAX) return send(res, 413, { error: 'image too large (max 20MB)' });
|
|
553
|
+
const extByMime = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/bmp': 'bmp' };
|
|
554
|
+
const ext = extByMime[mime] || 'png';
|
|
555
|
+
const dir = path.join(path.dirname(abs), '.annotron-uploads');
|
|
556
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
557
|
+
const rawName = (body.name || 'image').replace(/\.[^.]*$/, '');
|
|
558
|
+
const safeBase = rawName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60) || 'image';
|
|
559
|
+
const fname = `${Date.now()}_${Math.random().toString(36).slice(2, 7)}_${safeBase}.${ext}`;
|
|
560
|
+
const dest = path.join(dir, fname);
|
|
561
|
+
try { fs.writeFileSync(dest, buf); } catch (e) { return send(res, 500, { error: 'write failed: ' + e.message }); }
|
|
562
|
+
return send(res, 200, { ok: true, path: dest, name: body.name || fname });
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (pathname === '/agent-progress' && method === 'POST') {
|
|
566
|
+
const body = await readBody(req);
|
|
567
|
+
const file = body.file;
|
|
568
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
569
|
+
const abs = path.resolve(file);
|
|
570
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
571
|
+
broadcastSSE(abs, 'agent-progress', JSON.stringify({ step: body.step || '', done: !!body.done }));
|
|
572
|
+
return send(res, 200, { ok: true });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (pathname === '/agent-metrics' && method === 'POST') {
|
|
576
|
+
const body = await readBody(req);
|
|
577
|
+
const file = body.file;
|
|
578
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
579
|
+
const abs = path.resolve(file);
|
|
580
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
581
|
+
const metrics = {
|
|
582
|
+
model: body.model || undefined,
|
|
583
|
+
tokensUsed: body.tokensUsed || undefined,
|
|
584
|
+
tokensMax: body.tokensMax || undefined,
|
|
585
|
+
contextUsed: body.contextUsed || undefined,
|
|
586
|
+
contextMax: body.contextMax || undefined,
|
|
587
|
+
};
|
|
588
|
+
Object.keys(metrics).forEach(k => metrics[k] === undefined && delete metrics[k]);
|
|
589
|
+
broadcastSSE(abs, 'agent-metrics', JSON.stringify(metrics));
|
|
590
|
+
return send(res, 200, { ok: true });
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (pathname === '/cancel' && method === 'POST') {
|
|
594
|
+
const body = await readBody(req);
|
|
595
|
+
const file = body.file;
|
|
596
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
597
|
+
const abs = path.resolve(file);
|
|
598
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
599
|
+
const sess = getSession(abs);
|
|
600
|
+
sess.cancelRequested = true;
|
|
601
|
+
broadcastSSE(abs, 'agent-cancelled', '{}');
|
|
602
|
+
return send(res, 200, { ok: true });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (pathname === '/cancelled' && method === 'GET') {
|
|
606
|
+
const file = u.searchParams.get('file');
|
|
607
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
608
|
+
const abs = path.resolve(file);
|
|
609
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
610
|
+
const sess = getSession(abs);
|
|
611
|
+
return send(res, 200, { cancelled: !!sess.cancelRequested });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Fileless check used by the `annotron check` CLI fallback: is any in-flight
|
|
615
|
+
// (actively-working) session currently under a cancel request?
|
|
616
|
+
if (pathname === '/cancel-check' && method === 'GET') {
|
|
617
|
+
for (const [f, s] of sessions) {
|
|
618
|
+
if (s.working && s.cancelRequested) return send(res, 200, { cancelled: true, file: f });
|
|
619
|
+
}
|
|
620
|
+
return send(res, 200, { cancelled: false });
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ── Hook ingest (Claude Code hooks POST their raw stdin JSON here) ──────────
|
|
624
|
+
// PreToolUse: mirror the activity, enforce cancel, and (if remote-approve is
|
|
625
|
+
// on) gate the tool on a browser Allow/Deny. Returns the exact JSON the hook
|
|
626
|
+
// echoes back to Claude Code, or an empty body meaning "defer to normal flow".
|
|
627
|
+
if (pathname === '/hook/pretool' && method === 'POST') {
|
|
628
|
+
const body = await readBody(req);
|
|
629
|
+
const tool = body.tool_name || body.tool || '';
|
|
630
|
+
const input = body.tool_input || body.input || {};
|
|
631
|
+
// Never gate/mirror annotron's OWN CLI calls — the agent must be able to
|
|
632
|
+
// reply/poll/check even while cancelled or while remote-approve is on
|
|
633
|
+
// (gating them would deadlock the poll). Detect the real command string
|
|
634
|
+
// here (server-side JSON parse) rather than fuzzy-matching raw stdin.
|
|
635
|
+
const cmd = typeof input.command === 'string' ? input.command : '';
|
|
636
|
+
if (tool === 'Bash' && /annotron["']?\s+(poll|progress|check|stop|help)\b/.test(cmd)) {
|
|
637
|
+
return send(res, 200, '');
|
|
638
|
+
}
|
|
639
|
+
const found = findActiveSession(body.cwd);
|
|
640
|
+
if (!found) return send(res, 200, ''); // no active review → normal flow
|
|
641
|
+
const [file, sess] = found;
|
|
642
|
+
broadcastSSE(file, 'agent-progress', JSON.stringify({ step: formatActivity(tool, input) }));
|
|
643
|
+
|
|
644
|
+
if (sess.cancelRequested) {
|
|
645
|
+
return send(res, 200, permJSON('deny',
|
|
646
|
+
'annotron: the user cancelled the current review. Stop immediately — do not run further tools. Reply on the artifact (annotron poll <file> --reply "Stopped — cancelled by user.") then wait for new feedback.'));
|
|
647
|
+
}
|
|
648
|
+
if (!sess.remoteApprove) return send(res, 200, '');
|
|
649
|
+
if (sess.autoAllowTools.has(tool)) return send(res, 200, permJSON('allow'));
|
|
650
|
+
|
|
651
|
+
const requestId = 'perm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
652
|
+
const summary = formatActivity(tool, input);
|
|
653
|
+
sess.pendingPerms.set(requestId, { tool, summary });
|
|
654
|
+
broadcastSSE(file, 'permission-request', JSON.stringify({ requestId, tool, summary }));
|
|
655
|
+
const decision = await new Promise(resolve => {
|
|
656
|
+
sess.permWaiters.set(requestId, resolve);
|
|
657
|
+
setTimeout(() => {
|
|
658
|
+
if (sess.permWaiters.has(requestId)) { sess.permWaiters.delete(requestId); resolve(null); }
|
|
659
|
+
}, 170000);
|
|
660
|
+
});
|
|
661
|
+
sess.pendingPerms.delete(requestId);
|
|
662
|
+
broadcastSSE(file, 'permission-resolved', JSON.stringify({ requestId }));
|
|
663
|
+
if (!decision) {
|
|
664
|
+
return send(res, 200, permJSON('ask',
|
|
665
|
+
'annotron: no response from the review UI in time — falling back to the terminal prompt.'));
|
|
666
|
+
}
|
|
667
|
+
if (decision.decision === 'allow' && decision.always) sess.autoAllowTools.add(tool);
|
|
668
|
+
if (decision.decision === 'deny') {
|
|
669
|
+
return send(res, 200, permJSON('deny', decision.reason || 'Denied by the reviewer in annotron.'));
|
|
670
|
+
}
|
|
671
|
+
return send(res, 200, permJSON('allow'));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// PostToolUse: mark the current activity step done.
|
|
675
|
+
if (pathname === '/hook/posttool' && method === 'POST') {
|
|
676
|
+
const body = await readBody(req);
|
|
677
|
+
const found = findActiveSession(body.cwd);
|
|
678
|
+
if (found) broadcastSSE(found[0], 'agent-progress-done', JSON.stringify({ tool: body.tool_name || '' }));
|
|
679
|
+
return send(res, 200, { ok: true });
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Notification: reflect "waiting for permission / idle" in the browser.
|
|
683
|
+
if (pathname === '/hook/notify' && method === 'POST') {
|
|
684
|
+
const body = await readBody(req);
|
|
685
|
+
const found = findSessionByCwd(body.cwd);
|
|
686
|
+
const type = body.notification_type || body.type || '';
|
|
687
|
+
if (found) broadcastSSE(found[0], 'agent-status', JSON.stringify({ type }));
|
|
688
|
+
return send(res, 200, { ok: true });
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Stop: the turn finished → agent is idle, waiting for the next feedback.
|
|
692
|
+
if (pathname === '/hook/stop' && method === 'POST') {
|
|
693
|
+
const body = await readBody(req);
|
|
694
|
+
const found = findSessionByCwd(body.cwd);
|
|
695
|
+
if (found) broadcastSSE(found[0], 'agent-status', JSON.stringify({ type: 'idle' }));
|
|
696
|
+
return send(res, 200, { ok: true });
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ── Remote permission control (from the browser) ────────────────────────────
|
|
700
|
+
if (pathname === '/permission/decision' && method === 'POST') {
|
|
701
|
+
const body = await readBody(req);
|
|
702
|
+
const file = body.file;
|
|
703
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
704
|
+
const abs = path.resolve(file);
|
|
705
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
706
|
+
const sess = getSession(abs);
|
|
707
|
+
const resolve = sess.permWaiters.get(body.requestId);
|
|
708
|
+
if (resolve) {
|
|
709
|
+
sess.permWaiters.delete(body.requestId);
|
|
710
|
+
resolve({
|
|
711
|
+
decision: body.decision === 'deny' ? 'deny' : 'allow',
|
|
712
|
+
always: !!body.always,
|
|
713
|
+
reason: body.reason || '',
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
return send(res, 200, { ok: true });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (pathname === '/permission/mode' && method === 'POST') {
|
|
720
|
+
const body = await readBody(req);
|
|
721
|
+
const file = body.file;
|
|
722
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
723
|
+
const abs = path.resolve(file);
|
|
724
|
+
if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
|
|
725
|
+
const sess = getSession(abs);
|
|
726
|
+
sess.remoteApprove = !!body.enabled;
|
|
727
|
+
if (!sess.remoteApprove) {
|
|
728
|
+
// Turning off: release anything currently waiting so nothing hangs.
|
|
729
|
+
for (const resolve of sess.permWaiters.values()) resolve(null);
|
|
730
|
+
sess.permWaiters.clear();
|
|
731
|
+
sess.pendingPerms.clear();
|
|
732
|
+
}
|
|
733
|
+
if (body.clearAutoAllow) sess.autoAllowTools.clear();
|
|
734
|
+
broadcastSSE(abs, 'permission-mode', JSON.stringify({ enabled: sess.remoteApprove }));
|
|
735
|
+
return send(res, 200, { ok: true, enabled: sess.remoteApprove });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (pathname === '/events' && method === 'GET') {
|
|
739
|
+
const file = u.searchParams.get('file');
|
|
740
|
+
if (!file) return send(res, 400, { error: 'missing file' });
|
|
741
|
+
const abs = path.resolve(file);
|
|
742
|
+
// Allow events even if not in allowList (browser may connect before register)
|
|
743
|
+
res.writeHead(200, {
|
|
744
|
+
'Content-Type': 'text/event-stream',
|
|
745
|
+
'Cache-Control': 'no-cache',
|
|
746
|
+
'Connection': 'keep-alive',
|
|
747
|
+
'Access-Control-Allow-Origin': '*',
|
|
748
|
+
});
|
|
749
|
+
res.write(': connected\n\n');
|
|
750
|
+
const sess = getSession(abs);
|
|
751
|
+
sess.sseClients.add(res);
|
|
752
|
+
req.on('close', () => { sess.sseClients.delete(res); });
|
|
753
|
+
// Keep-alive ping
|
|
754
|
+
const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch { clearInterval(ping); } }, 20000);
|
|
755
|
+
req.on('close', () => clearInterval(ping));
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (pathname === '/stop' && method === 'POST') {
|
|
760
|
+
send(res, 200, { ok: true });
|
|
761
|
+
setTimeout(() => process.exit(0), 100);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
send(res, 404, { error: 'not found' });
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const server = http.createServer(handler);
|
|
769
|
+
server.on('error', (err) => {
|
|
770
|
+
if (err.code === 'EADDRINUSE') {
|
|
771
|
+
// Another server already owns the port — exit quietly
|
|
772
|
+
process.exit(0);
|
|
773
|
+
}
|
|
774
|
+
console.error('[annotron server]', err.message);
|
|
775
|
+
process.exit(1);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
server.listen(PORT, HOST, () => {
|
|
779
|
+
console.log(`annotron server listening on http://${HOST}:${PORT}`);
|
|
780
|
+
});
|