diffprism 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1283 @@
1
+ import {
2
+ getCurrentBranch,
3
+ getDiff,
4
+ listBranches,
5
+ listCommits
6
+ } from "./chunk-QGWYCEJN.js";
7
+ import {
8
+ analyze
9
+ } from "./chunk-DHCVZGHE.js";
10
+
11
+ // packages/core/src/server-file.ts
12
+ import fs from "fs";
13
+ import path from "path";
14
+ import os from "os";
15
+ function serverDir() {
16
+ return path.join(os.homedir(), ".diffprism");
17
+ }
18
+ function serverFilePath() {
19
+ return path.join(serverDir(), "server.json");
20
+ }
21
+ function isPidAlive(pid) {
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+ function writeServerFile(info) {
30
+ const dir = serverDir();
31
+ if (!fs.existsSync(dir)) {
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ }
34
+ fs.writeFileSync(serverFilePath(), JSON.stringify(info, null, 2) + "\n");
35
+ }
36
+ function readServerFile() {
37
+ const filePath = serverFilePath();
38
+ if (!fs.existsSync(filePath)) {
39
+ return null;
40
+ }
41
+ try {
42
+ const raw = fs.readFileSync(filePath, "utf-8");
43
+ const info = JSON.parse(raw);
44
+ if (!isPidAlive(info.pid)) {
45
+ fs.unlinkSync(filePath);
46
+ return null;
47
+ }
48
+ return info;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ function removeServerFile() {
54
+ try {
55
+ const filePath = serverFilePath();
56
+ if (fs.existsSync(filePath)) {
57
+ fs.unlinkSync(filePath);
58
+ }
59
+ } catch {
60
+ }
61
+ }
62
+ async function isServerAlive() {
63
+ const info = readServerFile();
64
+ if (!info) {
65
+ return null;
66
+ }
67
+ try {
68
+ const response = await fetch(`http://localhost:${info.httpPort}/api/status`, {
69
+ signal: AbortSignal.timeout(2e3)
70
+ });
71
+ if (response.ok) {
72
+ return info;
73
+ }
74
+ return null;
75
+ } catch {
76
+ removeServerFile();
77
+ return null;
78
+ }
79
+ }
80
+
81
+ // packages/core/src/server-client.ts
82
+ import { spawn } from "child_process";
83
+ import fs2 from "fs";
84
+ import path2 from "path";
85
+ import os2 from "os";
86
+ import { fileURLToPath } from "url";
87
+ async function ensureServer(options = {}) {
88
+ const existing = await isServerAlive();
89
+ if (existing) {
90
+ return existing;
91
+ }
92
+ const spawnArgs = options.spawnCommand ?? buildDefaultSpawnCommand(options);
93
+ const logDir = path2.join(os2.homedir(), ".diffprism");
94
+ if (!fs2.existsSync(logDir)) {
95
+ fs2.mkdirSync(logDir, { recursive: true });
96
+ }
97
+ const logPath = path2.join(logDir, "server.log");
98
+ const logFd = fs2.openSync(logPath, "a");
99
+ const [cmd, ...args] = spawnArgs;
100
+ const child = spawn(cmd, args, {
101
+ detached: true,
102
+ stdio: ["ignore", logFd, logFd],
103
+ env: { ...process.env }
104
+ });
105
+ child.unref();
106
+ fs2.closeSync(logFd);
107
+ const timeoutMs = options.timeoutMs ?? 15e3;
108
+ const startTime = Date.now();
109
+ while (Date.now() - startTime < timeoutMs) {
110
+ await new Promise((resolve) => setTimeout(resolve, 500));
111
+ const info = await isServerAlive();
112
+ if (info) {
113
+ return info;
114
+ }
115
+ }
116
+ throw new Error(
117
+ `DiffPrism server failed to start within ${timeoutMs / 1e3}s. Check logs at ${logPath}`
118
+ );
119
+ }
120
+ function buildDefaultSpawnCommand(options) {
121
+ const thisFile = fileURLToPath(import.meta.url);
122
+ const thisDir = path2.dirname(thisFile);
123
+ const workspaceRoot = path2.resolve(thisDir, "..", "..", "..");
124
+ const devBin = path2.join(workspaceRoot, "cli", "bin", "diffprism.mjs");
125
+ let binPath = "diffprism";
126
+ if (fs2.existsSync(devBin)) {
127
+ binPath = devBin;
128
+ } else {
129
+ let searchDir = thisDir;
130
+ while (searchDir !== path2.dirname(searchDir)) {
131
+ const candidate = path2.join(
132
+ searchDir,
133
+ "node_modules",
134
+ ".bin",
135
+ "diffprism"
136
+ );
137
+ if (fs2.existsSync(candidate)) {
138
+ binPath = candidate;
139
+ break;
140
+ }
141
+ searchDir = path2.dirname(searchDir);
142
+ }
143
+ }
144
+ const args = [process.execPath, binPath, "server", "--_daemon"];
145
+ if (options.dev) {
146
+ args.push("--dev");
147
+ }
148
+ return args;
149
+ }
150
+ async function submitReviewToServer(serverInfo, diffRef, options = {}) {
151
+ const cwd = options.cwd ?? process.cwd();
152
+ const projectPath = options.projectPath ?? cwd;
153
+ let payload;
154
+ if (options.injectedPayload) {
155
+ payload = options.injectedPayload;
156
+ } else {
157
+ const { getDiff: getDiff2, getCurrentBranch: getCurrentBranch2, detectWorktree } = await import("./src-AMCPIYDZ.js");
158
+ const { analyze: analyze2 } = await import("./src-JMPTSU3P.js");
159
+ const { diffSet, rawDiff } = getDiff2(diffRef, { cwd });
160
+ if (diffSet.files.length === 0) {
161
+ return {
162
+ result: {
163
+ decision: "approved",
164
+ comments: [],
165
+ summary: "No changes to review."
166
+ },
167
+ sessionId: ""
168
+ };
169
+ }
170
+ const briefing = analyze2(diffSet);
171
+ const currentBranch = getCurrentBranch2({ cwd });
172
+ const worktreeInfo = detectWorktree({ cwd });
173
+ payload = {
174
+ reviewId: "",
175
+ // Server assigns the real ID
176
+ diffSet,
177
+ rawDiff,
178
+ briefing,
179
+ metadata: {
180
+ title: options.title,
181
+ description: options.description,
182
+ reasoning: options.reasoning,
183
+ currentBranch,
184
+ worktree: worktreeInfo.isWorktree ? {
185
+ isWorktree: true,
186
+ worktreePath: worktreeInfo.worktreePath,
187
+ mainWorktreePath: worktreeInfo.mainWorktreePath
188
+ } : void 0
189
+ }
190
+ };
191
+ }
192
+ const createResponse = await fetch(
193
+ `http://localhost:${serverInfo.httpPort}/api/reviews`,
194
+ {
195
+ method: "POST",
196
+ headers: { "Content-Type": "application/json" },
197
+ body: JSON.stringify({
198
+ payload,
199
+ projectPath,
200
+ diffRef: options.diffRef ?? diffRef
201
+ })
202
+ }
203
+ );
204
+ if (!createResponse.ok) {
205
+ throw new Error(
206
+ `Global server returned ${createResponse.status} on create`
207
+ );
208
+ }
209
+ const { sessionId } = await createResponse.json();
210
+ if (options.annotations?.length) {
211
+ for (const ann of options.annotations) {
212
+ await fetch(
213
+ `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/annotations`,
214
+ {
215
+ method: "POST",
216
+ headers: { "Content-Type": "application/json" },
217
+ body: JSON.stringify({
218
+ file: ann.file,
219
+ line: ann.line,
220
+ body: ann.body,
221
+ type: ann.type,
222
+ confidence: ann.confidence ?? 1,
223
+ category: ann.category ?? "other",
224
+ source: {
225
+ agent: ann.source_agent ?? "unknown",
226
+ tool: "open_review"
227
+ }
228
+ })
229
+ }
230
+ );
231
+ }
232
+ }
233
+ const pollIntervalMs = 2e3;
234
+ const maxWaitMs = options.timeoutMs ?? 6e5;
235
+ const start = Date.now();
236
+ while (Date.now() - start < maxWaitMs) {
237
+ const resultResponse = await fetch(
238
+ `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/result`
239
+ );
240
+ if (resultResponse.ok) {
241
+ const data = await resultResponse.json();
242
+ if (data.result) {
243
+ return { result: data.result, sessionId };
244
+ }
245
+ }
246
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
247
+ }
248
+ throw new Error("Review timed out waiting for submission.");
249
+ }
250
+
251
+ // packages/core/src/diff-utils.ts
252
+ import { createHash } from "crypto";
253
+ function hashDiff(rawDiff) {
254
+ return createHash("sha256").update(rawDiff).digest("hex");
255
+ }
256
+ function fileKey(file) {
257
+ return file.stage ? `${file.stage}:${file.path}` : file.path;
258
+ }
259
+ function detectChangedFiles(oldDiffSet, newDiffSet) {
260
+ if (!oldDiffSet) {
261
+ return newDiffSet.files.map(fileKey);
262
+ }
263
+ const oldFiles = new Map(
264
+ oldDiffSet.files.map((f) => [fileKey(f), f])
265
+ );
266
+ const changed = [];
267
+ for (const newFile of newDiffSet.files) {
268
+ const key = fileKey(newFile);
269
+ const oldFile = oldFiles.get(key);
270
+ if (!oldFile) {
271
+ changed.push(key);
272
+ } else if (oldFile.additions !== newFile.additions || oldFile.deletions !== newFile.deletions) {
273
+ changed.push(key);
274
+ }
275
+ }
276
+ for (const oldFile of oldDiffSet.files) {
277
+ if (!newDiffSet.files.some((f) => fileKey(f) === fileKey(oldFile))) {
278
+ changed.push(fileKey(oldFile));
279
+ }
280
+ }
281
+ return changed;
282
+ }
283
+
284
+ // packages/core/src/diff-poller.ts
285
+ function createDiffPoller(options) {
286
+ let { diffRef } = options;
287
+ const { cwd, pollInterval, onDiffChanged, onError, silent } = options;
288
+ let lastDiffHash = null;
289
+ let lastDiffSet = null;
290
+ let refreshRequested = false;
291
+ let interval = null;
292
+ let running = false;
293
+ function poll() {
294
+ if (!running) return;
295
+ try {
296
+ const { diffSet: newDiffSet, rawDiff: newRawDiff } = getDiff(diffRef, { cwd });
297
+ const newHash = hashDiff(newRawDiff);
298
+ if (newHash !== lastDiffHash || refreshRequested) {
299
+ refreshRequested = false;
300
+ const newBriefing = analyze(newDiffSet);
301
+ const changedFiles = detectChangedFiles(lastDiffSet, newDiffSet);
302
+ lastDiffHash = newHash;
303
+ lastDiffSet = newDiffSet;
304
+ const updatePayload = {
305
+ diffSet: newDiffSet,
306
+ rawDiff: newRawDiff,
307
+ briefing: newBriefing,
308
+ changedFiles,
309
+ timestamp: Date.now()
310
+ };
311
+ onDiffChanged(updatePayload);
312
+ }
313
+ } catch (err) {
314
+ if (onError && err instanceof Error) {
315
+ onError(err);
316
+ }
317
+ }
318
+ }
319
+ return {
320
+ start() {
321
+ if (running) return;
322
+ running = true;
323
+ try {
324
+ const { diffSet: initialDiffSet, rawDiff: initialRawDiff } = getDiff(diffRef, { cwd });
325
+ lastDiffHash = hashDiff(initialRawDiff);
326
+ lastDiffSet = initialDiffSet;
327
+ } catch {
328
+ }
329
+ interval = setInterval(poll, pollInterval);
330
+ },
331
+ stop() {
332
+ running = false;
333
+ if (interval) {
334
+ clearInterval(interval);
335
+ interval = null;
336
+ }
337
+ },
338
+ setDiffRef(newRef) {
339
+ diffRef = newRef;
340
+ lastDiffHash = null;
341
+ lastDiffSet = null;
342
+ },
343
+ refresh() {
344
+ refreshRequested = true;
345
+ }
346
+ };
347
+ }
348
+
349
+ // packages/core/src/global-server.ts
350
+ import http2 from "http";
351
+ import { randomUUID as randomUUID2 } from "crypto";
352
+ import getPort from "get-port";
353
+ import open from "open";
354
+ import { WebSocketServer, WebSocket } from "ws";
355
+
356
+ // packages/core/src/ui-server.ts
357
+ import http from "http";
358
+ import fs3 from "fs";
359
+ import path3 from "path";
360
+ import { fileURLToPath as fileURLToPath2 } from "url";
361
+ var MIME_TYPES = {
362
+ ".html": "text/html",
363
+ ".js": "application/javascript",
364
+ ".css": "text/css",
365
+ ".json": "application/json",
366
+ ".svg": "image/svg+xml",
367
+ ".png": "image/png",
368
+ ".ico": "image/x-icon",
369
+ ".woff": "font/woff",
370
+ ".woff2": "font/woff2"
371
+ };
372
+ function resolveUiDist() {
373
+ const thisFile = fileURLToPath2(import.meta.url);
374
+ const thisDir = path3.dirname(thisFile);
375
+ const publishedUiDist = path3.resolve(thisDir, "..", "ui-dist");
376
+ if (fs3.existsSync(path3.join(publishedUiDist, "index.html"))) {
377
+ return publishedUiDist;
378
+ }
379
+ const workspaceRoot = path3.resolve(thisDir, "..", "..", "..");
380
+ const devUiDist = path3.join(workspaceRoot, "packages", "ui", "dist");
381
+ if (fs3.existsSync(path3.join(devUiDist, "index.html"))) {
382
+ return devUiDist;
383
+ }
384
+ throw new Error(
385
+ "Could not find built UI. Run 'pnpm -F @diffprism/ui build' first."
386
+ );
387
+ }
388
+ function resolveUiRoot() {
389
+ const thisFile = fileURLToPath2(import.meta.url);
390
+ const thisDir = path3.dirname(thisFile);
391
+ const workspaceRoot = path3.resolve(thisDir, "..", "..", "..");
392
+ const uiRoot = path3.join(workspaceRoot, "packages", "ui");
393
+ if (fs3.existsSync(path3.join(uiRoot, "index.html"))) {
394
+ return uiRoot;
395
+ }
396
+ throw new Error(
397
+ "Could not find UI source directory. Dev mode requires the diffprism workspace."
398
+ );
399
+ }
400
+ async function startViteDevServer(uiRoot, port, silent) {
401
+ const { createServer } = await import("vite");
402
+ const vite = await createServer({
403
+ root: uiRoot,
404
+ server: { port, strictPort: true, open: false },
405
+ logLevel: silent ? "silent" : "warn"
406
+ });
407
+ await vite.listen();
408
+ return vite;
409
+ }
410
+ function createStaticServer(distPath, port) {
411
+ const server = http.createServer((req, res) => {
412
+ const urlPath = req.url?.split("?")[0] ?? "/";
413
+ let filePath = path3.join(distPath, urlPath === "/" ? "index.html" : urlPath);
414
+ if (!fs3.existsSync(filePath)) {
415
+ filePath = path3.join(distPath, "index.html");
416
+ }
417
+ const ext = path3.extname(filePath);
418
+ const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
419
+ try {
420
+ const content = fs3.readFileSync(filePath);
421
+ res.writeHead(200, { "Content-Type": contentType });
422
+ res.end(content);
423
+ } catch {
424
+ res.writeHead(404);
425
+ res.end("Not found");
426
+ }
427
+ });
428
+ return new Promise((resolve, reject) => {
429
+ server.on("error", reject);
430
+ server.listen(port, () => resolve(server));
431
+ });
432
+ }
433
+
434
+ // packages/core/src/review-history.ts
435
+ import fs4 from "fs";
436
+ import path4 from "path";
437
+ import { randomUUID } from "crypto";
438
+ function generateEntryId() {
439
+ return randomUUID();
440
+ }
441
+ function getHistoryPath(projectDir) {
442
+ return path4.join(projectDir, ".diffprism", "history", "reviews.json");
443
+ }
444
+ function readHistory(projectDir) {
445
+ const filePath = getHistoryPath(projectDir);
446
+ if (!fs4.existsSync(filePath)) {
447
+ return { version: 1, entries: [] };
448
+ }
449
+ try {
450
+ const raw = fs4.readFileSync(filePath, "utf-8");
451
+ const parsed = JSON.parse(raw);
452
+ return parsed;
453
+ } catch {
454
+ return { version: 1, entries: [] };
455
+ }
456
+ }
457
+ function appendHistory(projectDir, entry) {
458
+ const filePath = getHistoryPath(projectDir);
459
+ const dir = path4.dirname(filePath);
460
+ if (!fs4.existsSync(dir)) {
461
+ fs4.mkdirSync(dir, { recursive: true });
462
+ }
463
+ const history = readHistory(projectDir);
464
+ history.entries.push(entry);
465
+ history.entries.sort((a, b) => a.timestamp - b.timestamp);
466
+ fs4.writeFileSync(filePath, JSON.stringify(history, null, 2) + "\n");
467
+ }
468
+ function getRecentHistory(projectDir, limit = 50) {
469
+ const history = readHistory(projectDir);
470
+ return history.entries.slice(-limit);
471
+ }
472
+
473
+ // packages/core/src/global-server.ts
474
+ var SUBMITTED_TTL_MS = 5 * 60 * 1e3;
475
+ var ABANDONED_TTL_MS = 60 * 60 * 1e3;
476
+ var CLEANUP_INTERVAL_MS = 60 * 1e3;
477
+ var sessions = /* @__PURE__ */ new Map();
478
+ var clientSessions = /* @__PURE__ */ new Map();
479
+ var sessionWatchers = /* @__PURE__ */ new Map();
480
+ var serverPollInterval = 2e3;
481
+ var reopenBrowserIfNeeded = null;
482
+ function toSummary(session) {
483
+ const { payload } = session;
484
+ const fileCount = payload.diffSet.files.length;
485
+ let additions = 0;
486
+ let deletions = 0;
487
+ for (const file of payload.diffSet.files) {
488
+ additions += file.additions;
489
+ deletions += file.deletions;
490
+ }
491
+ return {
492
+ id: session.id,
493
+ projectPath: session.projectPath,
494
+ branch: payload.metadata.currentBranch,
495
+ title: payload.metadata.title,
496
+ fileCount,
497
+ additions,
498
+ deletions,
499
+ status: session.status,
500
+ decision: session.result?.decision,
501
+ createdAt: session.createdAt,
502
+ hasNewChanges: session.hasNewChanges
503
+ };
504
+ }
505
+ function readBody(req) {
506
+ return new Promise((resolve, reject) => {
507
+ let body = "";
508
+ req.on("data", (chunk) => {
509
+ body += chunk.toString();
510
+ });
511
+ req.on("end", () => resolve(body));
512
+ req.on("error", reject);
513
+ });
514
+ }
515
+ function jsonResponse(res, status, data) {
516
+ res.writeHead(status, { "Content-Type": "application/json" });
517
+ res.end(JSON.stringify(data));
518
+ }
519
+ function matchRoute(method, url, expectedMethod, pattern) {
520
+ if (method !== expectedMethod) return null;
521
+ const patternParts = pattern.split("/");
522
+ const urlParts = url.split("/");
523
+ if (patternParts.length !== urlParts.length) return null;
524
+ const params = {};
525
+ for (let i = 0; i < patternParts.length; i++) {
526
+ if (patternParts[i].startsWith(":")) {
527
+ params[patternParts[i].slice(1)] = urlParts[i];
528
+ } else if (patternParts[i] !== urlParts[i]) {
529
+ return null;
530
+ }
531
+ }
532
+ return params;
533
+ }
534
+ var wss = null;
535
+ function broadcastToAll(msg) {
536
+ if (!wss) return;
537
+ const data = JSON.stringify(msg);
538
+ for (const client of wss.clients) {
539
+ if (client.readyState === WebSocket.OPEN) {
540
+ client.send(data);
541
+ }
542
+ }
543
+ }
544
+ function sendToSessionClients(sessionId, msg) {
545
+ if (!wss) return;
546
+ const data = JSON.stringify(msg);
547
+ for (const [client, sid] of clientSessions.entries()) {
548
+ if (sid === sessionId && client.readyState === WebSocket.OPEN) {
549
+ client.send(data);
550
+ }
551
+ }
552
+ }
553
+ function broadcastSessionUpdate(session) {
554
+ broadcastToAll({
555
+ type: "session:updated",
556
+ payload: toSummary(session)
557
+ });
558
+ }
559
+ function broadcastSessionRemoved(sessionId) {
560
+ for (const [client, sid] of clientSessions.entries()) {
561
+ if (sid === sessionId) {
562
+ clientSessions.delete(client);
563
+ }
564
+ }
565
+ broadcastToAll({
566
+ type: "session:removed",
567
+ payload: { sessionId }
568
+ });
569
+ }
570
+ function hasViewersForSession(sessionId) {
571
+ for (const [client, sid] of clientSessions.entries()) {
572
+ if (sid === sessionId && client.readyState === WebSocket.OPEN) {
573
+ return true;
574
+ }
575
+ }
576
+ return false;
577
+ }
578
+ function startSessionWatcher(sessionId) {
579
+ if (sessionWatchers.has(sessionId)) return;
580
+ const session = sessions.get(sessionId);
581
+ if (!session?.diffRef) return;
582
+ const poller = createDiffPoller({
583
+ diffRef: session.diffRef,
584
+ cwd: session.projectPath,
585
+ pollInterval: serverPollInterval,
586
+ onDiffChanged: (updatePayload) => {
587
+ const s = sessions.get(sessionId);
588
+ if (!s) return;
589
+ s.payload = {
590
+ ...s.payload,
591
+ diffSet: updatePayload.diffSet,
592
+ rawDiff: updatePayload.rawDiff,
593
+ briefing: updatePayload.briefing
594
+ };
595
+ s.lastDiffHash = hashDiff(updatePayload.rawDiff);
596
+ s.lastDiffSet = updatePayload.diffSet;
597
+ if (hasViewersForSession(sessionId)) {
598
+ sendToSessionClients(sessionId, {
599
+ type: "diff:update",
600
+ payload: updatePayload
601
+ });
602
+ s.hasNewChanges = false;
603
+ } else {
604
+ s.hasNewChanges = true;
605
+ broadcastSessionList();
606
+ }
607
+ }
608
+ });
609
+ poller.start();
610
+ sessionWatchers.set(sessionId, poller);
611
+ }
612
+ function stopSessionWatcher(sessionId) {
613
+ const poller = sessionWatchers.get(sessionId);
614
+ if (poller) {
615
+ poller.stop();
616
+ sessionWatchers.delete(sessionId);
617
+ }
618
+ }
619
+ function startAllWatchers() {
620
+ for (const [id, session] of sessions.entries()) {
621
+ if (session.diffRef && !sessionWatchers.has(id)) {
622
+ startSessionWatcher(id);
623
+ }
624
+ }
625
+ }
626
+ function stopAllWatchers() {
627
+ for (const [, poller] of sessionWatchers.entries()) {
628
+ poller.stop();
629
+ }
630
+ sessionWatchers.clear();
631
+ }
632
+ function hasConnectedClients() {
633
+ if (!wss) return false;
634
+ for (const client of wss.clients) {
635
+ if (client.readyState === WebSocket.OPEN) return true;
636
+ }
637
+ return false;
638
+ }
639
+ function broadcastSessionList() {
640
+ const summaries = Array.from(sessions.values()).map(toSummary);
641
+ broadcastToAll({ type: "session:list", payload: summaries });
642
+ }
643
+ function recordReviewHistory(session, result) {
644
+ if (session.projectPath.startsWith("github:")) return;
645
+ try {
646
+ const { payload } = session;
647
+ const entry = {
648
+ id: generateEntryId(),
649
+ timestamp: Date.now(),
650
+ diffRef: session.diffRef ?? "unknown",
651
+ decision: result.decision,
652
+ filesReviewed: payload.diffSet.files.length,
653
+ additions: payload.diffSet.files.reduce((sum, f) => sum + f.additions, 0),
654
+ deletions: payload.diffSet.files.reduce((sum, f) => sum + f.deletions, 0),
655
+ commentCount: result.comments.length,
656
+ branch: payload.metadata.currentBranch,
657
+ title: payload.metadata.title,
658
+ summary: result.summary ?? payload.briefing.summary
659
+ };
660
+ appendHistory(session.projectPath, entry);
661
+ } catch {
662
+ }
663
+ }
664
+ async function handleApiRequest(req, res) {
665
+ const method = req.method ?? "GET";
666
+ const url = (req.url ?? "/").split("?")[0];
667
+ res.setHeader("Access-Control-Allow-Origin", "*");
668
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
669
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
670
+ if (method === "OPTIONS") {
671
+ res.writeHead(204);
672
+ res.end();
673
+ return true;
674
+ }
675
+ if (!url.startsWith("/api/")) {
676
+ return false;
677
+ }
678
+ if (method === "GET" && url === "/api/status") {
679
+ jsonResponse(res, 200, {
680
+ running: true,
681
+ pid: process.pid,
682
+ sessions: sessions.size,
683
+ uptime: process.uptime()
684
+ });
685
+ return true;
686
+ }
687
+ if (method === "POST" && url === "/api/reviews") {
688
+ try {
689
+ const body = await readBody(req);
690
+ const { payload, projectPath, diffRef } = JSON.parse(body);
691
+ let existingSession;
692
+ for (const session of sessions.values()) {
693
+ if (session.projectPath === projectPath) {
694
+ existingSession = session;
695
+ break;
696
+ }
697
+ }
698
+ if (existingSession) {
699
+ const sessionId = existingSession.id;
700
+ payload.reviewId = sessionId;
701
+ if (diffRef) {
702
+ payload.watchMode = true;
703
+ }
704
+ stopSessionWatcher(sessionId);
705
+ existingSession.payload = payload;
706
+ existingSession.status = "pending";
707
+ existingSession.result = null;
708
+ existingSession.createdAt = Date.now();
709
+ existingSession.diffRef = diffRef;
710
+ existingSession.lastDiffHash = diffRef ? hashDiff(payload.rawDiff) : void 0;
711
+ existingSession.lastDiffSet = diffRef ? payload.diffSet : void 0;
712
+ existingSession.hasNewChanges = false;
713
+ existingSession.annotations = [];
714
+ if (diffRef) {
715
+ startSessionWatcher(sessionId);
716
+ }
717
+ if (hasViewersForSession(sessionId)) {
718
+ sendToSessionClients(sessionId, {
719
+ type: "review:init",
720
+ payload
721
+ });
722
+ }
723
+ broadcastSessionUpdate(existingSession);
724
+ reopenBrowserIfNeeded?.();
725
+ jsonResponse(res, 200, { sessionId });
726
+ } else {
727
+ const sessionId = `session-${randomUUID2().slice(0, 8)}`;
728
+ payload.reviewId = sessionId;
729
+ if (diffRef) {
730
+ payload.watchMode = true;
731
+ }
732
+ const session = {
733
+ id: sessionId,
734
+ payload,
735
+ projectPath,
736
+ status: "pending",
737
+ createdAt: Date.now(),
738
+ result: null,
739
+ diffRef,
740
+ lastDiffHash: diffRef ? hashDiff(payload.rawDiff) : void 0,
741
+ lastDiffSet: diffRef ? payload.diffSet : void 0,
742
+ hasNewChanges: false,
743
+ annotations: []
744
+ };
745
+ sessions.set(sessionId, session);
746
+ if (diffRef) {
747
+ startSessionWatcher(sessionId);
748
+ }
749
+ broadcastToAll({
750
+ type: "session:added",
751
+ payload: toSummary(session)
752
+ });
753
+ reopenBrowserIfNeeded?.();
754
+ jsonResponse(res, 201, { sessionId });
755
+ }
756
+ } catch {
757
+ jsonResponse(res, 400, { error: "Invalid request body" });
758
+ }
759
+ return true;
760
+ }
761
+ if (method === "GET" && url === "/api/reviews") {
762
+ const summaries = Array.from(sessions.values()).map(toSummary);
763
+ jsonResponse(res, 200, { sessions: summaries });
764
+ return true;
765
+ }
766
+ const getReviewParams = matchRoute(method, url, "GET", "/api/reviews/:id");
767
+ if (getReviewParams) {
768
+ const session = sessions.get(getReviewParams.id);
769
+ if (!session) {
770
+ jsonResponse(res, 404, { error: "Session not found" });
771
+ return true;
772
+ }
773
+ jsonResponse(res, 200, toSummary(session));
774
+ return true;
775
+ }
776
+ const postResultParams = matchRoute(method, url, "POST", "/api/reviews/:id/result");
777
+ if (postResultParams) {
778
+ const session = sessions.get(postResultParams.id);
779
+ if (!session) {
780
+ jsonResponse(res, 404, { error: "Session not found" });
781
+ return true;
782
+ }
783
+ try {
784
+ const body = await readBody(req);
785
+ const result = JSON.parse(body);
786
+ session.result = result;
787
+ session.status = "submitted";
788
+ recordReviewHistory(session, result);
789
+ if (result.decision === "dismissed") {
790
+ broadcastSessionRemoved(postResultParams.id);
791
+ } else {
792
+ broadcastSessionUpdate(session);
793
+ }
794
+ jsonResponse(res, 200, { ok: true });
795
+ } catch {
796
+ jsonResponse(res, 400, { error: "Invalid request body" });
797
+ }
798
+ return true;
799
+ }
800
+ const getResultParams = matchRoute(method, url, "GET", "/api/reviews/:id/result");
801
+ if (getResultParams) {
802
+ const session = sessions.get(getResultParams.id);
803
+ if (!session) {
804
+ jsonResponse(res, 404, { error: "Session not found" });
805
+ return true;
806
+ }
807
+ if (session.result) {
808
+ jsonResponse(res, 200, { result: session.result, status: "submitted" });
809
+ } else {
810
+ jsonResponse(res, 200, { result: null, status: session.status });
811
+ }
812
+ return true;
813
+ }
814
+ const postContextParams = matchRoute(method, url, "POST", "/api/reviews/:id/context");
815
+ if (postContextParams) {
816
+ const session = sessions.get(postContextParams.id);
817
+ if (!session) {
818
+ jsonResponse(res, 404, { error: "Session not found" });
819
+ return true;
820
+ }
821
+ try {
822
+ const body = await readBody(req);
823
+ const contextPayload = JSON.parse(body);
824
+ if (contextPayload.reasoning !== void 0) {
825
+ session.payload.metadata.reasoning = contextPayload.reasoning;
826
+ }
827
+ if (contextPayload.title !== void 0) {
828
+ session.payload.metadata.title = contextPayload.title;
829
+ }
830
+ if (contextPayload.description !== void 0) {
831
+ session.payload.metadata.description = contextPayload.description;
832
+ }
833
+ sendToSessionClients(session.id, {
834
+ type: "context:update",
835
+ payload: contextPayload
836
+ });
837
+ jsonResponse(res, 200, { ok: true });
838
+ } catch {
839
+ jsonResponse(res, 400, { error: "Invalid request body" });
840
+ }
841
+ return true;
842
+ }
843
+ const postAnnotationParams = matchRoute(method, url, "POST", "/api/reviews/:id/annotations");
844
+ if (postAnnotationParams) {
845
+ const session = sessions.get(postAnnotationParams.id);
846
+ if (!session) {
847
+ jsonResponse(res, 404, { error: "Session not found" });
848
+ return true;
849
+ }
850
+ try {
851
+ const body = await readBody(req);
852
+ const { file, line, body: annotationBody, type, confidence, category, source } = JSON.parse(body);
853
+ const annotation = {
854
+ id: randomUUID2(),
855
+ sessionId: session.id,
856
+ file,
857
+ line,
858
+ body: annotationBody,
859
+ type,
860
+ confidence: confidence ?? 1,
861
+ category: category ?? "other",
862
+ source,
863
+ createdAt: Date.now()
864
+ };
865
+ session.annotations.push(annotation);
866
+ sendToSessionClients(session.id, {
867
+ type: "annotation:added",
868
+ payload: annotation
869
+ });
870
+ jsonResponse(res, 200, { annotationId: annotation.id });
871
+ } catch {
872
+ jsonResponse(res, 400, { error: "Invalid request body" });
873
+ }
874
+ return true;
875
+ }
876
+ const getAnnotationsParams = matchRoute(method, url, "GET", "/api/reviews/:id/annotations");
877
+ if (getAnnotationsParams) {
878
+ const session = sessions.get(getAnnotationsParams.id);
879
+ if (!session) {
880
+ jsonResponse(res, 404, { error: "Session not found" });
881
+ return true;
882
+ }
883
+ jsonResponse(res, 200, { annotations: session.annotations });
884
+ return true;
885
+ }
886
+ const dismissAnnotationParams = matchRoute(method, url, "POST", "/api/reviews/:id/annotations/:annotationId/dismiss");
887
+ if (dismissAnnotationParams) {
888
+ const session = sessions.get(dismissAnnotationParams.id);
889
+ if (!session) {
890
+ jsonResponse(res, 404, { error: "Session not found" });
891
+ return true;
892
+ }
893
+ const annotation = session.annotations.find((a) => a.id === dismissAnnotationParams.annotationId);
894
+ if (!annotation) {
895
+ jsonResponse(res, 404, { error: "Annotation not found" });
896
+ return true;
897
+ }
898
+ annotation.dismissed = true;
899
+ sendToSessionClients(dismissAnnotationParams.id, {
900
+ type: "annotation:dismissed",
901
+ payload: { annotationId: dismissAnnotationParams.annotationId }
902
+ });
903
+ jsonResponse(res, 200, { ok: true });
904
+ return true;
905
+ }
906
+ const deleteParams = matchRoute(method, url, "DELETE", "/api/reviews/:id");
907
+ if (deleteParams) {
908
+ stopSessionWatcher(deleteParams.id);
909
+ if (sessions.delete(deleteParams.id)) {
910
+ broadcastSessionRemoved(deleteParams.id);
911
+ jsonResponse(res, 200, { ok: true });
912
+ } else {
913
+ jsonResponse(res, 404, { error: "Session not found" });
914
+ }
915
+ return true;
916
+ }
917
+ const getRefsParams = matchRoute(method, url, "GET", "/api/reviews/:id/refs");
918
+ if (getRefsParams) {
919
+ const session = sessions.get(getRefsParams.id);
920
+ if (!session) {
921
+ jsonResponse(res, 404, { error: "Session not found" });
922
+ return true;
923
+ }
924
+ if (session.projectPath.startsWith("github:")) {
925
+ jsonResponse(res, 400, { error: "Ref listing not available for GitHub PRs" });
926
+ return true;
927
+ }
928
+ try {
929
+ const branches = listBranches({ cwd: session.projectPath });
930
+ const commits = listCommits({ cwd: session.projectPath });
931
+ const currentBranch = getCurrentBranch({ cwd: session.projectPath });
932
+ jsonResponse(res, 200, { branches, commits, currentBranch });
933
+ } catch {
934
+ jsonResponse(res, 500, { error: "Failed to list git refs" });
935
+ }
936
+ return true;
937
+ }
938
+ const postCompareParams = matchRoute(method, url, "POST", "/api/reviews/:id/compare");
939
+ if (postCompareParams) {
940
+ const session = sessions.get(postCompareParams.id);
941
+ if (!session) {
942
+ jsonResponse(res, 404, { error: "Session not found" });
943
+ return true;
944
+ }
945
+ if (session.projectPath.startsWith("github:")) {
946
+ jsonResponse(res, 400, { error: "Ref comparison not available for GitHub PRs" });
947
+ return true;
948
+ }
949
+ try {
950
+ const body = await readBody(req);
951
+ const { ref } = JSON.parse(body);
952
+ if (!ref) {
953
+ jsonResponse(res, 400, { error: "Missing ref in request body" });
954
+ return true;
955
+ }
956
+ const { diffSet: newDiffSet, rawDiff: newRawDiff } = getDiff(ref, {
957
+ cwd: session.projectPath
958
+ });
959
+ const newBriefing = analyze(newDiffSet);
960
+ const changedFiles = detectChangedFiles(session.lastDiffSet ?? null, newDiffSet);
961
+ session.payload = {
962
+ ...session.payload,
963
+ diffSet: newDiffSet,
964
+ rawDiff: newRawDiff,
965
+ briefing: newBriefing
966
+ };
967
+ session.lastDiffHash = hashDiff(newRawDiff);
968
+ session.lastDiffSet = newDiffSet;
969
+ stopSessionWatcher(session.id);
970
+ session.diffRef = ref;
971
+ if (hasConnectedClients()) {
972
+ startSessionWatcher(session.id);
973
+ }
974
+ sendToSessionClients(session.id, {
975
+ type: "diff:update",
976
+ payload: {
977
+ diffSet: newDiffSet,
978
+ rawDiff: newRawDiff,
979
+ briefing: newBriefing,
980
+ changedFiles,
981
+ timestamp: Date.now()
982
+ }
983
+ });
984
+ jsonResponse(res, 200, { ok: true, fileCount: newDiffSet.files.length });
985
+ } catch {
986
+ jsonResponse(res, 400, { error: "Failed to compute diff for the given ref" });
987
+ }
988
+ return true;
989
+ }
990
+ const getSessionHistoryParams = matchRoute(method, url, "GET", "/api/reviews/:id/history");
991
+ if (getSessionHistoryParams) {
992
+ const session = sessions.get(getSessionHistoryParams.id);
993
+ if (!session) {
994
+ jsonResponse(res, 404, { error: "Session not found" });
995
+ return true;
996
+ }
997
+ if (session.projectPath.startsWith("github:")) {
998
+ jsonResponse(res, 200, { history: [] });
999
+ return true;
1000
+ }
1001
+ const history = getRecentHistory(session.projectPath);
1002
+ jsonResponse(res, 200, { history });
1003
+ return true;
1004
+ }
1005
+ if (method === "GET" && req.url) {
1006
+ const parsedUrl = new URL(req.url, "http://localhost");
1007
+ if (parsedUrl.pathname === "/api/history") {
1008
+ const projectPath = parsedUrl.searchParams.get("project");
1009
+ if (!projectPath) {
1010
+ jsonResponse(res, 400, { error: "Missing required query parameter: project" });
1011
+ return true;
1012
+ }
1013
+ if (projectPath.startsWith("github:")) {
1014
+ jsonResponse(res, 200, { history: [] });
1015
+ return true;
1016
+ }
1017
+ const history = getRecentHistory(projectPath);
1018
+ jsonResponse(res, 200, { history });
1019
+ return true;
1020
+ }
1021
+ }
1022
+ jsonResponse(res, 404, { error: "Not found" });
1023
+ return true;
1024
+ }
1025
+ async function startGlobalServer(options = {}) {
1026
+ const {
1027
+ httpPort: preferredHttpPort = 24680,
1028
+ wsPort: preferredWsPort = 24681,
1029
+ silent = false,
1030
+ dev = false,
1031
+ pollInterval = 2e3,
1032
+ openBrowser = true
1033
+ } = options;
1034
+ serverPollInterval = pollInterval;
1035
+ const [httpPort, wsPort] = await Promise.all([
1036
+ getPort({ port: preferredHttpPort }),
1037
+ getPort({ port: preferredWsPort })
1038
+ ]);
1039
+ let uiPort;
1040
+ let uiHttpServer = null;
1041
+ let viteServer = null;
1042
+ if (dev) {
1043
+ uiPort = await getPort();
1044
+ const uiRoot = resolveUiRoot();
1045
+ viteServer = await startViteDevServer(uiRoot, uiPort, silent);
1046
+ } else {
1047
+ uiPort = await getPort();
1048
+ const uiDist = resolveUiDist();
1049
+ uiHttpServer = await createStaticServer(uiDist, uiPort);
1050
+ }
1051
+ const httpServer = http2.createServer(async (req, res) => {
1052
+ const handled = await handleApiRequest(req, res);
1053
+ if (!handled) {
1054
+ res.writeHead(404);
1055
+ res.end("Not found");
1056
+ }
1057
+ });
1058
+ wss = new WebSocketServer({ port: wsPort });
1059
+ wss.on("connection", (ws, req) => {
1060
+ startAllWatchers();
1061
+ const url = new URL(req.url ?? "/", `http://localhost:${wsPort}`);
1062
+ const sessionId = url.searchParams.get("sessionId");
1063
+ if (sessionId) {
1064
+ clientSessions.set(ws, sessionId);
1065
+ const session = sessions.get(sessionId);
1066
+ if (session) {
1067
+ session.status = "in_review";
1068
+ session.hasNewChanges = false;
1069
+ broadcastSessionUpdate(session);
1070
+ const msg = {
1071
+ type: "review:init",
1072
+ payload: session.payload
1073
+ };
1074
+ ws.send(JSON.stringify(msg));
1075
+ for (const annotation of session.annotations) {
1076
+ ws.send(JSON.stringify({
1077
+ type: "annotation:added",
1078
+ payload: annotation
1079
+ }));
1080
+ }
1081
+ }
1082
+ } else {
1083
+ const summaries = Array.from(sessions.values()).map(toSummary);
1084
+ const msg = {
1085
+ type: "session:list",
1086
+ payload: summaries
1087
+ };
1088
+ ws.send(JSON.stringify(msg));
1089
+ if (summaries.length === 1) {
1090
+ const session = sessions.get(summaries[0].id);
1091
+ if (session) {
1092
+ clientSessions.set(ws, session.id);
1093
+ session.status = "in_review";
1094
+ session.hasNewChanges = false;
1095
+ broadcastSessionUpdate(session);
1096
+ ws.send(JSON.stringify({
1097
+ type: "review:init",
1098
+ payload: session.payload
1099
+ }));
1100
+ for (const annotation of session.annotations) {
1101
+ ws.send(JSON.stringify({
1102
+ type: "annotation:added",
1103
+ payload: annotation
1104
+ }));
1105
+ }
1106
+ }
1107
+ }
1108
+ }
1109
+ ws.on("message", (data) => {
1110
+ try {
1111
+ const msg = JSON.parse(data.toString());
1112
+ if (msg.type === "review:submit") {
1113
+ const sid = clientSessions.get(ws);
1114
+ if (sid) {
1115
+ const session = sessions.get(sid);
1116
+ if (session) {
1117
+ session.result = msg.payload;
1118
+ session.status = "submitted";
1119
+ recordReviewHistory(session, msg.payload);
1120
+ if (msg.payload.decision === "dismissed") {
1121
+ broadcastSessionRemoved(sid);
1122
+ } else {
1123
+ broadcastSessionUpdate(session);
1124
+ }
1125
+ }
1126
+ }
1127
+ } else if (msg.type === "session:select") {
1128
+ const session = sessions.get(msg.payload.sessionId);
1129
+ if (session) {
1130
+ clientSessions.set(ws, session.id);
1131
+ session.status = "in_review";
1132
+ session.hasNewChanges = false;
1133
+ startSessionWatcher(session.id);
1134
+ broadcastSessionUpdate(session);
1135
+ ws.send(JSON.stringify({
1136
+ type: "review:init",
1137
+ payload: session.payload
1138
+ }));
1139
+ for (const annotation of session.annotations) {
1140
+ ws.send(JSON.stringify({
1141
+ type: "annotation:added",
1142
+ payload: annotation
1143
+ }));
1144
+ }
1145
+ }
1146
+ } else if (msg.type === "session:close") {
1147
+ const closedId = msg.payload.sessionId;
1148
+ stopSessionWatcher(closedId);
1149
+ const closedSession = sessions.get(closedId);
1150
+ if (closedSession && !closedSession.result) {
1151
+ closedSession.result = { decision: "dismissed", comments: [] };
1152
+ closedSession.status = "submitted";
1153
+ }
1154
+ broadcastSessionRemoved(closedId);
1155
+ } else if (msg.type === "diff:change_ref") {
1156
+ const sid = clientSessions.get(ws);
1157
+ if (sid) {
1158
+ const session = sessions.get(sid);
1159
+ if (session) {
1160
+ const newRef = msg.payload.diffRef;
1161
+ try {
1162
+ const { diffSet: newDiffSet, rawDiff: newRawDiff } = getDiff(newRef, {
1163
+ cwd: session.projectPath
1164
+ });
1165
+ const newBriefing = analyze(newDiffSet);
1166
+ session.payload = {
1167
+ ...session.payload,
1168
+ diffSet: newDiffSet,
1169
+ rawDiff: newRawDiff,
1170
+ briefing: newBriefing
1171
+ };
1172
+ session.diffRef = newRef;
1173
+ session.lastDiffHash = hashDiff(newRawDiff);
1174
+ session.lastDiffSet = newDiffSet;
1175
+ stopSessionWatcher(sid);
1176
+ startSessionWatcher(sid);
1177
+ sendToSessionClients(sid, {
1178
+ type: "diff:update",
1179
+ payload: {
1180
+ diffSet: newDiffSet,
1181
+ rawDiff: newRawDiff,
1182
+ briefing: newBriefing,
1183
+ changedFiles: newDiffSet.files.map((f) => f.path),
1184
+ timestamp: Date.now()
1185
+ }
1186
+ });
1187
+ } catch (err) {
1188
+ const errorMsg = {
1189
+ type: "diff:error",
1190
+ payload: {
1191
+ error: err instanceof Error ? err.message : String(err)
1192
+ }
1193
+ };
1194
+ ws.send(JSON.stringify(errorMsg));
1195
+ }
1196
+ }
1197
+ }
1198
+ }
1199
+ } catch {
1200
+ }
1201
+ });
1202
+ ws.on("close", () => {
1203
+ clientSessions.delete(ws);
1204
+ });
1205
+ });
1206
+ await new Promise((resolve, reject) => {
1207
+ httpServer.on("error", reject);
1208
+ httpServer.listen(httpPort, () => resolve());
1209
+ });
1210
+ function cleanupExpiredSessions() {
1211
+ const now = Date.now();
1212
+ for (const [id, session] of sessions.entries()) {
1213
+ const age = now - session.createdAt;
1214
+ const expired = session.status === "submitted" && age > SUBMITTED_TTL_MS || session.status === "pending" && age > ABANDONED_TTL_MS;
1215
+ if (expired) {
1216
+ stopSessionWatcher(id);
1217
+ sessions.delete(id);
1218
+ broadcastSessionRemoved(id);
1219
+ }
1220
+ }
1221
+ }
1222
+ const cleanupTimer = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
1223
+ const serverInfo = {
1224
+ httpPort,
1225
+ wsPort,
1226
+ pid: process.pid,
1227
+ startedAt: Date.now()
1228
+ };
1229
+ writeServerFile(serverInfo);
1230
+ if (!silent) {
1231
+ console.log(`
1232
+ DiffPrism Global Server`);
1233
+ console.log(` API: http://localhost:${httpPort}`);
1234
+ console.log(` WS: ws://localhost:${wsPort}`);
1235
+ console.log(` UI: http://localhost:${uiPort}`);
1236
+ console.log(` PID: ${process.pid}`);
1237
+ console.log(`
1238
+ Waiting for reviews...
1239
+ `);
1240
+ }
1241
+ const uiUrl = `http://localhost:${uiPort}?wsPort=${wsPort}&httpPort=${httpPort}&serverMode=true`;
1242
+ if (openBrowser) {
1243
+ await open(uiUrl);
1244
+ }
1245
+ reopenBrowserIfNeeded = () => {
1246
+ if (!hasConnectedClients()) {
1247
+ open(uiUrl);
1248
+ }
1249
+ };
1250
+ async function stop() {
1251
+ clearInterval(cleanupTimer);
1252
+ stopAllWatchers();
1253
+ if (wss) {
1254
+ for (const client of wss.clients) {
1255
+ client.close();
1256
+ }
1257
+ wss.close();
1258
+ wss = null;
1259
+ }
1260
+ clientSessions.clear();
1261
+ sessions.clear();
1262
+ reopenBrowserIfNeeded = null;
1263
+ await new Promise((resolve) => {
1264
+ httpServer.close(() => resolve());
1265
+ });
1266
+ if (viteServer) {
1267
+ await viteServer.close();
1268
+ }
1269
+ if (uiHttpServer) {
1270
+ uiHttpServer.close();
1271
+ }
1272
+ removeServerFile();
1273
+ }
1274
+ return { httpPort, wsPort, stop };
1275
+ }
1276
+
1277
+ export {
1278
+ readServerFile,
1279
+ isServerAlive,
1280
+ startGlobalServer,
1281
+ ensureServer,
1282
+ submitReviewToServer
1283
+ };