diffprism 0.48.2 → 1.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.
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  getCurrentBranch,
3
3
  getDiff,
4
+ getRepoRoot,
4
5
  listBranches,
5
6
  listCommits
6
- } from "./chunk-QGWYCEJN.js";
7
+ } from "./chunk-3GMPE2ZR.js";
7
8
  import {
8
9
  analyze
9
10
  } from "./chunk-DHCVZGHE.js";
@@ -78,24 +79,162 @@ async function isServerAlive() {
78
79
  }
79
80
  }
80
81
 
81
- // packages/core/src/server-client.ts
82
- import { spawn } from "child_process";
82
+ // packages/core/src/diff-scope.ts
83
+ var DEFAULT_DIFF_REF = "working-copy";
84
+ var COMMIT_GATE_DIFF_REF = "staged";
85
+ var DIFF_REF_DESCRIPTION = 'Which changes to review. "working-copy" (the default): everything not yet committed, staged and unstaged shown as separate groups. "staged": only what the next commit would contain. "unstaged": only edits not yet staged. Or a ref range such as "HEAD~3..HEAD" or "main..feature".';
86
+
87
+ // packages/core/src/build-info.ts
83
88
  import fs2 from "fs";
84
89
  import path2 from "path";
85
- import os2 from "os";
86
90
  import { fileURLToPath } from "url";
