getgloss 0.8.4 → 0.9.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,4 +1,5 @@
1
1
  // src/server/daemon.ts
2
+ import { fileURLToPath as fileURLToPath2 } from "url";
2
3
  import { serve } from "@hono/node-server";
3
4
 
4
5
  // src/shared/paths.ts
@@ -9,7 +10,7 @@ import path from "path";
9
10
  // package.json
10
11
  var package_default = {
11
12
  name: "getgloss",
12
- version: "0.8.4",
13
+ version: "0.9.0",
13
14
  description: "Local browser-based diff review for coding-agent loops.",
14
15
  type: "module",
15
16
  packageManager: "pnpm@10.33.2",
@@ -198,6 +199,7 @@ var REVIEW_STATUSES = ["pending", "submitted", "cancelled", "resolved"];
198
199
  var DIFF_LINE_TYPES = ["context", "add", "delete"];
199
200
  var DIFF_SCOPE_MODES = ["working", "branch", "explicit"];
200
201
  var DIFF_FALLBACK_REASONS = ["working-tree-clean", "missing-branch-base"];
202
+ var DIFF_CONTEXT_MAX_LINES = 500;
201
203
  var REVIEW_SCOPE_MODES = ["all", "single", "range"];
202
204
  var RESOLUTION_STATUSES = ["partial", "resolved"];
203
205
 
@@ -213,7 +215,7 @@ function parseJsonValue(value, guard, label) {
213
215
  return value;
214
216
  }
215
217
  function isServerInfo(value) {
216
- return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir);
218
+ return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir) && isOptionalString(value.cwd) && isOptionalString(value.daemonPath);
217
219
  }
