getgloss 0.13.2 → 0.14.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.
@@ -10,7 +10,7 @@ import path from "path";
10
10
  // package.json
11
11
  var package_default = {
12
12
  name: "getgloss",
13
- version: "0.13.2",
13
+ version: "0.14.0",
14
14
  description: "Local browser-based diff review for coding-agent loops.",
15
15
  type: "module",
16
16
  packageManager: "pnpm@10.33.2",
@@ -92,6 +92,7 @@ var package_default = {
92
92
 
93
93
  // src/shared/paths.ts
94
94
  var packageVersion = package_default.version;
95
+ var protocolVersion = 1;
95
96
  function expandHome(input) {
96
97
  if (input === "~") {
97
98
  return homedir();
@@ -149,6 +150,9 @@ function globalReviewMarkdownFile(reviewId) {
149
150
  function globalReviewResolvedFile(reviewId) {
150
151
  return path.join(globalReviewDir(reviewId), "resolved.json");
151
152
  }
153
+ function globalReviewEventsFile(reviewId) {
154
+ return path.join(globalReviewDir(reviewId), "events.jsonl");
155
+ }
152
156
  async function ensureDir(dir) {
153
157
  await mkdir(dir, { recursive: true });
154
158
  }
@@ -205,6 +209,14 @@ var SOURCE_PEEK_MAX_BYTES = 35e4;
205
209
  var SOURCE_PEEK_RANGE_MAX_LINES = 240;
206
210
  var REVIEW_SCOPE_MODES = ["all", "single", "range"];
207
211
  var RESOLUTION_STATUSES = ["partial", "resolved"];
212
+ var REVIEW_EVENT_ACTORS = ["human", "agent", "system"];
213
+ var AGENT_STATUSES = ["claimed", "working", "blocked", "completed", "failed"];
214
+ var REVIEW_UPDATE_REASONS = [
215
+ "review-resolved",
216
+ "comment-resolved",
217
+ "comment-reopened",
218
+ "turn-created"
219
+ ];
208
220
  var OPEN_FILE_SCOPES = ["review", "repo"];
209
221
  var OPEN_FILE_TARGETS = [
210
222
  "default",
@@ -253,7 +265,7 @@ function parseJsonValue(value, guard, label) {
253
265
  return value;
254
266
  }
255
267
  function isServerInfo(value) {
256
- return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir) && isOptionalString(value.cwd) && isOptionalString(value.daemonPath);
268
+ return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isOptionalNumber(value.protocolVersion) && isString(value.startedAt) && isString(value.stateDir) && isOptionalString(value.cwd) && isOptionalString(value.daemonPath);
257
269
  }
258
270
  function isClearReviewsRequest(value) {
259
271
  return isRecord(value) && isOptionalNonNegativeInteger(value.olderThanDays) && isOptionalBoolean(value.dryRun);
@@ -276,6 +288,12 @@ function isSourcePeekRequest(value) {
276
288
  function isSourcePeekRangeRequest(value) {
277
289
  return isRecord(value) && isString(value.filePath) && isOptionalString(value.turnId) && isDiffContextSource(value.source) && isOneOf(value.side, SIDES) && isPositiveInteger(value.startLine) && isPositiveInteger(value.lineCount) && value.lineCount <= SOURCE_PEEK_RANGE_MAX_LINES;
278
290
  }
291
+ function isAgentClaimRequest(value) {
292
+ return isRecord(value) && isOptionalString(value.message) && isOptionalString(value.turn);
293
+ }
294
+ function isAgentNoteRequest(value) {
295
+ return isRecord(value) && isString(value.message) && isOptional(value.status, isAgentStatus) && isOptionalString(value.turn);
296
+ }
279
297
  function isSubmitReviewRequest(value) {
280
298
  return isRecord(value) && isArrayOf(value.comments, isComment) && isOptional(value.reviewScope, isReviewScope);
281
299
  }
@@ -300,6 +318,31 @@ function isFeedbackBundle(value) {
300
318
  function isResolutionBundle(value) {
301
319
  return isRecord(value) && isString(value.reviewId) && isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isResolutionStatus(value.status) && isNullableString(value.summary) && isNullableString(value.resolvedAt) && isArrayOf(value.comments, isResolvedComment);
302
320
  }
321
+ function isReviewEvent(value) {
322
+ if (!isRecord(value) || !isString(value.reviewId) || !isString(value.type)) {
323
+ return false;
324
+ }
325
+ if (!hasValidReviewEventEnvelope(value)) {
326
+ return false;
327
+ }
328
+ switch (value.type) {
329
+ case "review.opened":
330
+ case "review.cancelled":
331
+ return true;
332
+ case "review.turn.created":
333
+ return isString(value.turnId) && isNumber(value.turnIndex) && isBoolean(value.reused);
334
+ case "review.submitted":
335
+ return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isRecord(value.counts) && isNumber(value.counts.files) && isNumber(value.counts.comments);
336
+ case "review.updated":
337
+ return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isReviewUpdateReason(value.reason) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.counts);
338
+ case "agent.claimed":
339
+ return isString(value.turnId) && isNumber(value.turnIndex) && value.status === "claimed" && isOptionalString(value.message);
340
+ case "agent.note":
341
+ return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isOptional(value.status, isAgentStatus) && isString(value.message);
342
+ default:
343
+ return false;
344
+ }
345
+ }
303
346
  function isReviewTurnMeta(value) {
304
347
  return isRecord(value) && isString(value.id) && isNumber(value.index) && isReviewStatus(value.status) && isString(value.createdAt) && isOptionalString(value.submittedAt) && isOptionalString(value.resolvedAt) && isString(value.artifactDir) && isString(value.diffPath) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath) && isOptionalString(value.resolvedPath);
305
348
  }
@@ -391,6 +434,18 @@ function isOpenFileScope(value) {
391
434
  function isOpenFileTarget(value) {
392
435
  return isOneOf(value, OPEN_FILE_TARGETS);
393
436
  }
437
+ function isReviewUpdateReason(value) {
438
+ return isOneOf(value, REVIEW_UPDATE_REASONS);
439
+ }
440
+ function isReviewEventActor(value) {
441
+ return isOneOf(value, REVIEW_EVENT_ACTORS);
442
+ }
443
+ function isAgentStatus(value) {
444
+ return isOneOf(value, AGENT_STATUSES);
445
+ }
446
+ function hasValidReviewEventEnvelope(value) {
447
+ return isOptionalString(value.id) && isOptionalNumber(value.seq) && isOptionalString(value.createdAt) && isOptional(value.actor, isReviewEventActor);
448
+ }
394
449
  function isRecord(value) {
395
450
  return typeof value === "object" && value !== null && !Array.isArray(value);
396
451
  }
@@ -585,50 +640,6 @@ import { fileURLToPath } from "url";
585
640
  import { Hono } from "hono";
586
641
  import { streamSSE } from "hono/streaming";
587
642
 
588
- // src/shared/comments.ts
589
- function isLineComment(comment) {
590
- return comment.kind === void 0 || comment.kind === "line";
591
- }
592
- function compareCommentsByLocation(a, b) {
593
- const aIsLine = isLineComment(a);
594
- const bIsLine = isLineComment(b);
595
- if (!aIsLine || !bIsLine) {
596
- if (aIsLine !== bIsLine) {
597
- return aIsLine ? 1 : -1;
598
- }
599
- return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
600
- }
601
- return a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine || a.endLine - b.endLine || a.side.localeCompare(b.side);
602
- }
603
- function countCommentFiles(comments) {
604
- const filePaths = /* @__PURE__ */ new Set();
605
- for (const comment of comments) {
606
- if (isLineComment(comment)) {
607
- filePaths.add(comment.filePath);
608
- }
609
- }
610
- return filePaths.size;
611
- }
612
- function formatLineRange(range, options = {}) {
613
- const startLine = Math.min(range.startLine, range.endLine);
614
- const endLine = Math.max(range.startLine, range.endLine);
615
- if (startLine === endLine) {
616
- return `${range.side}${startLine}`;
617
- }
618
- const endPrefix = options.repeatSideOnEnd === false ? "" : range.side;
619
- return `${range.side}${startLine}-${endPrefix}${endLine}`;
620
- }
621
- function resolutionCounts(feedback, resolvedComments = []) {
622
- const comments = feedback?.comments ?? [];
623
- const resolvedIds = new Set(resolvedComments.map((comment) => comment.commentId));
624
- const resolved = comments.filter((comment) => resolvedIds.has(comment.id)).length;
625
- return {
626
- total: comments.length,
627
- resolved,
628
- open: comments.length - resolved
629
- };
630
- }
631
-
632
643
  // src/shared/git-diff.ts
633
644
  import { readFile as readFile2 } from "fs/promises";
634
645
  import path5 from "path";
@@ -884,11 +895,6 @@ function splitFileLines(contents) {
884
895
  return lines;
885
896
  }
886
897
 
887
- // src/shared/reviews.ts
888
- function isResolvableReviewStatus(status) {
889
- return status === "submitted" || status === "resolved";
890
- }
891
-
892
898
  // src/server/local-open.ts
893
899
  import { execFile } from "child_process";
894
900
  import { access } from "fs/promises";
@@ -1652,7 +1658,7 @@ function escapeExtendedGrep(value) {
1652
1658
 
1653
1659
  // src/server/store.ts
1654
1660
  import { createHash } from "crypto";
1655
- import { readdir as readdir2, readFile as readFile5 } from "fs/promises";
1661
+ import { appendFile, readdir as readdir2, readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
1656
1662
  import path8 from "path";
1657
1663
  import { ulid } from "ulid";
1658
1664
 
@@ -1870,6 +1876,50 @@ function cleanupResult({
1870
1876
  };
1871
1877
  }
1872
1878
 
1879
+ // src/shared/comments.ts
1880
+ function isLineComment(comment) {
1881
+ return comment.kind === void 0 || comment.kind === "line";
1882
+ }
1883
+ function compareCommentsByLocation(a, b) {
1884
+ const aIsLine = isLineComment(a);
1885
+ const bIsLine = isLineComment(b);
1886
+ if (!aIsLine || !bIsLine) {
1887
+ if (aIsLine !== bIsLine) {
1888
+ return aIsLine ? 1 : -1;
1889
+ }
1890
+ return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
1891
+ }
1892
+ return a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine || a.endLine - b.endLine || a.side.localeCompare(b.side);
1893
+ }
1894
+ function countCommentFiles(comments) {
1895
+ const filePaths = /* @__PURE__ */ new Set();
1896
+ for (const comment of comments) {
1897
+ if (isLineComment(comment)) {
1898
+ filePaths.add(comment.filePath);
1899
+ }
1900
+ }
1901
+ return filePaths.size;
1902
+ }
1903
+ function formatLineRange(range, options = {}) {
1904
+ const startLine = Math.min(range.startLine, range.endLine);
1905
+ const endLine = Math.max(range.startLine, range.endLine);
1906
+ if (startLine === endLine) {
1907
+ return `${range.side}${startLine}`;
1908
+ }
1909
+ const endPrefix = options.repeatSideOnEnd === false ? "" : range.side;
1910
+ return `${range.side}${startLine}-${endPrefix}${endLine}`;
1911
+ }
1912
+ function resolutionCounts(feedback, resolvedComments = []) {
1913
+ const comments = feedback?.comments ?? [];
1914
+ const resolvedIds = new Set(resolvedComments.map((comment) => comment.commentId));
1915
+ const resolved = comments.filter((comment) => resolvedIds.has(comment.id)).length;
1916
+ return {
1917
+ total: comments.length,
1918
+ resolved,
1919
+ open: comments.length - resolved
1920
+ };
1921
+ }
1922
+
1873
1923
  // src/shared/review-scope.ts
1874
1924
  var ALL_REVIEW_SCOPE = { mode: "all" };
1875
1925
  function normalizeReviewScope(diff, scope = ALL_REVIEW_SCOPE) {
@@ -1992,6 +2042,11 @@ function firstNonEmptyLine(text) {
1992
2042
  return void 0;
1993
2043
  }
1994
2044
 
2045
+ // src/shared/reviews.ts
2046
+ function isResolvableReviewStatus(status) {
2047
+ return status === "submitted" || status === "resolved";
2048
+ }
2049
+
1995
2050
  // src/server/store.ts
1996
2051
  var ReviewStore = class {
1997
2052
  reviews = /* @__PURE__ */ new Map();
@@ -2013,8 +2068,11 @@ var ReviewStore = class {
2013
2068
  const record = normalizeRecord({ meta, turns: [turn], diff: turn.diff });
2014
2069
  this.reviews.set(id, record);
2015
2070
  await this.persistInitial(record, turn);
2016
- this.emit({ type: "review.opened", reviewId: id });
2017
- return record;
2071
+ const event = await this.appendReviewEvent(id, "system", {
2072
+ type: "review.opened",
2073
+ reviewId: id
2074
+ });
2075
+ return withEvents(record, [event]);
2018
2076
  }
2019
2077
  async appendTurn(id, diff) {
2020
2078
  const record = await this.get(id);
@@ -2027,14 +2085,18 @@ var ReviewStore = class {
2027
2085
  const latest = latestTurn(record);
2028
2086
  if (latest.status === "pending") {
2029
2087
  if (diffFingerprint(latest.diff) === diffFingerprint(diff)) {
2030
- this.emit({
2088
+ await this.appendReviewEvent(id, "system", {
2031
2089
  type: "review.turn.created",
2032
2090
  reviewId: id,
2033
2091
  turnId: latest.id,
2034
2092
  turnIndex: latest.index,
2035
2093
  reused: true
2036
2094
  });
2037
- return { record, turn: latest, reused: true };
2095
+ return {
2096
+ record: withEvents(record, await this.readEvents(id)),
2097
+ turn: latest,
2098
+ reused: true
2099
+ };
2038
2100
  }
2039
2101
  throw new Error(`Review ${id} already has a pending turn`);
2040
2102
  }
@@ -2049,14 +2111,18 @@ var ReviewStore = class {
2049
2111
  });
2050
2112
  this.reviews.set(id, nextRecord);
2051
2113
  await this.persistInitial(nextRecord, turn);
2052
- this.emit({
2114
+ await this.appendReviewEvent(id, "system", {
2053
2115
  type: "review.turn.created",
2054
2116
  reviewId: id,
2055
2117
  turnId: turn.id,
2056
2118
  turnIndex: turn.index,
2057
2119
  reused: false
2058
2120
  });
2059
- return { record: nextRecord, turn, reused: false };
2121
+ return {
2122
+ record: withEvents(nextRecord, await this.readEvents(id)),
2123
+ turn,
2124
+ reused: false
2125
+ };
2060
2126
  }
2061
2127
  async list() {
2062
2128
  await this.loadAllReviews();
@@ -2128,7 +2194,7 @@ var ReviewStore = class {
2128
2194
  writeTextFile(markdownPath, serializeFeedbackMarkdown(feedback))
2129
2195
  ]);
2130
2196
  await this.persistMeta(nextRecord);
2131
- this.emit({
2197
+ await this.appendReviewEvent(id, "human", {
2132
2198
  type: "review.submitted",
2133
2199
  reviewId: id,
2134
2200
  turnId: nextTurn.id,
@@ -2138,12 +2204,86 @@ var ReviewStore = class {
2138
2204
  comments: feedback.comments.length
2139
2205
  }
2140
2206
  });
2141
- return { record: nextRecord, feedbackPath, markdownPath, turn: nextTurn };
2207
+ return {
2208
+ record: withEvents(nextRecord, await this.readEvents(id)),
2209
+ feedbackPath,
2210
+ markdownPath,
2211
+ turn: nextTurn
2212
+ };
2142
2213
  }
2143
2214
  async feedback(id) {
2144
2215
  const record = await this.get(id);
2145
2216
  return record?.feedback ?? null;
2146
2217
  }
2218
+ async events(id, afterSeq = 0) {
2219
+ const record = await this.get(id);
2220
+ if (!record) {
2221
+ return [];
2222
+ }
2223
+ return this.readEvents(id, afterSeq);
2224
+ }
2225
+ async claim(id, message, turnSelector) {
2226
+ const record = await this.get(id);
2227
+ if (!record) {
2228
+ throw new Error(`Review ${id} not found`);
2229
+ }
2230
+ const turn = turnSelector ? this.resolveTurnSelector(record, turnSelector) : [...record.turns].reverse().find((candidate) => candidate.status === "submitted");
2231
+ if (!turn) {
2232
+ throw new Error(`Review ${id} has no submitted unresolved turn to claim`);
2233
+ }
2234
+ if (turn.status !== "submitted") {
2235
+ throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be claimed`);
2236
+ }
2237
+ this.assertResolvable(turn, id);
2238
+ if (!turn.feedbackPath || !turn.markdownPath) {
2239
+ throw new Error(`Review ${id} turn ${turn.index} is missing feedback paths`);
2240
+ }
2241
+ const event = await this.appendReviewEvent(id, "agent", {
2242
+ type: "agent.claimed",
2243
+ reviewId: id,
2244
+ turnId: turn.id,
2245
+ turnIndex: turn.index,
2246
+ status: "claimed",
2247
+ ...message ? { message } : {}
2248
+ });
2249
+ return {
2250
+ ok: true,
2251
+ reviewId: id,
2252
+ turnId: turn.id,
2253
+ turnIndex: turn.index,
2254
+ status: "claimed",
2255
+ feedbackPath: turn.feedbackPath,
2256
+ markdownPath: turn.markdownPath,
2257
+ artifactDir: turn.artifactDir,
2258
+ feedback: turn.feedback,
2259
+ ...turn.resolution ? { resolution: turn.resolution } : {},
2260
+ event
2261
+ };
2262
+ }
2263
+ async addAgentNote(id, message, status, turnSelector) {
2264
+ const record = await this.get(id);
2265
+ if (!record) {
2266
+ throw new Error(`Review ${id} not found`);
2267
+ }
2268
+ const turn = turnSelector ? this.resolveTurnSelector(record, turnSelector) : activeTurn(record);
2269
+ const event = await this.appendReviewEvent(id, "agent", {
2270
+ type: "agent.note",
2271
+ reviewId: id,
2272
+ turnId: turn.id,
2273
+ turnIndex: turn.index,
2274
+ ...status ? { status } : {},
2275
+ message
2276
+ });
2277
+ return {
2278
+ ok: true,
2279
+ reviewId: id,
2280
+ turnId: turn.id,
2281
+ turnIndex: turn.index,
2282
+ ...status ? { status } : {},
2283
+ message,
2284
+ event
2285
+ };
2286
+ }
2147
2287
  async markResolved(id, summary, turnSelector) {
2148
2288
  const record = await this.get(id);
2149
2289
  if (!record) {
@@ -2272,6 +2412,75 @@ var ReviewStore = class {
2272
2412
  }
2273
2413
  };
2274
2414
  }
2415
+ async appendReviewEvent(reviewId, actor, input) {
2416
+ const persistedEvents = await this.readPersistedEvents(reviewId);
2417
+ let existingEvents = persistedEvents ?? [];
2418
+ if (!persistedEvents && input.type !== "review.opened") {
2419
+ const record2 = this.reviews.get(reviewId) ?? await this.loadKnownReview(reviewId);
2420
+ existingEvents = record2 ? synthesizeReviewEvents(record2) : [];
2421
+ if (existingEvents.length > 0) {
2422
+ await ensureDir(globalReviewDir(reviewId));
2423
+ await writeFile3(
2424
+ globalReviewEventsFile(reviewId),
2425
+ `${existingEvents.map((event2) => JSON.stringify(event2)).join("\n")}
2426
+ `
2427
+ );
2428
+ }
2429
+ }
2430
+ const event = {
2431
+ ...input,
2432
+ id: ulid(),
2433
+ seq: nextEventSeq(existingEvents),
2434
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2435
+ actor
2436
+ };
2437
+ await ensureDir(globalReviewDir(reviewId));
2438
+ await appendFile(globalReviewEventsFile(reviewId), `${JSON.stringify(event)}
2439
+ `);
2440
+ const record = this.reviews.get(reviewId);
2441
+ if (record) {
2442
+ this.reviews.set(reviewId, withEvents(record, [...existingEvents, event]));
2443
+ }
2444
+ this.emit(event);
2445
+ return event;
2446
+ }
2447
+ async readEvents(reviewId, afterSeq = 0) {
2448
+ const persistedEvents = await this.readPersistedEvents(reviewId);
2449
+ if (!persistedEvents) {
2450
+ const record = this.reviews.get(reviewId) ?? await this.loadKnownReview(reviewId);
2451
+ if (!record) {
2452
+ return [];
2453
+ }
2454
+ const synthesized = synthesizeReviewEvents(record);
2455
+ if (synthesized.length > 0) {
2456
+ await ensureDir(globalReviewDir(reviewId));
2457
+ await writeFile3(
2458
+ globalReviewEventsFile(reviewId),
2459
+ `${synthesized.map((event) => JSON.stringify(event)).join("\n")}
2460
+ `
2461
+ );
2462
+ }
2463
+ this.reviews.set(reviewId, withEvents(record, synthesized));
2464
+ return synthesized.filter((event) => (event.seq ?? 0) > afterSeq);
2465
+ }
2466
+ return persistedEvents.filter((event) => (event.seq ?? 0) > afterSeq);
2467
+ }
2468
+ async readPersistedEvents(reviewId) {
2469
+ let raw;
2470
+ try {
2471
+ raw = await readFile5(globalReviewEventsFile(reviewId), "utf8");
2472
+ } catch (error) {
2473
+ if (isFileNotFound(error)) {
2474
+ return null;
2475
+ }
2476
+ throw new Error(`Could not read review events for ${reviewId}: ${formatError(error)}`, {
2477
+ cause: error
2478
+ });
2479
+ }
2480
+ return raw.split("\n").map((line) => line.trim()).filter(Boolean).map(
2481
+ (line) => parseJsonFile(line, isReviewEvent, "review event", globalReviewEventsFile(reviewId))
2482
+ ).filter((event) => event.reviewId === reviewId);
2483
+ }
2275
2484
  emit(event) {
2276
2485
  for (const listener of this.listeners.get(event.reviewId) ?? []) {
2277
2486
  listener(event);
@@ -2357,7 +2566,9 @@ var ReviewStore = class {
2357
2566
  diff: latest.diff
2358
2567
  });
2359
2568
  this.reviews.set(id, record);
2360
- return record;
2569
+ const withTimeline = withEvents(record, await this.readEvents(id));
2570
+ this.reviews.set(id, withTimeline);
2571
+ return withTimeline;
2361
2572
  }
2362
2573
  async loadReviewFromTurnsOnly(id) {
2363
2574
  const turns = await this.loadPersistedTurns(id);
@@ -2380,8 +2591,10 @@ var ReviewStore = class {
2380
2591
  diff: latest.diff
2381
2592
  });
2382
2593
  this.reviews.set(id, record);
2383
- await this.persistMeta(record);
2384
- return record;
2594
+ const withTimeline = withEvents(record, await this.readEvents(id));
2595
+ this.reviews.set(id, withTimeline);
2596
+ await this.persistMeta(withTimeline);
2597
+ return withTimeline;
2385
2598
  }
2386
2599
  async loadPersistedTurns(id) {
2387
2600
  let entries;
@@ -2526,7 +2739,7 @@ var ReviewStore = class {
2526
2739
  path: resolvedPath,
2527
2740
  resolution
2528
2741
  };
2529
- this.emit({
2742
+ await this.appendReviewEvent(record.meta.id, "agent", {
2530
2743
  type: "review.updated",
2531
2744
  reviewId: record.meta.id,
2532
2745
  turnId: nextTurn.id,
@@ -2618,6 +2831,88 @@ function turnSummary(turn) {
2618
2831
  comments: resolutionCounts(turn.feedback, turn.resolution?.comments ?? [])
2619
2832
  };
2620
2833
  }
2834
+ function withEvents(record, events) {
2835
+ return { ...record, events: events.toSorted(compareReviewEvents) };
2836
+ }
2837
+ function nextEventSeq(events) {
2838
+ return Math.max(0, ...events.map((event) => event.seq ?? 0)) + 1;
2839
+ }
2840
+ function synthesizeReviewEvents(record) {
2841
+ let seq = 1;
2842
+ const next = (actor, input, createdAt) => ({
2843
+ ...input,
2844
+ id: ulid(),
2845
+ seq: seq++,
2846
+ createdAt,
2847
+ actor
2848
+ });
2849
+ const events = [
2850
+ next("system", { type: "review.opened", reviewId: record.meta.id }, record.meta.createdAt)
2851
+ ];
2852
+ const turns = record.turns.toSorted((a, b) => a.index - b.index);
2853
+ for (const turn of turns) {
2854
+ if (turn.index > 1) {
2855
+ events.push(
2856
+ next(
2857
+ "system",
2858
+ {
2859
+ type: "review.turn.created",
2860
+ reviewId: record.meta.id,
2861
+ turnId: turn.id,
2862
+ turnIndex: turn.index,
2863
+ reused: false
2864
+ },
2865
+ turn.createdAt
2866
+ )
2867
+ );
2868
+ }
2869
+ if (turn.feedback) {
2870
+ events.push(
2871
+ next(
2872
+ "human",
2873
+ {
2874
+ type: "review.submitted",
2875
+ reviewId: record.meta.id,
2876
+ turnId: turn.id,
2877
+ turnIndex: turn.index,
2878
+ counts: {
2879
+ files: countCommentFiles(turn.feedback.comments),
2880
+ comments: turn.feedback.comments.length
2881
+ }
2882
+ },
2883
+ turn.submittedAt ?? turn.feedback.timestamp
2884
+ )
2885
+ );
2886
+ }
2887
+ if (turn.feedback && turn.resolution) {
2888
+ const counts = resolutionCounts(turn.feedback, turn.resolution.comments);
2889
+ events.push(
2890
+ next(
2891
+ "agent",
2892
+ {
2893
+ type: "review.updated",
2894
+ reviewId: record.meta.id,
2895
+ turnId: turn.id,
2896
+ turnIndex: turn.index,
2897
+ reason: turn.resolution.status === "resolved" ? "review-resolved" : "comment-resolved",
2898
+ status: turn.status,
2899
+ resolutionStatus: turn.resolution.status,
2900
+ counts
2901
+ },
2902
+ turn.resolution.resolvedAt ?? turn.resolution.comments.at(-1)?.resolvedAt ?? turn.resolvedAt ?? turn.submittedAt ?? turn.createdAt
2903
+ )
2904
+ );
2905
+ }
2906
+ }
2907
+ return events.toSorted(compareReviewEvents);
2908
+ }
2909
+ function compareReviewEvents(left, right) {
2910
+ const seqDelta = (left.seq ?? 0) - (right.seq ?? 0);
2911
+ if (seqDelta !== 0) {
2912
+ return seqDelta;
2913
+ }
2914
+ return (left.createdAt ?? "").localeCompare(right.createdAt ?? "");
2915
+ }
2621
2916
  function reconcileTurn(meta, diff, feedback, resolution) {
2622
2917
  const status = resolution?.status === "resolved" ? "resolved" : feedback ? "submitted" : "pending";
2623
2918
  return {
@@ -2700,7 +2995,9 @@ function createApp(origin2, options = {}) {
2700
2995
  const response = {
2701
2996
  ok: true,
2702
2997
  version: packageVersion,
2998
+ protocolVersion,
2703
2999
  activeReviews: reviews.filter((review) => review.status === "pending").length,
3000
+ stateDir: globalStateDir(),
2704
3001
  ...options.health?.()
2705
3002
  };
2706
3003
  return c.json(response);
@@ -2782,14 +3079,56 @@ function createApp(origin2, options = {}) {
2782
3079
  }
2783
3080
  return c.json(feedback);
2784
3081
  });
3082
+ app.post("/api/reviews/:id/agent/claim", async (c) => {
3083
+ const id = c.req.param("id");
3084
+ const existing = await reviewStore.get(id);
3085
+ if (!existing) {
3086
+ return c.json({ error: "review not found" }, 404);
3087
+ }
3088
+ const parsed = await readOptionalJsonBody(c, isAgentClaimRequest, "agent claim request");
3089
+ if (!parsed.ok) {
3090
+ return parsed.response;
3091
+ }
3092
+ const body = parsed.body;
3093
+ try {
3094
+ const result = await reviewStore.claim(id, body.message, body.turn);
3095
+ options.onReviewActivity?.();
3096
+ return c.json(result);
3097
+ } catch (error) {
3098
+ return c.json({ error: formatError(error) }, statusForStoreError(error));
3099
+ }
3100
+ });
3101
+ app.post("/api/reviews/:id/agent/notes", async (c) => {
3102
+ const id = c.req.param("id");
3103
+ const existing = await reviewStore.get(id);
3104
+ if (!existing) {
3105
+ return c.json({ error: "review not found" }, 404);
3106
+ }
3107
+ const parsed = await readJsonBody(c, isAgentNoteRequest, "agent note request");
3108
+ if (!parsed.ok) {
3109
+ return parsed.response;
3110
+ }
3111
+ const body = parsed.body;
3112
+ try {
3113
+ const result = await reviewStore.addAgentNote(id, body.message, body.status, body.turn);
3114
+ options.onReviewActivity?.();
3115
+ return c.json(result);
3116
+ } catch (error) {
3117
+ return c.json({ error: formatError(error) }, statusForStoreError(error));
3118
+ }
3119
+ });
2785
3120
  app.get("/api/reviews/:id/events", async (c) => {
2786
3121
  const id = c.req.param("id");
2787
3122
  const record = await reviewStore.get(id);
2788
3123
  if (!record) {
2789
3124
  return c.json({ error: "review not found" }, 404);
2790
3125
  }
3126
+ const afterSeq = eventReplayAfterSeq(c);
2791
3127
  return streamSSE(c, async (stream) => {
2792
3128
  let closed = false;
3129
+ let replaying = true;
3130
+ let lastSentSeq = afterSeq;
3131
+ const bufferedEvents = [];
2793
3132
  let pending = Promise.resolve();
2794
3133
  let cleanup = null;
2795
3134
  let close = null;
@@ -2806,14 +3145,30 @@ function createApp(origin2, options = {}) {
2806
3145
  });
2807
3146
  unregisterEventStream = options.registerEventStream?.(() => close?.()) ?? null;
2808
3147
  const send = (event) => {
2809
- pending = pending.then(() => stream.writeSSE({ data: JSON.stringify(event) })).then(() => {
3148
+ const eventSeq = event.seq ?? 0;
3149
+ if (eventSeq > 0 && eventSeq <= lastSentSeq) {
3150
+ return;
3151
+ }
3152
+ if (eventSeq > 0) {
3153
+ lastSentSeq = eventSeq;
3154
+ }
3155
+ pending = pending.then(() => {
3156
+ const data = JSON.stringify(event);
3157
+ return event.seq ? stream.writeSSE({ data, id: String(event.seq) }) : stream.writeSSE({ data });
3158
+ }).then(() => {
2810
3159
  if (event.type === "review.cancelled") {
2811
3160
  close?.();
2812
3161
  }
2813
3162
  });
2814
3163
  void pending.catch(() => close?.());
2815
3164
  };
2816
- const unsubscribe = reviewStore.subscribe(id, send);
3165
+ const unsubscribe = reviewStore.subscribe(id, (event) => {
3166
+ if (replaying) {
3167
+ bufferedEvents.push(event);
3168
+ return;
3169
+ }
3170
+ send(event);
3171
+ });
2817
3172
  const heartbeat = setInterval(() => {
2818
3173
  pending = pending.then(async () => {
2819
3174
  await stream.write(`: keep-alive ${Date.now()}
@@ -2829,18 +3184,12 @@ function createApp(origin2, options = {}) {
2829
3184
  unregisterEventStream = null;
2830
3185
  };
2831
3186
  stream.onAbort(() => close?.());
2832
- send({ type: "review.opened", reviewId: id });
2833
- if (isResolvableReviewStatus(record.meta.status) && record.feedback) {
2834
- send({
2835
- type: "review.submitted",
2836
- reviewId: id,
2837
- turnId: record.meta.activeTurnId,
2838
- turnIndex: record.meta.turns?.find((turn) => turn.id === record.meta.activeTurnId)?.index,
2839
- counts: {
2840
- files: countCommentFiles(record.feedback.comments),
2841
- comments: record.feedback.comments.length
2842
- }
2843
- });
3187
+ for (const event of await reviewStore.events(id, afterSeq)) {
3188
+ send(event);
3189
+ }
3190
+ replaying = false;
3191
+ for (const event of bufferedEvents) {
3192
+ send(event);
2844
3193
  }
2845
3194
  await closedPromise;
2846
3195
  });
@@ -3310,6 +3659,12 @@ function serveRootFile(fileName, contentType) {
3310
3659
  }
3311
3660
  };
3312
3661
  }
3662
+ function eventReplayAfterSeq(c) {
3663
+ const url = new URL(c.req.url);
3664
+ const rawAfter = url.searchParams.get("after") ?? c.req.header("last-event-id") ?? "0";
3665
+ const afterSeq = Number(rawAfter);
3666
+ return Number.isInteger(afterSeq) && afterSeq > 0 ? afterSeq : 0;
3667
+ }
3313
3668
  async function readJsonBody(c, guard, label) {
3314
3669
  let body;
3315
3670
  try {
@@ -3329,6 +3684,34 @@ async function readJsonBody(c, guard, label) {
3329
3684
  };
3330
3685
  }
3331
3686
  }
3687
+ async function readOptionalJsonBody(c, guard, label) {
3688
+ let raw;
3689
+ try {
3690
+ raw = await c.req.text();
3691
+ } catch (error) {
3692
+ return {
3693
+ ok: false,
3694
+ response: c.json({ error: `invalid JSON body: ${formatError(error)}` }, 400)
3695
+ };
3696
+ }
3697
+ let body;
3698
+ try {
3699
+ body = raw.trim().length > 0 ? JSON.parse(raw) : {};
3700
+ } catch (error) {
3701
+ return {
3702
+ ok: false,
3703
+ response: c.json({ error: `invalid JSON body: ${formatError(error)}` }, 400)
3704
+ };
3705
+ }
3706
+ try {
3707
+ return { ok: true, body: parseJsonValue(body, guard, label) };
3708
+ } catch (error) {
3709
+ return {
3710
+ ok: false,
3711
+ response: c.json({ error: formatError(error) }, 400)
3712
+ };
3713
+ }
3714
+ }
3332
3715
  function isPathWithin(parentPath, childPath) {
3333
3716
  const relative = path9.relative(parentPath, childPath);
3334
3717
  return relative === "" || !relative.startsWith("..") && !path9.isAbsolute(relative);
@@ -3473,6 +3856,7 @@ await writeServerInfo({
3473
3856
  pid: process.pid,
3474
3857
  port,
3475
3858
  version: packageVersion,
3859
+ protocolVersion,
3476
3860
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3477
3861
  stateDir: globalStateDir(),
3478
3862
  cwd: process.cwd(),