91
+ function getBuildInfo() {
92
+ let dir = path2.dirname(fileURLToPath(import.meta.url));
93
+ while (dir !== path2.dirname(dir)) {
94
+ const manifest = path2.join(dir, "package.json");
95
+ if (fs2.existsSync(manifest) && isOwnManifest(manifest)) {
96
+ return fs2.existsSync(path2.join(dir, ".git")) ? { dev: true, root: dir } : { dev: false, root: null };
97
+ }
98
+ dir = path2.dirname(dir);
99
+ }
100
+ return { dev: false, root: null };
101
+ }
102
+ function isOwnManifest(manifestPath) {
103
+ try {
104
+ const manifest = JSON.parse(fs2.readFileSync(manifestPath, "utf8"));
105
+ return manifest.name === "diffprism";
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+ function describeVersion(version) {
111
+ const info = getBuildInfo();
112
+ return info.dev ? `${version} (dev build \u2014 ${info.root})` : version;
113
+ }
114
+
115
+ // packages/core/src/feedback.ts
116
+ import fs3 from "fs";
117
+ import os2 from "os";
118
+ import path3 from "path";
119
+ var ISSUES_NEW_URL = "https://github.com/CodeJonesW/diffprism/issues/new";
120
+ var MAX_ERROR_CHARS = 1500;
121
+ function currentVersion() {
122
+ return true ? "1.0.0" : "0.0.0-dev";
123
+ }
124
+ function describeEnvironment() {
125
+ return {
126
+ version: currentVersion(),
127
+ // Whether it is a dev build, but not where: the checkout path is private.
128
+ build: getBuildInfo().dev ? "dev build" : "release",
129
+ os: `${os2.type()} ${os2.release()} (${os2.arch()})`,
130
+ node: process.version
131
+ };
132
+ }
133
+ function lastErrorFile() {
134
+ return path3.join(os2.homedir(), ".diffprism", "last-error.json");
135
+ }
136
+ function recordError(command, err) {
137
+ const report = {
138
+ command,
139
+ message: err instanceof Error ? err.message : String(err),
140
+ at: (/* @__PURE__ */ new Date()).toISOString()
141
+ };
142
+ try {
143
+ fs3.mkdirSync(path3.dirname(lastErrorFile()), { recursive: true });
144
+ fs3.writeFileSync(lastErrorFile(), JSON.stringify(report, null, 2));
145
+ } catch {
146
+ }
147
+ }
148
+ function readLastError() {
149
+ try {
150
+ return JSON.parse(fs3.readFileSync(lastErrorFile(), "utf8"));
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+ var REPORT_HINT = "Think this is a DiffPrism bug? Report it: diffprism feedback --bug";
156
+ function redactHome(text) {
157
+ const home = os2.homedir();
158
+ return home ? text.split(home).join("~") : text;
159
+ }
160
+ function buildFeedbackUrl(options) {
161
+ const env = options.environment ?? describeEnvironment();
162
+ const message = options.message?.trim();
163
+ const error = options.kind === "bug" ? options.error : null;
164
+ const firstLine = (text) => text.split("\n")[0].slice(0, 80);
165
+ const title = options.kind === "bug" ? `Bug: ${firstLine(message || (error ? redactHome(error.message) : "")) || "describe the problem"}` : `Feedback: ${message ? firstLine(message) : "your idea or experience"}`;
166
+ const sections = [];
167
+ if (options.kind === "bug") {
168
+ sections.push(`### What happened
169
+
170
+ ${message || "<!-- What were you doing, and what went wrong? -->"}`);
171
+ sections.push("### What you expected\n\n<!-- What should have happened instead? -->");
172
+ } else {
173
+ sections.push(`### Feedback
174
+
175
+ ${message || "<!-- What's working, what isn't, what you wish it did. -->"}`);
176
+ }
177
+ if (error) {
178
+ let errorText = redactHome(error.message);
179
+ if (errorText.length > MAX_ERROR_CHARS) {
180
+ errorText = `${errorText.slice(0, MAX_ERROR_CHARS)}
181
+ \u2026 (truncated)`;
182
+ }
183
+ sections.push(`### Last error
184
+
185
+ \`diffprism ${error.command}\` at ${error.at}
186
+
187
+ \`\`\`
188
+ ${errorText}
189
+ \`\`\``);
190
+ }
191
+ sections.push(
192
+ [
193
+ "### Environment",
194
+ "",
195
+ "| | |",
196
+ "|---|---|",
197
+ `| DiffPrism | ${env.version} (${env.build}) |`,
198
+ `| OS | ${env.os} |`,
199
+ `| Node | ${env.node} |`
200
+ ].join("\n")
201
+ );
202
+ sections.push("<!-- Everything above is visible before you submit. Remove anything you don't want to share publicly. -->");
203
+ const params = new URLSearchParams({
204
+ title,
205
+ body: sections.join("\n\n"),
206
+ labels: options.kind === "bug" ? "bug" : "feedback"
207
+ });
208
+ return `${ISSUES_NEW_URL}?${params.toString()}`;
209
+ }
210
+
211
+ // packages/core/src/threads.ts
212
+ function lastAuthor(annotation) {
213
+ const replies = annotation.replies ?? [];
214
+ return replies.length > 0 ? replies[replies.length - 1].author : annotation.author ?? "agent";
215
+ }
216
+ function awaitingAgent(annotation) {
217
+ return !annotation.dismissed && lastAuthor(annotation) === "reviewer";
218
+ }
219
+
220
+ // packages/core/src/server-client.ts
221
+ import { spawn } from "child_process";
222
+ import fs4 from "fs";
223
+ import path4 from "path";
224
+ import os3 from "os";
225
+ import { fileURLToPath as fileURLToPath2 } from "url";
87
226
  async function ensureServer(options = {}) {
88
227
  const existing = await isServerAlive();
89
228
  if (existing) {
90
229
  return existing;
91
230
  }
92
231
  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 });
232
+ const logDir = path4.join(os3.homedir(), ".diffprism");
233
+ if (!fs4.existsSync(logDir)) {
234
+ fs4.mkdirSync(logDir, { recursive: true });
96
235
  }
97
- const logPath = path2.join(logDir, "server.log");
98
- const logFd = fs2.openSync(logPath, "a");
236
+ const logPath = path4.join(logDir, "server.log");
237
+ const logFd = fs4.openSync(logPath, "a");
99
238
  const [cmd, ...args] = spawnArgs;
100
239
  const child = spawn(cmd, args, {
101
240
  detached: true,
@@ -103,7 +242,7 @@ async function ensureServer(options = {}) {
103
242
  env: { ...process.env }
104
243
  });
105
244
  child.unref();
106
- fs2.closeSync(logFd);
245
+ fs4.closeSync(logFd);
107
246
  const timeoutMs = options.timeoutMs ?? 15e3;
108
247
  const startTime = Date.now();
109
248
  while (Date.now() - startTime < timeoutMs) {
@@ -118,15 +257,15 @@ async function ensureServer(options = {}) {
118
257
  );
119
258
  }
120
259
  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
- if (fs2.existsSync(devBin)) {
260
+ const thisFile = fileURLToPath2(import.meta.url);
261
+ const thisDir = path4.dirname(thisFile);
262
+ const workspaceRoot = path4.resolve(thisDir, "..", "..", "..");
263
+ const devBin = path4.join(workspaceRoot, "cli", "bin", "diffprism.mjs");
264
+ if (fs4.existsSync(devBin)) {
126
265
  return withDevFlag([process.execPath, devBin, "server", "--_daemon"], options);
127
266
  }
128
267
  let searchDir = thisDir;
129
- while (searchDir !== path2.dirname(searchDir)) {
268
+ while (searchDir !== path4.dirname(searchDir)) {
130
269
  const ownBin = readOwnBinPath(searchDir);
131
270
  if (ownBin) {
132
271
  return withDevFlag(
@@ -134,11 +273,11 @@ function buildDefaultSpawnCommand(options) {
134
273
  options
135
274
  );
136
275
  }
137
- const shim = path2.join(searchDir, "node_modules", ".bin", "diffprism");
138
- if (fs2.existsSync(shim)) {
276
+ const shim = path4.join(searchDir, "node_modules", ".bin", "diffprism");
277
+ if (fs4.existsSync(shim)) {
139
278
  return withDevFlag([shim, "server", "--_daemon"], options);
140
279
  }
141
- searchDir = path2.dirname(searchDir);
280
+ searchDir = path4.dirname(searchDir);
142
281
  }
143
282
  return withDevFlag(["diffprism", "server", "--_daemon"], options);
144
283
  }
@@ -146,22 +285,71 @@ function withDevFlag(args, options) {
146
285
  return options.dev ? [...args, "--dev"] : args;
147
286
  }
148
287
  function readOwnBinPath(dir) {
149
- const manifestPath = path2.join(dir, "package.json");
150
- if (!fs2.existsSync(manifestPath)) {
288
+ const manifestPath = path4.join(dir, "package.json");
289
+ if (!fs4.existsSync(manifestPath)) {
151
290
  return null;
152
291
  }
153
292
  try {
154
- const manifest = JSON.parse(fs2.readFileSync(manifestPath, "utf8"));
293
+ const manifest = JSON.parse(fs4.readFileSync(manifestPath, "utf8"));
155
294
  const entry = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.diffprism;
156
295
  if (!entry) {
157
296
  return null;
158
297
  }
159
- const resolved = path2.resolve(dir, entry);
160
- return fs2.existsSync(resolved) ? resolved : null;
298
+ const resolved = path4.resolve(dir, entry);
299
+ return fs4.existsSync(resolved) ? resolved : null;
161
300
  } catch {
162
301
  return null;
163
302
  }
164
303
  }
304
+ var ReviewTimeoutError = class extends Error {
305
+ sessionId;
306
+ waitedMs;
307
+ constructor(sessionId, waitedMs) {
308
+ super(
309
+ `Review ${sessionId} is still open after ${Math.round(waitedMs / 1e3)}s without a decision.`
310
+ );
311
+ this.name = "ReviewTimeoutError";
312
+ this.sessionId = sessionId;
313
+ this.waitedMs = waitedMs;
314
+ }
315
+ };
316
+ var ReviewerAskedError = class extends Error {
317
+ sessionId;
318
+ threads;
319
+ constructor(sessionId, threads) {
320
+ super(
321
+ `The reviewer asked ${threads.length} question${threads.length === 1 ? "" : "s"} on review ${sessionId} before deciding.`
322
+ );
323
+ this.name = "ReviewerAskedError";
324
+ this.sessionId = sessionId;
325
+ this.threads = threads;
326
+ }
327
+ };
328
+ async function waitForDecision(serverInfo, sessionId, maxWaitMs, pollIntervalMs = 2e3) {
329
+ const base = `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}`;
330
+ const start = Date.now();
331
+ while (Date.now() - start < maxWaitMs) {
332
+ const resultResponse = await fetch(`${base}/result`);
333
+ if (!resultResponse.ok) {
334
+ throw new Error(`Session not found: ${sessionId}`);
335
+ }
336
+ const { result } = await resultResponse.json();
337
+ if (result) {
338
+ return result;
339
+ }
340
+ const annotationsResponse = await fetch(`${base}/annotations`);
341
+ if (!annotationsResponse.ok) {
342
+ throw new Error(`Could not read threads for ${sessionId}: server returned ${annotationsResponse.status}`);
343
+ }
344
+ const { annotations } = await annotationsResponse.json();
345
+ const asked = annotations.filter(awaitingAgent);
346
+ if (asked.length > 0) {
347
+ throw new ReviewerAskedError(sessionId, asked);
348
+ }
349
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
350
+ }
351
+ throw new ReviewTimeoutError(sessionId, maxWaitMs);
352
+ }
165
353
  async function submitReviewToServer(serverInfo, diffRef, options = {}) {
166
354
  const cwd = options.cwd ?? process.cwd();
167
355
  const projectPath = options.projectPath ?? cwd;
@@ -169,7 +357,7 @@ async function submitReviewToServer(serverInfo, diffRef, options = {}) {
169
357
  if (options.injectedPayload) {
170
358
  payload = options.injectedPayload;
171
359
  } else {
172
- const { getDiff: getDiff2, getCurrentBranch: getCurrentBranch2, detectWorktree } = await import("./src-AMCPIYDZ.js");
360
+ const { getDiff: getDiff2, getCurrentBranch: getCurrentBranch2, detectWorktree } = await import("./src-OZQ32NDN.js");
173
361
  const { analyze: analyze2 } = await import("./src-JMPTSU3P.js");
174
362
  const { diffSet, rawDiff } = getDiff2(diffRef, { cwd });
175
363
  if (diffSet.files.length === 0) {
@@ -249,49 +437,7 @@ async function submitReviewToServer(serverInfo, diffRef, options = {}) {
249
437
  if (maxWaitMs <= 0) {
250
438
  return { result: null, sessionId };
251
439
  }
252
- const pollIntervalMs = 2e3;
253
- const start = Date.now();
254
- while (Date.now() - start < maxWaitMs) {
255
- const resultResponse = await fetch(
256
- `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/result`
257
- );
258
- if (resultResponse.ok) {
259
- const data = await resultResponse.json();
260
- if (data.result) {
261
- return { result: data.result, sessionId };
262
- }
263
- }
264
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
265
- }
266
- throw new Error("Review timed out waiting for submission.");
267
- }
268
-
269
- // packages/core/src/build-info.ts
270
- import fs3 from "fs";
271
- import path3 from "path";
272
- import { fileURLToPath as fileURLToPath2 } from "url";
273
- function getBuildInfo() {
274
- let dir = path3.dirname(fileURLToPath2(import.meta.url));
275
- while (dir !== path3.dirname(dir)) {
276
- const manifest = path3.join(dir, "package.json");
277
- if (fs3.existsSync(manifest) && isOwnManifest(manifest)) {
278
- return fs3.existsSync(path3.join(dir, ".git")) ? { dev: true, root: dir } : { dev: false, root: null };
279
- }
280
- dir = path3.dirname(dir);
281
- }
282
- return { dev: false, root: null };
283
- }
284
- function isOwnManifest(manifestPath) {
285
- try {
286
- const manifest = JSON.parse(fs3.readFileSync(manifestPath, "utf8"));
287
- return manifest.name === "diffprism";
288
- } catch {
289
- return false;
290
- }
291
- }
292
- function describeVersion(version) {
293
- const info = getBuildInfo();
294
- return info.dev ? `${version} (dev build \u2014 ${info.root})` : version;
440
+ return { result: await waitForDecision(serverInfo, sessionId, maxWaitMs), sessionId };
295
441
  }
296
442
 
297
443
  // packages/core/src/diff-utils.ts
@@ -334,8 +480,20 @@ function createDiffPoller(options) {
334
480
  let lastDiffHash = null;
335
481
  let lastDiffSet = null;
336
482
  let refreshRequested = false;
337
- let interval = null;
483
+ let timer = null;
338
484
  let running = false;
485
+ let quietPolls = 0;
486
+ function nextDelay() {
487
+ return typeof pollInterval === "number" ? pollInterval : pollInterval({ quietPolls });
488
+ }
489
+ function schedule() {
490
+ if (!running) return;
491
+ timer = setTimeout(() => {
492
+ timer = null;
493
+ poll();
494
+ schedule();
495
+ }, nextDelay());
496
+ }
339
497
  function poll() {
340
498
  if (!running) return;
341
499
  try {
@@ -343,6 +501,7 @@ function createDiffPoller(options) {
343
501
  const newHash = hashDiff(newRawDiff);
344
502
  if (newHash !== lastDiffHash || refreshRequested) {
345
503
  refreshRequested = false;
504
+ quietPolls = 0;
346
505
  const newBriefing = analyze(newDiffSet);
347
506
  const changedFiles = detectChangedFiles(lastDiffSet, newDiffSet);
348
507
  lastDiffHash = newHash;
@@ -355,8 +514,11 @@ function createDiffPoller(options) {
355
514
  timestamp: Date.now()
356
515
  };
357
516
  onDiffChanged(updatePayload);
517
+ } else {
518
+ quietPolls++;
358
519
  }
359
520
  } catch (err) {
521
+ quietPolls++;
360
522
  if (onError && err instanceof Error) {
361
523
  onError(err);
362
524
  }
@@ -372,13 +534,13 @@ function createDiffPoller(options) {
372
534
  lastDiffSet = initialDiffSet;
373
535
  } catch {
374
536
  }
375
- interval = setInterval(poll, pollInterval);
537
+ schedule();
376
538
  },
377
539
  stop() {
378
540
  running = false;
379
- if (interval) {
380
- clearInterval(interval);
381
- interval = null;
541
+ if (timer) {
542
+ clearTimeout(timer);
543
+ timer = null;
382
544
  }
383
545
  },
384
546
  setDiffRef(newRef) {
@@ -388,6 +550,15 @@ function createDiffPoller(options) {
388
550
  },
389
551
  refresh() {
390
552
  refreshRequested = true;
553
+ },
554
+ wake() {
555
+ if (!running) return;
556
+ if (timer) {
557
+ clearTimeout(timer);
558
+ timer = null;
559
+ }
560
+ poll();
561
+ schedule();
391
562
  }
392
563
  };
393
564
  }
@@ -398,12 +569,13 @@ import { randomUUID as randomUUID2 } from "crypto";
398
569
  import getPort from "get-port";
399
570
  import open from "open";
400
571
  import { WebSocketServer, WebSocket } from "ws";
401
- import fs6 from "fs";
572
+ import fs7 from "fs";
573
+ import path7 from "path";
402
574
 
403
575
  // packages/core/src/ui-server.ts
404
576
  import http from "http";
405
- import fs4 from "fs";
406
- import path4 from "path";
577
+ import fs5 from "fs";
578
+ import path5 from "path";
407
579
  import { fileURLToPath as fileURLToPath3 } from "url";
408
580
  var MIME_TYPES = {
409
581
  ".html": "text/html",
@@ -418,14 +590,14 @@ var MIME_TYPES = {
418
590
  };
419
591
  function resolveUiDist() {
420
592
  const thisFile = fileURLToPath3(import.meta.url);
421
- const thisDir = path4.dirname(thisFile);
422
- const publishedUiDist = path4.resolve(thisDir, "..", "ui-dist");
423
- if (fs4.existsSync(path4.join(publishedUiDist, "index.html"))) {
593
+ const thisDir = path5.dirname(thisFile);
594
+ const publishedUiDist = path5.resolve(thisDir, "..", "ui-dist");
595
+ if (fs5.existsSync(path5.join(publishedUiDist, "index.html"))) {
424
596
  return publishedUiDist;
425
597
  }
426
- const workspaceRoot = path4.resolve(thisDir, "..", "..", "..");
427
- const devUiDist = path4.join(workspaceRoot, "packages", "ui", "dist");
428
- if (fs4.existsSync(path4.join(devUiDist, "index.html"))) {
598
+ const workspaceRoot = path5.resolve(thisDir, "..", "..", "..");
599
+ const devUiDist = path5.join(workspaceRoot, "packages", "ui", "dist");
600
+ if (fs5.existsSync(path5.join(devUiDist, "index.html"))) {
429
601
  return devUiDist;
430
602
  }
431
603
  throw new Error(
@@ -434,10 +606,10 @@ function resolveUiDist() {
434
606
  }
435
607
  function resolveUiRoot() {
436
608
  const thisFile = fileURLToPath3(import.meta.url);
437
- const thisDir = path4.dirname(thisFile);
438
- const workspaceRoot = path4.resolve(thisDir, "..", "..", "..");
439
- const uiRoot = path4.join(workspaceRoot, "packages", "ui");
440
- if (fs4.existsSync(path4.join(uiRoot, "index.html"))) {
609
+ const thisDir = path5.dirname(thisFile);
610
+ const workspaceRoot = path5.resolve(thisDir, "..", "..", "..");
611
+ const uiRoot = path5.join(workspaceRoot, "packages", "ui");
612
+ if (fs5.existsSync(path5.join(uiRoot, "index.html"))) {
441
613
  return uiRoot;
442
614
  }
443
615
  throw new Error(
@@ -457,14 +629,14 @@ async function startViteDevServer(uiRoot, port, silent) {
457
629
  function createStaticServer(distPath, port) {
458
630
  const server = http.createServer((req, res) => {
459
631
  const urlPath = req.url?.split("?")[0] ?? "/";
460
- let filePath = path4.join(distPath, urlPath === "/" ? "index.html" : urlPath);
461
- if (!fs4.existsSync(filePath)) {
462
- filePath = path4.join(distPath, "index.html");
632
+ let filePath = path5.join(distPath, urlPath === "/" ? "index.html" : urlPath);
633
+ if (!fs5.existsSync(filePath)) {
634
+ filePath = path5.join(distPath, "index.html");
463
635
  }
464
- const ext = path4.extname(filePath);
636
+ const ext = path5.extname(filePath);
465
637
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
466
638
  try {
467
- const content = fs4.readFileSync(filePath);
639
+ const content = fs5.readFileSync(filePath);
468
640
  res.writeHead(200, { "Content-Type": contentType });
469
641
  res.end(content);
470
642
  } catch {
@@ -478,23 +650,70 @@ function createStaticServer(distPath, port) {
478
650
  });
479
651
  }
480
652
 
653
+ // packages/core/src/pr-review.ts
654
+ var EVENTS = ["APPROVE", "REQUEST_CHANGES", "COMMENT"];
655
+ var PR_EVENT_DECISION = {
656
+ APPROVE: "approved",
657
+ REQUEST_CHANGES: "changes_requested",
658
+ // A GitHub "Comment" review neither approves nor blocks; this is the
659
+ // closest DiffPrism decision.
660
+ COMMENT: "approved_with_comments"
661
+ };
662
+ function buildGitHubReview(submission, annotations) {
663
+ if (!EVENTS.includes(submission.event)) {
664
+ return { error: `Unknown review event: ${String(submission.event)}` };
665
+ }
666
+ const body = submission.summary?.trim() ?? "";
667
+ if (!body && submission.event !== "APPROVE") {
668
+ return { error: "GitHub needs a summary to request changes or comment" };
669
+ }
670
+ const comments = [];
671
+ for (const id of submission.threadIds ?? []) {
672
+ const thread = annotations.find((a) => a.id === id);
673
+ if (!thread) return { error: `Thread not found: ${id}` };
674
+ if (thread.author !== "reviewer") return { error: `Only your own threads can be posted: ${id}` };
675
+ if (thread.dismissed) return { error: `Thread was dismissed: ${id}` };
676
+ comments.push({
677
+ path: thread.file,
678
+ line: thread.line,
679
+ side: thread.side === "old" ? "LEFT" : "RIGHT",
680
+ body: thread.body
681
+ });
682
+ }
683
+ return { review: { event: submission.event, body, comments } };
684
+ }
685
+
686
+ // packages/core/src/watch-schedule.ts
687
+ var DEFAULT_WATCH_SCHEDULE = {
688
+ viewedMs: 2e3,
689
+ unviewedMs: 3e4,
690
+ unviewedMaxMs: 5 * 6e4
691
+ };
692
+ function watcherPollDelay(state, options = DEFAULT_WATCH_SCHEDULE) {
693
+ if (state.viewed) {
694
+ return options.viewedMs;
695
+ }
696
+ const backedOff = options.unviewedMs * 2 ** Math.min(state.quietPolls, 20);
697
+ return Math.min(backedOff, options.unviewedMaxMs);
698
+ }
699
+
481
700
  // packages/core/src/review-history.ts
482
- import fs5 from "fs";
483
- import path5 from "path";
701
+ import fs6 from "fs";
702
+ import path6 from "path";
484
703
  import { randomUUID } from "crypto";
485
704
  function generateEntryId() {
486
705
  return randomUUID();
487
706
  }
488
707
  function getHistoryPath(projectDir) {
489
- return path5.join(projectDir, ".diffprism", "history", "reviews.json");
708
+ return path6.join(projectDir, ".diffprism", "history", "reviews.json");
490
709
  }
491
710
  function readHistory(projectDir) {
492
711
  const filePath = getHistoryPath(projectDir);
493
- if (!fs5.existsSync(filePath)) {
712
+ if (!fs6.existsSync(filePath)) {
494
713
  return { version: 1, entries: [] };
495
714
  }
496
715
  try {
497
- const raw = fs5.readFileSync(filePath, "utf-8");
716
+ const raw = fs6.readFileSync(filePath, "utf-8");
498
717
  const parsed = JSON.parse(raw);
499
718
  return parsed;
500
719
  } catch {
@@ -503,14 +722,14 @@ function readHistory(projectDir) {
503
722
  }
504
723
  function appendHistory(projectDir, entry) {
505
724
  const filePath = getHistoryPath(projectDir);
506
- const dir = path5.dirname(filePath);
507
- if (!fs5.existsSync(dir)) {
508
- fs5.mkdirSync(dir, { recursive: true });
725
+ const dir = path6.dirname(filePath);
726
+ if (!fs6.existsSync(dir)) {
727
+ fs6.mkdirSync(dir, { recursive: true });
509
728
  }
510
729
  const history = readHistory(projectDir);
511
730
  history.entries.push(entry);
512
731
  history.entries.sort((a, b) => a.timestamp - b.timestamp);
513
- fs5.writeFileSync(filePath, JSON.stringify(history, null, 2) + "\n");
732
+ fs6.writeFileSync(filePath, JSON.stringify(history, null, 2) + "\n");
514
733
  }
515
734
  function getRecentHistory(projectDir, limit = 50) {
516
735
  const history = readHistory(projectDir);
@@ -521,10 +740,126 @@ function getRecentHistory(projectDir, limit = 50) {
521
740
  var SUBMITTED_TTL_MS = 5 * 60 * 1e3;
522
741
  var ABANDONED_TTL_MS = 60 * 60 * 1e3;
523
742
  var CLEANUP_INTERVAL_MS = 60 * 1e3;
743
+ var IDLE_SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
744
+ function touch(session) {
745
+ session.lastActivityAt = Date.now();
746
+ }
524
747
  var sessions = /* @__PURE__ */ new Map();
748
+ function listedSummaries() {
749
+ return Array.from(sessions.values()).filter((session) => session.closedAt === void 0).map(toSummary);
750
+ }
751
+ function localIdentity(projectPath) {
752
+ const repoRoot = getRepoRoot({ cwd: projectPath }) ?? path7.resolve(projectPath);
753
+ return { key: `repo:${repoRoot}`, repoRoot };
754
+ }
755
+ function findSessionByKey(key) {
756
+ for (const session of sessions.values()) {
757
+ if (session.key === key) return session;
758
+ }
759
+ return void 0;
760
+ }
761
+ function openSession(request) {
762
+ const { payload, diffRef } = request;
763
+ if (diffRef) {
764
+ payload.watchMode = true;
765
+ }
766
+ const existing = findSessionByKey(request.key);
767
+ if (!existing) {
768
+ const id2 = `session-${randomUUID2().slice(0, 8)}`;
769
+ payload.reviewId = id2;
770
+ const session = {
771
+ id: id2,
772
+ key: request.key,
773
+ repoRoot: request.repoRoot,
774
+ payload,
775
+ projectPath: request.projectPath,
776
+ source: request.source,
777
+ status: "pending",
778
+ createdAt: Date.now(),
779
+ result: null,
780
+ diffRef,
781
+ openedDiffRef: diffRef,
782
+ lastDiffHash: diffRef ? hashDiff(payload.rawDiff) : void 0,
783
+ lastDiffSet: diffRef ? payload.diffSet : void 0,
784
+ hasNewChanges: false,
785
+ annotations: [],
786
+ attentionClearedAt: 0,
787
+ lastActivityAt: Date.now()
788
+ };
789
+ sessions.set(id2, session);
790
+ if (diffRef) {
791
+ startSessionWatcher(id2);
792
+ }
793
+ broadcastToAll({ type: "session:added", payload: toSummary(session) });
794
+ return { session, reused: false };
795
+ }
796
+ const id = existing.id;
797
+ payload.reviewId = id;
798
+ stopSessionWatcher(id);
799
+ const wasClosed = existing.closedAt !== void 0;
800
+ existing.closedAt = void 0;
801
+ const incomingHash = hashDiff(payload.rawDiff);
802
+ const contentChanged = hashDiff(existing.payload.rawDiff) !== incomingHash;
803
+ const stillAnswered = existing.result !== null && existing.result.decision !== "dismissed" && existing.verdictDiffHash === incomingHash;
804
+ if (existing.result !== null && !stillAnswered) {
805
+ existing.result = null;
806
+ existing.status = "pending";
807
+ existing.verdictDiffHash = void 0;
808
+ }
809
+ existing.payload = payload;
810
+ existing.projectPath = request.projectPath;
811
+ existing.repoRoot = request.repoRoot;
812
+ existing.diffRef = diffRef;
813
+ existing.openedDiffRef = diffRef;
814
+ existing.lastDiffHash = diffRef ? incomingHash : void 0;
815
+ existing.lastDiffSet = diffRef ? payload.diffSet : void 0;
816
+ existing.createdAt = Date.now();
817
+ touch(existing);
818
+ if (diffRef) {
819
+ startSessionWatcher(id);
820
+ }
821
+ if (hasViewersForSession(id)) {
822
+ if (existing.status === "pending") {
823
+ existing.status = "in_review";
824
+ }
825
+ existing.hasNewChanges = false;
826
+ sendToSessionClients(id, { type: "review:init", payload });
827
+ for (const annotation of existing.annotations) {
828
+ sendToSessionClients(id, { type: "annotation:added", payload: annotation });
829
+ }
830
+ } else if (contentChanged || wasClosed) {
831
+ existing.hasNewChanges = true;
832
+ }
833
+ if (wasClosed) {
834
+ broadcastToAll({ type: "session:added", payload: toSummary(existing) });
835
+ } else {
836
+ broadcastSessionUpdate(existing);
837
+ }
838
+ return { session: existing, reused: true };
839
+ }
840
+ function attachViewer(ws, session) {
841
+ clientSessions.set(ws, session.id);
842
+ session.status = "in_review";
843
+ session.hasNewChanges = false;
844
+ session.attentionClearedAt = Date.now();
845
+ touch(session);
846
+ const watcher = sessionWatchers.get(session.id);
847
+ startSessionWatcher(session.id);
848
+ broadcastSessionUpdate(session);
849
+ ws.send(JSON.stringify({ type: "review:init", payload: session.payload }));
850
+ for (const annotation of session.annotations) {
851
+ ws.send(JSON.stringify({ type: "annotation:added", payload: annotation }));
852
+ }
853
+ watcher?.wake();
854
+ }
855
+ function needsAttention(session) {
856
+ return session.annotations.some(
857
+ (a) => a.type === "warning" && !a.dismissed && a.createdAt > session.attentionClearedAt
858
+ );
859
+ }
525
860
  var clientSessions = /* @__PURE__ */ new Map();
526
861
  var sessionWatchers = /* @__PURE__ */ new Map();
527
- var serverPollInterval = 2e3;
862
+ var watchSchedule = DEFAULT_WATCH_SCHEDULE;
528
863
  var reopenBrowserIfNeeded = null;
529
864
  var serverUiUrl = null;
530
865
  function toSummary(session) {
@@ -549,6 +884,8 @@ function toSummary(session) {
549
884
  decision: session.result?.decision,
550
885
  createdAt: session.createdAt,
551
886
  hasNewChanges: session.hasNewChanges,
887
+ needsAttention: needsAttention(session),
888
+ diffRef: session.diffRef,
552
889
  source: session.source
553
890
  };
554
891
  }
@@ -632,10 +969,13 @@ function startSessionWatcher(sessionId) {
632
969
  const poller = createDiffPoller({
633
970
  diffRef: session.diffRef,
634
971
  cwd: session.projectPath,
635
- pollInterval: serverPollInterval,
972
+ // Asked before every poll, so a session speeds up the moment someone views
973
+ // it and backs off while nobody is — see watch-schedule.ts.
974
+ pollInterval: ({ quietPolls }) => watcherPollDelay({ viewed: hasViewersForSession(sessionId), quietPolls }, watchSchedule),
636
975
  onDiffChanged: (updatePayload) => {
637
976
  const s = sessions.get(sessionId);
638
977
  if (!s) return;
978
+ touch(s);
639
979
  s.payload = {
640
980
  ...s.payload,
641
981
  diffSet: updatePayload.diffSet,
@@ -687,9 +1027,16 @@ function hasConnectedClients() {
687
1027
  return false;
688
1028
  }
689
1029
  function broadcastSessionList() {
690
- const summaries = Array.from(sessions.values()).map(toSummary);
1030
+ const summaries = listedSummaries();
691
1031
  broadcastToAll({ type: "session:list", payload: summaries });
692
1032
  }
1033
+ function recordVerdict(session, result) {
1034
+ session.result = result;
1035
+ session.status = "submitted";
1036
+ session.verdictDiffHash = hashDiff(session.payload.rawDiff);
1037
+ touch(session);
1038
+ recordReviewHistory(session, result);
1039
+ }
693
1040
  function recordReviewHistory(session, result) {
694
1041
  if (session.projectPath.startsWith("github:")) return;
695
1042
  try {
@@ -714,12 +1061,12 @@ function recordReviewHistory(session, result) {
714
1061
  function isInsideGitRepo(dirPath) {
715
1062
  let current = dirPath;
716
1063
  while (current !== "/") {
717
- if (fs6.existsSync(`${current}/.git`)) return true;
1064
+ if (fs7.existsSync(`${current}/.git`)) return true;
718
1065
  const parent = current.replace(/\/[^/]+$/, "") || "/";
719
1066
  if (parent === current) break;
720
1067
  current = parent;
721
1068
  }
722
- return fs6.existsSync(`${current}/.git`);
1069
+ return fs7.existsSync(`${current}/.git`);
723
1070
  }
724
1071
  async function handleApiRequest(req, res) {
725
1072
  const method = req.method ?? "GET";
@@ -742,7 +1089,18 @@ async function handleApiRequest(req, res) {
742
1089
  sessions: sessions.size,
743
1090
  uptime: process.uptime(),
744
1091
  uiUrl: serverUiUrl,
745
- cwd: process.cwd()
1092
+ cwd: process.cwd(),
1093
+ // The dashboard reads its default scope from here rather than keeping
1094
+ // its own copy — Vite can't import @diffprism/core, and a copy drifts.
1095
+ defaultDiffRef: DEFAULT_DIFF_REF
1096
+ });
1097
+ return true;
1098
+ }
1099
+ if (method === "GET" && url === "/api/feedback") {
1100
+ const parsed = new URL(req.url ?? "/", "http://localhost");
1101
+ const kind = parsed.searchParams.get("kind") === "bug" ? "bug" : "feedback";
1102
+ jsonResponse(res, 200, {
1103
+ url: buildFeedbackUrl({ kind, error: kind === "bug" ? readLastError() : null })
746
1104
  });
747
1105
  return true;
748
1106
  }
@@ -750,72 +1108,17 @@ async function handleApiRequest(req, res) {
750
1108
  try {
751
1109
  const body = await readBody(req);
752
1110
  const { payload, projectPath, diffRef } = JSON.parse(body);
753
- let existingSession;
754
- for (const session of sessions.values()) {
755
- if (session.projectPath === projectPath && session.source === "agent") {
756
- existingSession = session;
757
- break;
758
- }
759
- }
760
- if (existingSession) {
761
- const sessionId = existingSession.id;
762
- payload.reviewId = sessionId;
763
- if (diffRef) {
764
- payload.watchMode = true;
765
- }
766
- stopSessionWatcher(sessionId);
767
- existingSession.payload = payload;
768
- existingSession.status = "pending";
769
- existingSession.result = null;
770
- existingSession.createdAt = Date.now();
771
- existingSession.diffRef = diffRef;
772
- existingSession.lastDiffHash = diffRef ? hashDiff(payload.rawDiff) : void 0;
773
- existingSession.lastDiffSet = diffRef ? payload.diffSet : void 0;
774
- existingSession.hasNewChanges = false;
775
- existingSession.annotations = [];
776
- if (diffRef) {
777
- startSessionWatcher(sessionId);
778
- }
779
- if (hasViewersForSession(sessionId)) {
780
- sendToSessionClients(sessionId, {
781
- type: "review:init",
782
- payload
783
- });
784
- }
785
- broadcastSessionUpdate(existingSession);
786
- reopenBrowserIfNeeded?.();
787
- jsonResponse(res, 200, { sessionId });
788
- } else {
789
- const sessionId = `session-${randomUUID2().slice(0, 8)}`;
790
- payload.reviewId = sessionId;
791
- if (diffRef) {
792
- payload.watchMode = true;
793
- }
794
- const session = {
795
- id: sessionId,
796
- payload,
797
- projectPath,
798
- source: "agent",
799
- status: "pending",
800
- createdAt: Date.now(),
801
- result: null,
802
- diffRef,
803
- lastDiffHash: diffRef ? hashDiff(payload.rawDiff) : void 0,
804
- lastDiffSet: diffRef ? payload.diffSet : void 0,
805
- hasNewChanges: false,
806
- annotations: []
807
- };
808
- sessions.set(sessionId, session);
809
- if (diffRef) {
810
- startSessionWatcher(sessionId);
811
- }
812
- broadcastToAll({
813
- type: "session:added",
814
- payload: toSummary(session)
815
- });
816
- reopenBrowserIfNeeded?.();
817
- jsonResponse(res, 201, { sessionId });
818
- }
1111
+ const identity = localIdentity(projectPath);
1112
+ const { session, reused } = openSession({
1113
+ key: identity.key,
1114
+ repoRoot: identity.repoRoot,
1115
+ projectPath: identity.repoRoot,
1116
+ payload,
1117
+ diffRef,
1118
+ source: "agent"
1119
+ });
1120
+ reopenBrowserIfNeeded?.();
1121
+ jsonResponse(res, reused ? 200 : 201, { sessionId: session.id });
819
1122
  } catch {
820
1123
  jsonResponse(res, 400, { error: "Invalid request body" });
821
1124
  }
@@ -824,13 +1127,13 @@ async function handleApiRequest(req, res) {
824
1127
  if (method === "POST" && url === "/api/projects/open") {
825
1128
  try {
826
1129
  const body = await readBody(req);
827
- const { projectPath, diffRef = "working-copy" } = JSON.parse(body);
1130
+ const { projectPath, diffRef = DEFAULT_DIFF_REF } = JSON.parse(body);
828
1131
  if (!projectPath) {
829
1132
  jsonResponse(res, 400, { error: "Missing projectPath" });
830
1133
  return true;
831
1134
  }
832
1135
  try {
833
- const stat = fs6.statSync(projectPath);
1136
+ const stat = fs7.statSync(projectPath);
834
1137
  if (!stat.isDirectory()) {
835
1138
  jsonResponse(res, 400, { error: "Path is not a directory" });
836
1139
  return true;
@@ -855,40 +1158,30 @@ async function handleApiRequest(req, res) {
855
1158
  currentBranch = getCurrentBranch({ cwd: projectPath });
856
1159
  } catch {
857
1160
  }
858
- const sessionId = `session-${randomUUID2().slice(0, 8)}`;
859
- const projectName = projectPath.split("/").pop() || projectPath;
1161
+ const identity = localIdentity(projectPath);
1162
+ const projectName = identity.repoRoot.split("/").pop() || identity.repoRoot;
860
1163
  const payload = {
861
- reviewId: sessionId,
1164
+ reviewId: "",
862
1165
  diffSet,
863
1166
  rawDiff,
864
1167
  briefing,
865
1168
  metadata: {
866
1169
  title: projectName,
867
1170
  currentBranch
868
- },
869
- watchMode: true
1171
+ }
870
1172
  };
871
- const session = {
872
- id: sessionId,
1173
+ const { session, reused } = openSession({
1174
+ key: identity.key,
1175
+ repoRoot: identity.repoRoot,
1176
+ projectPath: identity.repoRoot,
873
1177
  payload,
874
- projectPath,
875
- source: "manual",
876
- status: "pending",
877
- createdAt: Date.now(),
878
- result: null,
879
1178
  diffRef,
880
- lastDiffHash: hashDiff(rawDiff),
881
- lastDiffSet: diffSet,
882
- hasNewChanges: false,
883
- annotations: []
884
- };
885
- sessions.set(sessionId, session);
886
- startSessionWatcher(sessionId);
887
- broadcastToAll({
888
- type: "session:added",
889
- payload: toSummary(session)
1179
+ source: "manual"
1180
+ });
1181
+ jsonResponse(res, reused ? 200 : 201, {
1182
+ sessionId: session.id,
1183
+ fileCount: diffSet.files.length
890
1184
  });
891
- jsonResponse(res, 201, { sessionId, fileCount: diffSet.files.length });
892
1185
  } catch {
893
1186
  jsonResponse(res, 400, { error: "Invalid request body" });
894
1187
  }
@@ -910,7 +1203,7 @@ async function handleApiRequest(req, res) {
910
1203
  fetchPullRequest,
911
1204
  fetchPullRequestDiff,
912
1205
  normalizePr
913
- } = await import("./src-KF5HRJPX.js");
1206
+ } = await import("./src-DNNNQ2RD.js");
914
1207
  if (!isPrRef(prUrl)) {
915
1208
  jsonResponse(res, 400, {
916
1209
  error: "Invalid PR URL. Expected https://github.com/owner/repo/pull/123 or owner/repo#123"
@@ -947,26 +1240,15 @@ async function handleApiRequest(req, res) {
947
1240
  }
948
1241
  } catch {
949
1242
  }
950
- const sessionId = `session-${randomUUID2().slice(0, 8)}`;
951
- normalized.payload.reviewId = sessionId;
952
- const session = {
953
- id: sessionId,
954
- payload: normalized.payload,
1243
+ const { session, reused } = openSession({
1244
+ key: `pr:${owner}/${repo}#${prNumber}`.toLowerCase(),
1245
+ repoRoot: localRepoPath ? getRepoRoot({ cwd: localRepoPath }) ?? localRepoPath : null,
955
1246
  projectPath: localRepoPath ?? `github:${owner}/${repo}#${prNumber}`,
956
- source: "manual",
957
- status: "pending",
958
- createdAt: Date.now(),
959
- result: null,
960
- hasNewChanges: false,
961
- annotations: []
962
- };
963
- sessions.set(sessionId, session);
964
- broadcastToAll({
965
- type: "session:added",
966
- payload: toSummary(session)
1247
+ payload: normalized.payload,
1248
+ source: "manual"
967
1249
  });
968
- jsonResponse(res, 201, {
969
- sessionId,
1250
+ jsonResponse(res, reused ? 200 : 201, {
1251
+ sessionId: session.id,
970
1252
  fileCount: normalized.diffSet.files.length,
971
1253
  localRepoPath,
972
1254
  pr: {
@@ -989,7 +1271,7 @@ async function handleApiRequest(req, res) {
989
1271
  if (parsedUrl.pathname === "/api/fs/list") {
990
1272
  const dirPath = parsedUrl.searchParams.get("path") || process.cwd();
991
1273
  try {
992
- const stat = fs6.statSync(dirPath);
1274
+ const stat = fs7.statSync(dirPath);
993
1275
  if (!stat.isDirectory()) {
994
1276
  jsonResponse(res, 400, { error: "Not a directory" });
995
1277
  return true;
@@ -999,13 +1281,13 @@ async function handleApiRequest(req, res) {
999
1281
  return true;
1000
1282
  }
1001
1283
  try {
1002
- const entries = fs6.readdirSync(dirPath, { withFileTypes: true });
1284
+ const entries = fs7.readdirSync(dirPath, { withFileTypes: true });
1003
1285
  const dirs = [];
1004
1286
  for (const entry of entries) {
1005
1287
  if (!entry.isDirectory()) continue;
1006
1288
  if (entry.name.startsWith(".")) continue;
1007
1289
  const fullPath = `${dirPath}/${entry.name}`;
1008
- const isGitRepo2 = fs6.existsSync(`${fullPath}/.git`);
1290
+ const isGitRepo2 = fs7.existsSync(`${fullPath}/.git`);
1009
1291
  dirs.push({ name: entry.name, path: fullPath, isGitRepo: isGitRepo2 });
1010
1292
  }
1011
1293
  dirs.sort((a, b) => {
@@ -1022,10 +1304,22 @@ async function handleApiRequest(req, res) {
1022
1304
  }
1023
1305
  }
1024
1306
  if (method === "GET" && url === "/api/reviews") {
1025
- const summaries = Array.from(sessions.values()).map(toSummary);
1307
+ const summaries = listedSummaries();
1026
1308
  jsonResponse(res, 200, { sessions: summaries });
1027
1309
  return true;
1028
1310
  }
1311
+ if (method === "GET" && url === "/api/reviews/resolve") {
1312
+ const parsed = new URL(req.url ?? "/", "http://localhost");
1313
+ const rawPath = parsed.searchParams.get("path");
1314
+ if (!rawPath) {
1315
+ jsonResponse(res, 400, { error: "Missing path" });
1316
+ return true;
1317
+ }
1318
+ const repoRoot = getRepoRoot({ cwd: rawPath }) ?? path7.resolve(rawPath);
1319
+ const matches = Array.from(sessions.values()).filter((session) => session.closedAt === void 0 && session.repoRoot === repoRoot).map(toSummary);
1320
+ jsonResponse(res, 200, { repoRoot, sessions: matches });
1321
+ return true;
1322
+ }
1029
1323
  const getReviewParams = matchRoute(method, url, "GET", "/api/reviews/:id");
1030
1324
  if (getReviewParams) {
1031
1325
  const session = sessions.get(getReviewParams.id);
@@ -1060,9 +1354,7 @@ async function handleApiRequest(req, res) {
1060
1354
  try {
1061
1355
  const body = await readBody(req);
1062
1356
  const result = JSON.parse(body);
1063
- session.result = result;
1064
- session.status = "submitted";
1065
- recordReviewHistory(session, result);
1357
+ recordVerdict(session, result);
1066
1358
  if (result.decision === "dismissed") {
1067
1359
  broadcastSessionRemoved(postResultParams.id);
1068
1360
  } else {
@@ -1081,6 +1373,7 @@ async function handleApiRequest(req, res) {
1081
1373
  jsonResponse(res, 404, { error: "Session not found" });
1082
1374
  return true;
1083
1375
  }
1376
+ touch(session);
1084
1377
  if (session.result) {
1085
1378
  jsonResponse(res, 200, { result: session.result, status: "submitted" });
1086
1379
  } else {
@@ -1126,24 +1419,44 @@ async function handleApiRequest(req, res) {
1126
1419
  }
1127
1420
  try {
1128
1421
  const body = await readBody(req);
1129
- const { file, line, body: annotationBody, type, confidence, category, source } = JSON.parse(body);
1422
+ const { file, line, side, body: annotationBody, type, confidence, category, source, author } = JSON.parse(body);
1423
+ if (author !== void 0 && author !== "agent" && author !== "reviewer") {
1424
+ jsonResponse(res, 400, { error: `Unknown author: ${String(author)}` });
1425
+ return true;
1426
+ }
1427
+ if (side !== void 0 && side !== "old" && side !== "new") {
1428
+ jsonResponse(res, 400, { error: `Unknown side: ${String(side)}` });
1429
+ return true;
1430
+ }
1130
1431
  const annotation = {
1131
1432
  id: randomUUID2(),
1132
1433
  sessionId: session.id,
1133
1434
  file,
1134
1435
  line,
1436
+ // Agents annotate lines of the changed file; only the dashboard can
1437
+ // point at a deleted line, and it says so.
1438
+ side: side ?? "new",
1135
1439
  body: annotationBody,
1136
1440
  type,
1137
1441
  confidence: confidence ?? 1,
1138
1442
  category: category ?? "other",
1139
1443
  source,
1140
- createdAt: Date.now()
1444
+ createdAt: Date.now(),
1445
+ author: author ?? "agent",
1446
+ replies: []
1141
1447
  };
1142
1448
  session.annotations.push(annotation);
1449
+ touch(session);
1143
1450
  sendToSessionClients(session.id, {
1144
1451
  type: "annotation:added",
1145
1452
  payload: annotation
1146
1453
  });
1454
+ if (annotation.type === "warning") {
1455
+ if (hasViewersForSession(session.id)) {
1456
+ session.attentionClearedAt = annotation.createdAt;
1457
+ }
1458
+ broadcastSessionUpdate(session);
1459
+ }
1147
1460
  jsonResponse(res, 200, { annotationId: annotation.id });
1148
1461
  } catch {
1149
1462
  jsonResponse(res, 400, { error: "Invalid request body" });
@@ -1160,6 +1473,94 @@ async function handleApiRequest(req, res) {
1160
1473
  jsonResponse(res, 200, { annotations: session.annotations });
1161
1474
  return true;
1162
1475
  }
1476
+ const replyParams = matchRoute(method, url, "POST", "/api/reviews/:id/annotations/:annotationId/replies");
1477
+ if (replyParams) {
1478
+ const session = sessions.get(replyParams.id);
1479
+ if (!session) {
1480
+ jsonResponse(res, 404, { error: "Session not found" });
1481
+ return true;
1482
+ }
1483
+ const annotation = session.annotations.find((a) => a.id === replyParams.annotationId);
1484
+ if (!annotation) {
1485
+ jsonResponse(res, 404, { error: "Annotation not found" });
1486
+ return true;
1487
+ }
1488
+ try {
1489
+ const { author, agent, body: replyBody } = JSON.parse(await readBody(req));
1490
+ if (author !== "agent" && author !== "reviewer") {
1491
+ jsonResponse(res, 400, { error: 'author must be "agent" or "reviewer"' });
1492
+ return true;
1493
+ }
1494
+ if (!replyBody?.trim()) {
1495
+ jsonResponse(res, 400, { error: "A reply needs a body" });
1496
+ return true;
1497
+ }
1498
+ const reply = {
1499
+ id: randomUUID2(),
1500
+ author,
1501
+ ...author === "agent" ? { agent: agent ?? "unknown" } : {},
1502
+ body: replyBody,
1503
+ createdAt: Date.now()
1504
+ };
1505
+ annotation.replies = [...annotation.replies ?? [], reply];
1506
+ touch(session);
1507
+ sendToSessionClients(session.id, { type: "annotation:updated", payload: annotation });
1508
+ jsonResponse(res, 200, { replyId: reply.id, annotation });
1509
+ } catch {
1510
+ jsonResponse(res, 400, { error: "Invalid request body" });
1511
+ }
1512
+ return true;
1513
+ }
1514
+ const githubReviewParams = matchRoute(method, url, "POST", "/api/reviews/:id/github-review");
1515
+ if (githubReviewParams) {
1516
+ const session = sessions.get(githubReviewParams.id);
1517
+ if (!session) {
1518
+ jsonResponse(res, 404, { error: "Session not found" });
1519
+ return true;
1520
+ }
1521
+ const pr = session.payload.metadata.githubPr;
1522
+ if (!pr) {
1523
+ jsonResponse(res, 400, { error: "Not a pull request review" });
1524
+ return true;
1525
+ }
1526
+ let submission;
1527
+ try {
1528
+ submission = JSON.parse(await readBody(req));
1529
+ } catch {
1530
+ jsonResponse(res, 400, { error: "Invalid request body" });
1531
+ return true;
1532
+ }
1533
+ const built = buildGitHubReview(submission, session.annotations);
1534
+ if ("error" in built) {
1535
+ jsonResponse(res, 400, { error: built.error });
1536
+ return true;
1537
+ }
1538
+ const { resolveGitHubToken, createGitHubClient, submitGitHubReview } = await import("./src-DNNNQ2RD.js");
1539
+ let token;
1540
+ try {
1541
+ token = resolveGitHubToken();
1542
+ } catch (err) {
1543
+ jsonResponse(res, 401, { error: err instanceof Error ? err.message : String(err) });
1544
+ return true;
1545
+ }
1546
+ let posted;
1547
+ try {
1548
+ posted = await submitGitHubReview(createGitHubClient(token), pr.owner, pr.repo, pr.number, built.review);
1549
+ } catch (err) {
1550
+ jsonResponse(res, 502, {
1551
+ error: `GitHub rejected the review: ${err instanceof Error ? err.message : String(err)}`
1552
+ });
1553
+ return true;
1554
+ }
1555
+ recordVerdict(session, {
1556
+ decision: PR_EVENT_DECISION[built.review.event],
1557
+ comments: [],
1558
+ summary: built.review.body || void 0
1559
+ });
1560
+ broadcastSessionUpdate(session);
1561
+ jsonResponse(res, 200, { url: posted.url });
1562
+ return true;
1563
+ }
1163
1564
  const dismissAnnotationParams = matchRoute(method, url, "POST", "/api/reviews/:id/annotations/:annotationId/dismiss");
1164
1565
  if (dismissAnnotationParams) {
1165
1566
  const session = sessions.get(dismissAnnotationParams.id);
@@ -1177,6 +1578,9 @@ async function handleApiRequest(req, res) {
1177
1578
  type: "annotation:dismissed",
1178
1579
  payload: { annotationId: dismissAnnotationParams.annotationId }
1179
1580
  });
1581
+ if (annotation.type === "warning") {
1582
+ broadcastSessionUpdate(session);
1583
+ }
1180
1584
  jsonResponse(res, 200, { ok: true });
1181
1585
  return true;
1182
1586
  }
@@ -1257,7 +1661,8 @@ async function handleApiRequest(req, res) {
1257
1661
  }
1258
1662
  try {
1259
1663
  const body = await readBody(req);
1260
- const { ref } = JSON.parse(body);
1664
+ const { ref: requestedRef, reset } = JSON.parse(body);
1665
+ const ref = reset ? session.openedDiffRef ?? DEFAULT_DIFF_REF : requestedRef;
1261
1666
  if (!ref) {
1262
1667
  jsonResponse(res, 400, { error: "Missing ref in request body" });
1263
1668
  return true;
@@ -1277,6 +1682,7 @@ async function handleApiRequest(req, res) {
1277
1682
  session.lastDiffSet = newDiffSet;
1278
1683
  stopSessionWatcher(session.id);
1279
1684
  session.diffRef = ref;
1685
+ touch(session);
1280
1686
  if (hasConnectedClients()) {
1281
1687
  startSessionWatcher(session.id);
1282
1688
  }
@@ -1337,10 +1743,18 @@ async function startGlobalServer(options = {}) {
1337
1743
  wsPort: preferredWsPort = 24681,
1338
1744
  silent = false,
1339
1745
  dev = false,
1340
- pollInterval = 2e3,
1746
+ pollInterval = DEFAULT_WATCH_SCHEDULE.viewedMs,
1747
+ unviewedPollInterval = DEFAULT_WATCH_SCHEDULE.unviewedMs,
1748
+ unviewedPollMaxInterval = DEFAULT_WATCH_SCHEDULE.unviewedMaxMs,
1749
+ idleSessionTtl = IDLE_SESSION_TTL_MS,
1750
+ cleanupInterval = CLEANUP_INTERVAL_MS,
1341
1751
  openBrowser = true
1342
1752
  } = options;
1343
- serverPollInterval = pollInterval;
1753
+ watchSchedule = {
1754
+ viewedMs: pollInterval,
1755
+ unviewedMs: unviewedPollInterval,
1756
+ unviewedMaxMs: unviewedPollMaxInterval
1757
+ };
1344
1758
  const [httpPort, wsPort] = await Promise.all([
1345
1759
  getPort({ port: preferredHttpPort }),
1346
1760
  getPort({ port: preferredWsPort })
@@ -1370,26 +1784,14 @@ async function startGlobalServer(options = {}) {
1370
1784
  const url = new URL(req.url ?? "/", `http://localhost:${wsPort}`);
1371
1785
  const sessionId = url.searchParams.get("sessionId");
1372
1786
  if (sessionId) {
1373
- clientSessions.set(ws, sessionId);
1374
1787
  const session = sessions.get(sessionId);
1375
1788
  if (session) {
1376
- session.status = "in_review";
1377
- session.hasNewChanges = false;
1378
- broadcastSessionUpdate(session);
1379
- const msg = {
1380
- type: "review:init",
1381
- payload: session.payload
1382
- };
1383
- ws.send(JSON.stringify(msg));
1384
- for (const annotation of session.annotations) {
1385
- ws.send(JSON.stringify({
1386
- type: "annotation:added",
1387
- payload: annotation
1388
- }));
1389
- }
1789
+ attachViewer(ws, session);
1790
+ } else {
1791
+ clientSessions.set(ws, sessionId);
1390
1792
  }
1391
1793
  } else {
1392
- const summaries = Array.from(sessions.values()).map(toSummary);
1794
+ const summaries = listedSummaries();
1393
1795
  const msg = {
1394
1796
  type: "session:list",
1395
1797
  payload: summaries
@@ -1398,20 +1800,7 @@ async function startGlobalServer(options = {}) {
1398
1800
  if (summaries.length === 1) {
1399
1801
  const session = sessions.get(summaries[0].id);
1400
1802
  if (session) {
1401
- clientSessions.set(ws, session.id);
1402
- session.status = "in_review";
1403
- session.hasNewChanges = false;
1404
- broadcastSessionUpdate(session);
1405
- ws.send(JSON.stringify({
1406
- type: "review:init",
1407
- payload: session.payload
1408
- }));
1409
- for (const annotation of session.annotations) {
1410
- ws.send(JSON.stringify({
1411
- type: "annotation:added",
1412
- payload: annotation
1413
- }));
1414
- }
1803
+ attachViewer(ws, session);
1415
1804
  }
1416
1805
  }
1417
1806
  }
@@ -1423,9 +1812,7 @@ async function startGlobalServer(options = {}) {
1423
1812
  if (sid) {
1424
1813
  const session = sessions.get(sid);
1425
1814
  if (session) {
1426
- session.result = msg.payload;
1427
- session.status = "submitted";
1428
- recordReviewHistory(session, msg.payload);
1815
+ recordVerdict(session, msg.payload);
1429
1816
  if (msg.payload.decision === "dismissed") {
1430
1817
  broadcastSessionRemoved(sid);
1431
1818
  } else {
@@ -1436,29 +1823,18 @@ async function startGlobalServer(options = {}) {
1436
1823
  } else if (msg.type === "session:select") {
1437
1824
  const session = sessions.get(msg.payload.sessionId);
1438
1825
  if (session) {
1439
- clientSessions.set(ws, session.id);
1440
- session.status = "in_review";
1441
- session.hasNewChanges = false;
1442
- startSessionWatcher(session.id);
1443
- broadcastSessionUpdate(session);
1444
- ws.send(JSON.stringify({
1445
- type: "review:init",
1446
- payload: session.payload
1447
- }));
1448
- for (const annotation of session.annotations) {
1449
- ws.send(JSON.stringify({
1450
- type: "annotation:added",
1451
- payload: annotation
1452
- }));
1453
- }
1826
+ attachViewer(ws, session);
1454
1827
  }
1455
1828
  } else if (msg.type === "session:close") {
1456
1829
  const closedId = msg.payload.sessionId;
1457
1830
  stopSessionWatcher(closedId);
1458
1831
  const closedSession = sessions.get(closedId);
1459
- if (closedSession && !closedSession.result) {
1460
- closedSession.result = { decision: "dismissed", comments: [] };
1461
- closedSession.status = "submitted";
1832
+ if (closedSession) {
1833
+ closedSession.closedAt = Date.now();
1834
+ if (!closedSession.result) {
1835
+ closedSession.result = { decision: "dismissed", comments: [] };
1836
+ closedSession.status = "submitted";
1837
+ }
1462
1838
  }
1463
1839
  broadcastSessionRemoved(closedId);
1464
1840
  } else if (msg.type === "diff:change_ref") {
@@ -1519,19 +1895,18 @@ async function startGlobalServer(options = {}) {
1519
1895
  function cleanupExpiredSessions() {
1520
1896
  const now = Date.now();
1521
1897
  for (const [id, session] of sessions.entries()) {
1522
- if (session.source === "manual" && session.status !== "submitted") {
1523
- continue;
1524
- }
1898
+ const idle = session.status !== "submitted" && !hasViewersForSession(id) && now - session.lastActivityAt > idleSessionTtl;
1525
1899
  const age = now - session.createdAt;
1526
- const expired = session.status === "submitted" && age > SUBMITTED_TTL_MS || session.status === "pending" && age > ABANDONED_TTL_MS;
1527
- if (expired) {
1900
+ const ageRulesApply = !(session.source === "manual" && session.status !== "submitted");
1901
+ const expiredByAge = ageRulesApply && (session.status === "submitted" && age > SUBMITTED_TTL_MS || session.status === "pending" && age > ABANDONED_TTL_MS);
1902
+ if (idle || expiredByAge) {
1528
1903
  stopSessionWatcher(id);
1529
1904
  sessions.delete(id);
1530
1905
  broadcastSessionRemoved(id);
1531
1906
  }
1532
1907
  }
1533
1908
  }
1534
- const cleanupTimer = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
1909
+ const cleanupTimer = setInterval(cleanupExpiredSessions, cleanupInterval);
1535
1910
  const serverInfo = {
1536
1911
  httpPort,
1537
1912
  wsPort,
@@ -1588,12 +1963,54 @@ Waiting for reviews...
1588
1963
  return { httpPort, wsPort, stop };
1589
1964
  }
1590
1965
 
1966
+ // packages/core/src/mcp-tools.ts
1967
+ var MCP_TOOL_NAMES = [
1968
+ "open_review",
1969
+ "get_review_result",
1970
+ "update_review_context",
1971
+ "get_diff",
1972
+ "analyze_diff",
1973
+ "annotate",
1974
+ "get_review_state",
1975
+ "get_review_comments",
1976
+ "reply",
1977
+ "wait_for_comments",
1978
+ "get_user_focus",
1979
+ "get_pr_context",
1980
+ "get_file_diff",
1981
+ "get_file_context"
1982
+ ];
1983
+ var RETIRED_MCP_TOOL_NAMES = [
1984
+ "add_annotation",
1985
+ "add_review_comment",
1986
+ "flag_for_attention",
1987
+ "review_pr"
1988
+ ];
1989
+ function mcpToolPermission(name) {
1990
+ return `mcp__diffprism__${name}`;
1991
+ }
1992
+
1591
1993
  export {
1592
1994
  readServerFile,
1593
1995
  isServerAlive,
1996
+ DEFAULT_DIFF_REF,
1997
+ COMMIT_GATE_DIFF_REF,
1998
+ DIFF_REF_DESCRIPTION,
1999
+ getBuildInfo,
2000
+ describeVersion,
2001
+ currentVersion,
2002
+ recordError,
2003
+ readLastError,
2004
+ REPORT_HINT,
2005
+ buildFeedbackUrl,
1594
2006
  startGlobalServer,
2007
+ awaitingAgent,
1595
2008
  ensureServer,
2009
+ ReviewTimeoutError,
2010
+ ReviewerAskedError,
2011
+ waitForDecision,
1596
2012
  submitReviewToServer,
1597
- getBuildInfo,
1598
- describeVersion
2013
+ MCP_TOOL_NAMES,
2014
+ RETIRED_MCP_TOOL_NAMES,
2015
+ mcpToolPermission
1599
2016
  };