218
220
  function isClearReviewsRequest(value) {
219
221
  return isRecord(value) && isOptionalNonNegativeInteger(value.olderThanDays) && isOptionalBoolean(value.dryRun);
@@ -224,6 +226,9 @@ function isOpenFileRequest(value) {
224
226
  function isCommitRangeDiffRequest(value) {
225
227
  return isRecord(value) && isString(value.fromSha) && isString(value.toSha) && isOptionalString(value.turnId);
226
228
  }
229
+ function isDiffContextRequest(value) {
230
+ return isRecord(value) && isString(value.filePath) && isNullableString(value.oldPath) && isOptionalString(value.turnId) && isDiffContextSource(value.source) && isPositiveInteger(value.oldStart) && isPositiveInteger(value.newStart) && isPositiveInteger(value.lineCount);
231
+ }
227
232
  function isSubmitReviewRequest(value) {
228
233
  return isRecord(value) && isArrayOf(value.comments, isComment) && isOptional(value.reviewScope, isReviewScope);
229
234
  }
@@ -275,6 +280,21 @@ function isDiffCommit(value) {
275
280
  function isCommitDiff(value) {
276
281
  return isRecord(value) && isDiffCommit(value.commit) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile);
277
282
  }
283
+ function isDiffContextSource(value) {
284
+ if (!isRecord(value) || !isString(value.mode)) {
285
+ return false;
286
+ }
287
+ switch (value.mode) {
288
+ case "turn":
289
+ return true;
290
+ case "commit":
291
+ return isString(value.sha);
292
+ case "range":
293
+ return isString(value.fromSha) && isString(value.toSha);
294
+ default:
295
+ return false;
296
+ }
297
+ }
278
298
  function isReviewScope(value) {
279
299
  if (!isRecord(value) || !isOneOf(value.mode, REVIEW_SCOPE_MODES)) {
280
300
  return false;
@@ -333,6 +353,9 @@ function isNullableString(value) {
333
353
  function isNumber(value) {
334
354
  return typeof value === "number" && Number.isFinite(value);
335
355
  }
356
+ function isPositiveInteger(value) {
357
+ return isNumber(value) && Number.isInteger(value) && value > 0;
358
+ }
336
359
  function isOptionalNumber(value) {
337
360
  return value === void 0 || isNumber(value);
338
361
  }
@@ -438,9 +461,61 @@ async function assertStateDirWritable() {
438
461
  }
439
462
  }
440
463
 
464
+ // src/server/idle.ts
465
+ var DEFAULT_DAEMON_IDLE_TIMEOUT_MS = 12e4;
466
+ function normalizeIdleTimeoutMs(rawValue = process.env.GLOSS_IDLE_TIMEOUT_MS, warn = (message) => process.stderr.write(message)) {
467
+ if (rawValue === void 0) {
468
+ return DEFAULT_DAEMON_IDLE_TIMEOUT_MS;
469
+ }
470
+ const parsed = Number(rawValue);
471
+ if (Number.isFinite(parsed) && parsed > 0) {
472
+ return parsed;
473
+ }
474
+ warn(
475
+ `Warning: GLOSS_IDLE_TIMEOUT_MS=${JSON.stringify(rawValue)} is not positive; using ${DEFAULT_DAEMON_IDLE_TIMEOUT_MS}.
476
+ `
477
+ );
478
+ return DEFAULT_DAEMON_IDLE_TIMEOUT_MS;
479
+ }
480
+ function createIdleScheduler(options) {
481
+ let idleTimer = null;
482
+ const cancel = () => {
483
+ if (idleTimer) {
484
+ clearTimeout(idleTimer);
485
+ idleTimer = null;
486
+ }
487
+ };
488
+ const schedule = () => {
489
+ if (options.isShuttingDown()) {
490
+ return;
491
+ }
492
+ if (options.hasLiveClients()) {
493
+ cancel();
494
+ return;
495
+ }
496
+ if (!idleTimer) {
497
+ idleTimer = setTimeout(() => {
498
+ idleTimer = null;
499
+ void shutdownIfIdle();
500
+ }, options.timeoutMs);
501
+ }
502
+ };
503
+ const shutdownIfIdle = async () => {
504
+ if (options.isShuttingDown()) {
505
+ return;
506
+ }
507
+ if (options.hasLiveClients()) {
508
+ schedule();
509
+ return;
510
+ }
511
+ await options.shutdown();
512
+ };
513
+ return { cancel, schedule };
514
+ }
515
+
441
516
  // src/server/index.ts
442
- import { readFile as readFile4, realpath, stat } from "fs/promises";
443
- import path6 from "path";
517
+ import { readFile as readFile5, realpath, stat } from "fs/promises";
518
+ import path7 from "path";
444
519
  import { fileURLToPath } from "url";
445
520
  import { Hono } from "hono";
446
521
  import { streamSSE } from "hono/streaming";
@@ -473,6 +548,8 @@ function resolutionCounts(feedback, resolvedComments = []) {
473
548
  }
474
549
 
475
550
  // src/shared/git-diff.ts
551
+ import { readFile as readFile2 } from "fs/promises";
552
+ import path5 from "path";
476
553
  import { execa } from "execa";
477
554
 
478
555
  // src/shared/language.ts
@@ -660,6 +737,59 @@ async function captureCommitRangeDiff(fromSha, toSha, repoRoot) {
660
737
  files
661
738
  };
662
739
  }
740
+ async function captureDiffContext({
741
+ filePath,
742
+ lineCount,
743
+ newRef,
744
+ oldPath,
745
+ oldRef,
746
+ newStart,
747
+ oldStart,
748
+ repoRoot
749
+ }) {
750
+ const [oldLines, newLines] = await Promise.all([
751
+ oldRef && oldPath ? readRevisionLines(repoRoot, oldRef, oldPath).catch(() => null) : null,
752
+ newRef === null ? readWorkingTreeLines(repoRoot, filePath).catch(() => null) : readRevisionLines(repoRoot, newRef, filePath).catch(() => null)
753
+ ]);
754
+ if (!oldLines && !newLines) {
755
+ throw new Error(`Could not read ${filePath} from either side of the diff`);
756
+ }
757
+ const lines = [];
758
+ for (let index = 0; index < lineCount; index += 1) {
759
+ const oldLine = oldStart + index;
760
+ const newLine = newStart + index;
761
+ const content = newLines?.[newLine - 1] ?? oldLines?.[oldLine - 1];
762
+ if (content === void 0) {
763
+ continue;
764
+ }
765
+ lines.push({
766
+ type: "context",
767
+ oldLine,
768
+ newLine,
769
+ content
770
+ });
771
+ }
772
+ return {
773
+ filePath,
774
+ oldStart,
775
+ newStart,
776
+ lines
777
+ };
778
+ }
779
+ async function readRevisionLines(repoRoot, ref, filePath) {
780
+ const result = await execa("git", ["show", `${ref}:${filePath}`], { cwd: repoRoot });
781
+ return splitFileLines(result.stdout);
782
+ }
783
+ async function readWorkingTreeLines(repoRoot, filePath) {
784
+ return splitFileLines(await readFile2(path5.resolve(repoRoot, filePath), "utf8"));
785
+ }
786
+ function splitFileLines(contents) {
787
+ const lines = contents.replace(/\r\n/g, "\n").split("\n");
788
+ if (lines.at(-1) === "") {
789
+ lines.pop();
790
+ }
791
+ return lines;
792
+ }
663
793
 
664
794
  // src/shared/reviews.ts
665
795
  function isResolvableReviewStatus(status) {
@@ -674,12 +804,12 @@ async function openLocalPath(filePath) {
674
804
 
675
805
  // src/server/store.ts
676
806
  import { createHash } from "crypto";
677
- import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
678
- import path5 from "path";
807
+ import { readdir as readdir2, readFile as readFile4 } from "fs/promises";
808
+ import path6 from "path";
679
809
  import { ulid } from "ulid";
680
810
 
681
811
  // src/shared/cleanup.ts
682
- import { readdir, readFile as readFile2, rm as rm3 } from "fs/promises";
812
+ import { readdir, readFile as readFile3, rm as rm3 } from "fs/promises";
683
813
  var DEFAULT_REVIEW_RETENTION_DAYS = 30;
684
814
  var clearableStatuses = /* @__PURE__ */ new Set(["submitted", "resolved", "cancelled"]);
685
815
  var millisecondsPerDay = 24 * 60 * 60 * 1e3;
@@ -739,7 +869,7 @@ function normalizeRetentionDays(value) {
739
869
  async function cleanupCandidate(reviewId, artifactDir, cutoff, skipped) {
740
870
  let raw;
741
871
  try {
742
- raw = await readFile2(globalReviewMetaFile(reviewId), "utf8");
872
+ raw = await readFile3(globalReviewMetaFile(reviewId), "utf8");
743
873
  } catch (error) {
744
874
  if (isFileNotFound(error)) {
745
875
  skipped.push({ reviewId, artifactDir, reason: "missing metadata" });
@@ -819,7 +949,7 @@ async function persistedTurnCleanupState(reviewId, artifactDir, skipped) {
819
949
  async function readPersistedTurnMeta(reviewId, turnDirName, artifactDir, skipped) {
820
950
  let raw;
821
951
  try {
822
- raw = await readFile2(globalReviewTurnMetaFile(reviewId, turnDirName), "utf8");
952
+ raw = await readFile3(globalReviewTurnMetaFile(reviewId, turnDirName), "utf8");
823
953
  } catch (error) {
824
954
  skipped.push({
825
955
  reviewId,
@@ -1328,16 +1458,25 @@ var ReviewStore = class {
1328
1458
  const reviewLoads = [];
1329
1459
  for (const entry of entries) {
1330
1460
  if (entry.isDirectory()) {
1331
- reviewLoads.push(this.loadReview(entry.name));
1461
+ reviewLoads.push(this.loadReviewForList(entry.name));
1332
1462
  }
1333
1463
  }
1334
1464
  await Promise.all(reviewLoads);
1335
1465
  }
1466
+ async loadReviewForList(id) {
1467
+ try {
1468
+ return await this.loadReview(id);
1469
+ } catch (error) {
1470
+ process.stderr.write(`Warning: Skipping corrupt review ${id}: ${formatError(error)}
1471
+ `);
1472
+ return null;
1473
+ }
1474
+ }
1336
1475
  async loadReview(id) {
1337
1476
  const metaPath = globalReviewMetaFile(id);
1338
1477
  let metaRaw;
1339
1478
  try {
1340
- metaRaw = await readFile3(metaPath, "utf8");
1479
+ metaRaw = await readFile4(metaPath, "utf8");
1341
1480
  } catch (error) {
1342
1481
  if (isFileNotFound(error)) {
1343
1482
  return this.loadReviewFromTurnsOnly(id);
@@ -1419,8 +1558,8 @@ var ReviewStore = class {
1419
1558
  let diffRaw;
1420
1559
  try {
1421
1560
  [metaRaw, diffRaw] = await Promise.all([
1422
- readFile3(metaPath, "utf8"),
1423
- readFile3(diffPath, "utf8")
1561
+ readFile4(metaPath, "utf8"),
1562
+ readFile4(diffPath, "utf8")
1424
1563
  ]);
1425
1564
  } catch (error) {
1426
1565
  if (isFileNotFound(error)) {
@@ -1450,7 +1589,7 @@ var ReviewStore = class {
1450
1589
  const diffPath = globalReviewDiffFile(id);
1451
1590
  let diffRaw;
1452
1591
  try {
1453
- diffRaw = await readFile3(diffPath, "utf8");
1592
+ diffRaw = await readFile4(diffPath, "utf8");
1454
1593
  } catch (error) {
1455
1594
  if (isFileNotFound(error)) {
1456
1595
  return null;
@@ -1630,9 +1769,9 @@ function reconcileTurn(meta, diff, feedback, resolution) {
1630
1769
  status,
1631
1770
  submittedAt: feedback?.timestamp ?? meta.submittedAt,
1632
1771
  resolvedAt: status === "resolved" ? resolution?.resolvedAt ?? meta.resolvedAt : void 0,
1633
- feedbackPath: feedback ? meta.feedbackPath ?? path5.join(meta.artifactDir, "feedback.json") : void 0,
1634
- markdownPath: feedback ? meta.markdownPath ?? path5.join(meta.artifactDir, "feedback.md") : void 0,
1635
- resolvedPath: resolution ? meta.resolvedPath ?? path5.join(meta.artifactDir, "resolved.json") : void 0,
1772
+ feedbackPath: feedback ? meta.feedbackPath ?? path6.join(meta.artifactDir, "feedback.json") : void 0,
1773
+ markdownPath: feedback ? meta.markdownPath ?? path6.join(meta.artifactDir, "feedback.md") : void 0,
1774
+ resolvedPath: resolution ? meta.resolvedPath ?? path6.join(meta.artifactDir, "resolved.json") : void 0,
1636
1775
  diff,
1637
1776
  ...feedback ? { feedback } : {},
1638
1777
  ...resolution ? { resolution } : {}
@@ -1665,7 +1804,7 @@ function requiredPath(value, label) {
1665
1804
  async function readOptionalJsonFile(filePath, guard, label) {
1666
1805
  let raw;
1667
1806
  try {
1668
- raw = await readFile3(filePath, "utf8");
1807
+ raw = await readFile4(filePath, "utf8");
1669
1808
  } catch (error) {
1670
1809
  if (isFileNotFound(error)) {
1671
1810
  return void 0;
@@ -1705,7 +1844,8 @@ function createApp(origin2, options = {}) {
1705
1844
  const response = {
1706
1845
  ok: true,
1707
1846
  version: packageVersion,
1708
- activeReviews: reviews.filter((review) => review.status === "pending").length
1847
+ activeReviews: reviews.filter((review) => review.status === "pending").length,
1848
+ ...options.health?.()
1709
1849
  };
1710
1850
  return c.json(response);
1711
1851
  });
@@ -1916,6 +2056,60 @@ function createApp(origin2, options = {}) {
1916
2056
  };
1917
2057
  return c.json(response);
1918
2058
  });
2059
+ app.post("/api/reviews/:id/files/context", async (c) => {
2060
+ const id = c.req.param("id");
2061
+ const existing = await reviewStore.get(id);
2062
+ if (!existing) {
2063
+ return c.json({ error: "review not found" }, 404);
2064
+ }
2065
+ const parsed = await readJsonBody(c, isDiffContextRequest, "diff context request");
2066
+ if (!parsed.ok) {
2067
+ return parsed.response;
2068
+ }
2069
+ const body = parsed.body;
2070
+ if (body.lineCount > DIFF_CONTEXT_MAX_LINES) {
2071
+ return c.json({ error: `lineCount must be ${DIFF_CONTEXT_MAX_LINES} or less` }, 400);
2072
+ }
2073
+ const turn = body.turnId ? await reviewStore.getTurn(id, body.turnId) : null;
2074
+ if (body.turnId && !turn) {
2075
+ return c.json({ error: "turn not found" }, 404);
2076
+ }
2077
+ const diffPayload = turn?.diff ?? existing.diff;
2078
+ const repoRoot = path7.resolve(diffPayload.cwd);
2079
+ const pathError = validateContextPath(repoRoot, body.filePath, "filePath") ?? (body.oldPath ? validateContextPath(repoRoot, body.oldPath, "oldPath") : null);
2080
+ if (pathError) {
2081
+ return c.json({ error: pathError }, 400);
2082
+ }
2083
+ const source = await resolveContextSource(diffPayload, body.source);
2084
+ if (!source.ok) {
2085
+ return c.json({ error: source.error }, source.status);
2086
+ }
2087
+ const reviewFile = source.files.find((file) => file.path === body.filePath);
2088
+ if (!reviewFile) {
2089
+ return c.json({ error: "file is not part of this review context" }, 404);
2090
+ }
2091
+ if ((reviewFile.oldPath ?? null) !== body.oldPath) {
2092
+ return c.json({ error: "oldPath does not match the reviewed file" }, 400);
2093
+ }
2094
+ if (reviewFile.isBinary) {
2095
+ return c.json({ error: "binary file context is not available" }, 409);
2096
+ }
2097
+ try {
2098
+ const response = await captureDiffContext({
2099
+ filePath: body.filePath,
2100
+ oldPath: body.oldPath,
2101
+ oldRef: source.oldRef,
2102
+ newRef: source.newRef,
2103
+ oldStart: body.oldStart,
2104
+ newStart: body.newStart,
2105
+ lineCount: body.lineCount,
2106
+ repoRoot
2107
+ });
2108
+ return c.json(response);
2109
+ } catch (error) {
2110
+ return c.json({ error: `context is unavailable: ${formatError(error)}` }, 409);
2111
+ }
2112
+ });
1919
2113
  app.post("/api/reviews/:id/files/open", async (c) => {
1920
2114
  const id = c.req.param("id");
1921
2115
  const existing = await reviewStore.get(id);
@@ -1927,11 +2121,11 @@ function createApp(origin2, options = {}) {
1927
2121
  return parsed.response;
1928
2122
  }
1929
2123
  const { filePath, turnId } = parsed.body;
1930
- if (!filePath || filePath.includes("\0") || path6.isAbsolute(filePath)) {
2124
+ if (!filePath || filePath.includes("\0") || path7.isAbsolute(filePath)) {
1931
2125
  return c.json({ error: "filePath must be a repo-relative path" }, 400);
1932
2126
  }
1933
- const repoRoot = path6.resolve(existing.diff.cwd);
1934
- const requestedAbsolutePath = path6.resolve(repoRoot, filePath);
2127
+ const repoRoot = path7.resolve(existing.diff.cwd);
2128
+ const requestedAbsolutePath = path7.resolve(repoRoot, filePath);
1935
2129
  if (!isPathWithin(repoRoot, requestedAbsolutePath)) {
1936
2130
  return c.json({ error: "filePath must stay within the review cwd" }, 400);
1937
2131
  }
@@ -2047,13 +2241,13 @@ function createApp(origin2, options = {}) {
2047
2241
  }
2048
2242
  async function serveAsset(c) {
2049
2243
  const requestPath = new URL(c.req.url).pathname.replace(/^\/assets\//, "");
2050
- const normalized = path6.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
2051
- const assetPath = path6.join(webRoot, "assets", normalized);
2244
+ const normalized = path7.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
2245
+ const assetPath = path7.join(webRoot, "assets", normalized);
2052
2246
  try {
2053
- const body = await readFile4(assetPath);
2247
+ const body = await readFile5(assetPath);
2054
2248
  return new Response(body, {
2055
2249
  headers: {
2056
- "content-type": mimeTypes[path6.extname(assetPath)] ?? "application/octet-stream"
2250
+ "content-type": mimeTypes[path7.extname(assetPath)] ?? "application/octet-stream"
2057
2251
  }
2058
2252
  });
2059
2253
  } catch (error) {
@@ -2065,7 +2259,7 @@ async function serveAsset(c) {
2065
2259
  }
2066
2260
  async function serveIndex() {
2067
2261
  try {
2068
- const body = await readFile4(path6.join(webRoot, "index.html"));
2262
+ const body = await readFile5(path7.join(webRoot, "index.html"));
2069
2263
  return new Response(body, {
2070
2264
  headers: { "content-type": "text/html; charset=utf-8" }
2071
2265
  });
@@ -2079,7 +2273,7 @@ async function serveIndex() {
2079
2273
  function serveRootFile(fileName, contentType) {
2080
2274
  return async () => {
2081
2275
  try {
2082
- const body = await readFile4(path6.join(webRoot, fileName));
2276
+ const body = await readFile5(path7.join(webRoot, fileName));
2083
2277
  return new Response(body, {
2084
2278
  headers: { "content-type": contentType }
2085
2279
  });
@@ -2111,8 +2305,63 @@ async function readJsonBody(c, guard, label) {
2111
2305
  }
2112
2306
  }
2113
2307
  function isPathWithin(parentPath, childPath) {
2114
- const relative = path6.relative(parentPath, childPath);
2115
- return relative === "" || !relative.startsWith("..") && !path6.isAbsolute(relative);
2308
+ const relative = path7.relative(parentPath, childPath);
2309
+ return relative === "" || !relative.startsWith("..") && !path7.isAbsolute(relative);
2310
+ }
2311
+ async function resolveContextSource(diffPayload, source) {
2312
+ if (source.mode === "turn") {
2313
+ return {
2314
+ ok: true,
2315
+ files: diffPayload.files,
2316
+ oldRef: diffPayload.base.sha,
2317
+ newRef: diffPayload.scope.comparison.sha
2318
+ };
2319
+ }
2320
+ const commitDiffs = diffPayload.commitDiffs ?? [];
2321
+ if (commitDiffs.length === 0) {
2322
+ return {
2323
+ ok: false,
2324
+ status: 409,
2325
+ error: "commit context is only available for branch reviews"
2326
+ };
2327
+ }
2328
+ if (source.mode === "commit") {
2329
+ const commitDiff = commitDiffs.find((diff) => diff.commit.sha === source.sha);
2330
+ if (!commitDiff) {
2331
+ return { ok: false, status: 404, error: "commit must be part of this review" };
2332
+ }
2333
+ return {
2334
+ ok: true,
2335
+ files: commitDiff.files,
2336
+ oldRef: `${source.sha}^`,
2337
+ newRef: source.sha
2338
+ };
2339
+ }
2340
+ const fromIndex = commitDiffs.findIndex((diff) => diff.commit.sha === source.fromSha);
2341
+ const toIndex = commitDiffs.findIndex((diff) => diff.commit.sha === source.toSha);
2342
+ if (fromIndex < 0 || toIndex < 0) {
2343
+ return { ok: false, status: 404, error: "commit range must use commits from this review" };
2344
+ }
2345
+ if (fromIndex > toIndex) {
2346
+ return { ok: false, status: 400, error: "fromSha must come before or match toSha" };
2347
+ }
2348
+ const rangeDiff = source.fromSha === source.toSha ? commitDiffs[fromIndex] : await captureCommitRangeDiff(source.fromSha, source.toSha, diffPayload.cwd);
2349
+ return {
2350
+ ok: true,
2351
+ files: rangeDiff.files,
2352
+ oldRef: `${source.fromSha}^`,
2353
+ newRef: source.toSha
2354
+ };
2355
+ }
2356
+ function validateContextPath(repoRoot, filePath, label) {
2357
+ if (!filePath || filePath.includes("\0") || path7.isAbsolute(filePath)) {
2358
+ return `${label} must be a repo-relative path`;
2359
+ }
2360
+ const requestedAbsolutePath = path7.resolve(repoRoot, filePath);
2361
+ if (!isPathWithin(repoRoot, requestedAbsolutePath)) {
2362
+ return `${label} must stay within the review cwd`;
2363
+ }
2364
+ return null;
2116
2365
  }
2117
2366
  function activeTurnSummary(meta) {
2118
2367
  if (!meta.activeTurnId) {
@@ -2157,23 +2406,39 @@ async function runStartupCleanup(logger = defaultLogger) {
2157
2406
 
2158
2407
  // src/server/daemon.ts
2159
2408
  var port = Number(process.env.GLOSS_PORT ?? "0");
2160
- var idleTimeoutMs = Number(process.env.GLOSS_IDLE_TIMEOUT_MS ?? "120000");
2409
+ var idleTimeoutMs = normalizeIdleTimeoutMs();
2410
+ var daemonPath = fileURLToPath2(import.meta.url);
2161
2411
  if (!port) {
2162
2412
  throw new Error("GLOSS_PORT is required");
2163
2413
  }
2164
2414
  var origin = `http://localhost:${port}`;
2165
2415
  var eventStreams = /* @__PURE__ */ new Set();
2166
- var idleTimer = null;
2167
2416
  var shuttingDown = false;
2417
+ var idleScheduler = createIdleScheduler({
2418
+ timeoutMs: idleTimeoutMs,
2419
+ hasLiveClients: () => eventStreams.size > 0,
2420
+ isShuttingDown: () => shuttingDown,
2421
+ shutdown: () => shutdown(0)
2422
+ });
2168
2423
  var server = serve({
2169
2424
  fetch: createApp(origin, {
2170
2425
  onReviewActivity: () => {
2171
- void scheduleIdleShutdown();
2426
+ idleScheduler.schedule();
2172
2427
  },
2173
2428
  registerEventStream: (close) => {
2174
2429
  eventStreams.add(close);
2430
+ idleScheduler.schedule();
2175
2431
  return () => {
2176
2432
  eventStreams.delete(close);
2433
+ idleScheduler.schedule();
2434
+ };
2435
+ },
2436
+ health: () => {
2437
+ return {
2438
+ connections: eventStreams.size,
2439
+ cwd: process.cwd(),
2440
+ daemonPath,
2441
+ stateDir: globalStateDir()
2177
2442
  };
2178
2443
  }
2179
2444
  }).fetch,
@@ -2184,54 +2449,23 @@ await writeServerInfo({
2184
2449
  port,
2185
2450
  version: packageVersion,
2186
2451
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2187
- stateDir: globalStateDir()
2452
+ stateDir: globalStateDir(),
2453
+ cwd: process.cwd(),
2454
+ daemonPath
2188
2455
  });
2189
2456
  await runStartupCleanup();
2190
- await scheduleIdleShutdown();
2457
+ idleScheduler.schedule();
2191
2458
  for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
2192
2459
  process.on(signal, () => {
2193
2460
  void shutdown(0);
2194
2461
  });
2195
2462
  }
2196
- async function scheduleIdleShutdown() {
2197
- if (shuttingDown || idleTimeoutMs <= 0) {
2198
- return;
2199
- }
2200
- const activeReviews = await countActiveReviews();
2201
- if (activeReviews > 0) {
2202
- if (idleTimer) {
2203
- clearTimeout(idleTimer);
2204
- idleTimer = null;
2205
- }
2206
- return;
2207
- }
2208
- if (!idleTimer) {
2209
- idleTimer = setTimeout(() => {
2210
- idleTimer = null;
2211
- void shutdownIfIdle();
2212
- }, idleTimeoutMs);
2213
- }
2214
- }
2215
- async function shutdownIfIdle() {
2216
- if (await countActiveReviews() > 0) {
2217
- await scheduleIdleShutdown();
2218
- return;
2219
- }
2220
- await shutdown(0);
2221
- }
2222
- async function countActiveReviews() {
2223
- const reviews = await reviewStore.list();
2224
- return reviews.filter((review) => review.status === "pending").length;
2225
- }
2226
2463
  async function shutdown(exitCode) {
2227
2464
  if (shuttingDown) {
2228
2465
  return;
2229
2466
  }
2230
2467
  shuttingDown = true;
2231
- if (idleTimer) {
2232
- clearTimeout(idleTimer);
2233
- idleTimer = null;
2234
- }
2468
+ idleScheduler.cancel();
2235
2469
  for (const close of [...eventStreams]) {
2236
2470
  close();
2237
2471
  }