getgloss 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/index.ts
4
+ import { randomUUID as randomUUID3 } from "crypto";
5
+ import { constants } from "fs";
6
+ import { access, rm as rm5, writeFile as writeFile4 } from "fs/promises";
7
+ import path7 from "path";
4
8
  import { Command } from "commander";
5
9
  import openBrowser from "open";
6
10
 
@@ -14,6 +18,9 @@ function formatError(error) {
14
18
  function isFileNotFound(error) {
15
19
  return error instanceof Error && "code" in error && error.code === "ENOENT";
16
20
  }
21
+ function isPermissionError(error) {
22
+ return error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
23
+ }
17
24
 
18
25
  // src/shared/paths.ts
19
26
  import { mkdir } from "fs/promises";
@@ -23,7 +30,7 @@ import path from "path";
23
30
  // package.json
24
31
  var package_default = {
25
32
  name: "getgloss",
26
- version: "0.8.3",
33
+ version: "0.8.5",
27
34
  description: "Local browser-based diff review for coding-agent loops.",
28
35
  type: "module",
29
36
  packageManager: "pnpm@10.33.2",
@@ -119,6 +126,9 @@ function globalStateDir() {
119
126
  function globalServerFile() {
120
127
  return path.join(globalStateDir(), "server.json");
121
128
  }
129
+ function globalServerLockDir() {
130
+ return path.join(globalStateDir(), "server.lock");
131
+ }
122
132
  function globalLogDir() {
123
133
  return path.join(globalStateDir(), "logs");
124
134
  }
@@ -198,10 +208,10 @@ function parseJsonValue(value, guard, label) {
198
208
  return value;
199
209
  }
200
210
  function isServerInfo(value) {
201
- return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir);
211
+ return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir) && isOptionalString(value.cwd) && isOptionalString(value.daemonPath);
202
212
  }
203
213
  function isHealthResponse(value) {
204
- return isRecord(value) && isBoolean(value.ok) && isString(value.version) && isNumber(value.activeReviews);
214
+ return isRecord(value) && isBoolean(value.ok) && isString(value.version) && isNumber(value.activeReviews) && isOptionalNumber(value.connections) && isOptionalString(value.stateDir) && isOptionalString(value.cwd) && isOptionalString(value.daemonPath);
205
215
  }
206
216
  function isClearReviewsResult(value) {
207
217
  return isRecord(value) && isString(value.reviewsDir) && isString(value.cutoff) && isNumber(value.olderThanDays) && isBoolean(value.dryRun) && isArrayOf(value.candidates, isClearReviewEntry) && isArrayOf(value.deleted, isClearReviewEntry) && isArrayOf(value.skipped, isClearReviewSkipped) && isRecord(value.counts) && isNumber(value.counts.candidates) && isNumber(value.counts.deleted) && isNumber(value.counts.skipped);
@@ -592,11 +602,127 @@ function cleanupResult({
592
602
  };
593
603
  }
594
604
 
605
+ // src/shared/server-info.ts
606
+ import { randomUUID as randomUUID2 } from "crypto";
607
+ import { readFile as readFile2, rm as rm3, writeFile as writeFile2 } from "fs/promises";
608
+ import path3 from "path";
609
+
610
+ // src/shared/json.ts
611
+ import { randomUUID } from "crypto";
612
+ import { rename, rm as rm2, writeFile } from "fs/promises";
613
+ import path2 from "path";
614
+ function serializeJson(value) {
615
+ return `${JSON.stringify(value, null, 2)}
616
+ `;
617
+ }
618
+ async function writeJsonFile(filePath, value) {
619
+ await writeTextFile(filePath, serializeJson(value));
620
+ }
621
+ async function writeTextFile(filePath, value) {
622
+ const tempPath = path2.join(
623
+ path2.dirname(filePath),
624
+ `.${path2.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
625
+ );
626
+ try {
627
+ await writeFile(tempPath, value);
628
+ await rename(tempPath, filePath);
629
+ } catch (error) {
630
+ await rm2(tempPath, { force: true }).catch(() => void 0);
631
+ throw error;
632
+ }
633
+ }
634
+
635
+ // src/shared/server-info.ts
636
+ async function readServerInfo() {
637
+ let raw;
638
+ try {
639
+ raw = await readFile2(globalServerFile(), "utf8");
640
+ } catch (error) {
641
+ if (isFileNotFound(error)) {
642
+ return null;
643
+ }
644
+ if (isPermissionError(error)) {
645
+ throw new Error(serverInfoPermissionMessage("read", error), { cause: error });
646
+ }
647
+ throw new Error(`Could not read server info at ${globalServerFile()}: ${formatError(error)}`, {
648
+ cause: error
649
+ });
650
+ }
651
+ try {
652
+ return parseJson(raw, isServerInfo, "server info");
653
+ } catch (error) {
654
+ throw new Error(`Invalid server info at ${globalServerFile()}: ${formatError(error)}`, {
655
+ cause: error
656
+ });
657
+ }
658
+ }
659
+ async function writeServerInfo(info) {
660
+ try {
661
+ await ensureDir(globalStateDir());
662
+ } catch (error) {
663
+ if (isPermissionError(error)) {
664
+ throw new Error(serverInfoPermissionMessage("create", error), { cause: error });
665
+ }
666
+ throw error;
667
+ }
668
+ try {
669
+ await writeJsonFile(globalServerFile(), info);
670
+ } catch (error) {
671
+ if (!isPermissionError(error)) {
672
+ throw error;
673
+ }
674
+ await assertStateDirWritable();
675
+ try {
676
+ await writeFile2(globalServerFile(), serializeServerInfo(info));
677
+ } catch (directWriteError) {
678
+ throw new Error(serverInfoPermissionMessage("write", directWriteError), {
679
+ cause: directWriteError
680
+ });
681
+ }
682
+ }
683
+ }
684
+ async function removeServerInfoFile() {
685
+ try {
686
+ await rm3(globalServerFile(), { force: true });
687
+ return null;
688
+ } catch (error) {
689
+ return serverInfoPermissionMessage("remove", error);
690
+ }
691
+ }
692
+ function serverInfoPermissionMessage(action, error) {
693
+ const stateDir = globalStateDir();
694
+ const source = process.env.GLOSS_STATE_DIR ? `GLOSS_STATE_DIR=${stateDir}` : "GLOSS_STATE_DIR is not set; defaulting to ~/.gloss";
695
+ return [
696
+ `Could not ${action} Gloss server state at ${globalServerFile()}: ${formatError(error)}.`,
697
+ "`server.json` is not a review lock, so there is nothing to unlock after a review.",
698
+ `Check that ${stateDir} and ${globalServerFile()} are owned and writable by your user.`,
699
+ `On macOS, if the file is immutable, run \`chflags nouchg "${globalServerFile()}"\`.`,
700
+ `For sandboxed agents, set GLOSS_STATE_DIR to a writable directory. ${source}.`
701
+ ].join(" ");
702
+ }
703
+ function serializeServerInfo(info) {
704
+ return `${JSON.stringify(info, null, 2)}
705
+ `;
706
+ }
707
+ async function assertStateDirWritable() {
708
+ const probePath = path3.join(
709
+ globalStateDir(),
710
+ `.server.json.${process.pid}.${randomUUID2()}.probe`
711
+ );
712
+ try {
713
+ await writeFile2(probePath, "");
714
+ await rm3(probePath, { force: true });
715
+ } catch (error) {
716
+ await rm3(probePath, { force: true }).catch(() => void 0);
717
+ throw new Error(serverInfoPermissionMessage("write", error), { cause: error });
718
+ }
719
+ }
720
+
595
721
  // src/cli/git.ts
596
722
  import { execa } from "execa";
597
723
 
598
724
  // src/shared/language.ts
599
- import path2 from "path";
725
+ import path4 from "path";
600
726
  var languageByExtension = {
601
727
  cjs: "js",
602
728
  css: "css",
@@ -618,7 +744,7 @@ var languageByExtension = {
618
744
  yml: "yaml"
619
745
  };
620
746
  function languageForPath(filePath) {
621
- const ext = path2.extname(filePath).slice(1).toLowerCase();
747
+ const ext = path4.extname(filePath).slice(1).toLowerCase();
622
748
  if (!ext) {
623
749
  return null;
624
750
  }
@@ -984,67 +1110,15 @@ async function assertGitAvailable() {
984
1110
  // src/cli/lifecycle.ts
985
1111
  import { execFile, spawn } from "child_process";
986
1112
  import { closeSync, existsSync, openSync } from "fs";
987
- import { rm as rm3 } from "fs/promises";
1113
+ import { mkdir as mkdir2, readFile as readFile3, rm as rm4, stat, writeFile as writeFile3 } from "fs/promises";
988
1114
  import { userInfo } from "os";
1115
+ import path5 from "path";
989
1116
  import { fileURLToPath } from "url";
990
1117
  import { promisify } from "util";
991
1118
  import getPort from "get-port";
992
1119
 
993
- // src/shared/server-info.ts
994
- import { readFile as readFile2 } from "fs/promises";
995
-
996
- // src/shared/json.ts
997
- import { randomUUID } from "crypto";
998
- import { rename, rm as rm2, writeFile } from "fs/promises";
999
- import path3 from "path";
1000
- function serializeJson(value) {
1001
- return `${JSON.stringify(value, null, 2)}
1002
- `;
1003
- }
1004
- async function writeJsonFile(filePath, value) {
1005
- await writeTextFile(filePath, serializeJson(value));
1006
- }
1007
- async function writeTextFile(filePath, value) {
1008
- const tempPath = path3.join(
1009
- path3.dirname(filePath),
1010
- `.${path3.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
1011
- );
1012
- try {
1013
- await writeFile(tempPath, value);
1014
- await rename(tempPath, filePath);
1015
- } catch (error) {
1016
- await rm2(tempPath, { force: true }).catch(() => void 0);
1017
- throw error;
1018
- }
1019
- }
1020
-
1021
- // src/shared/server-info.ts
1022
- async function readServerInfo() {
1023
- let raw;
1024
- try {
1025
- raw = await readFile2(globalServerFile(), "utf8");
1026
- } catch (error) {
1027
- if (isFileNotFound(error)) {
1028
- return null;
1029
- }
1030
- throw new Error(`Could not read server info at ${globalServerFile()}: ${formatError(error)}`, {
1031
- cause: error
1032
- });
1033
- }
1034
- try {
1035
- return parseJson(raw, isServerInfo, "server info");
1036
- } catch (error) {
1037
- throw new Error(`Invalid server info at ${globalServerFile()}: ${formatError(error)}`, {
1038
- cause: error
1039
- });
1040
- }
1041
- }
1042
- async function writeServerInfo(info) {
1043
- await ensureDir(globalStateDir());
1044
- await writeJsonFile(globalServerFile(), info);
1045
- }
1046
-
1047
1120
  // src/cli/server-client.ts
1121
+ var timedWatchAttemptMs = 1e3;
1048
1122
  var ServerClient = class {
1049
1123
  constructor(baseUrl) {
1050
1124
  this.baseUrl = baseUrl;
@@ -1123,11 +1197,15 @@ var ServerClient = class {
1123
1197
  throw new Error(`watch timed out after ${timeoutSeconds} seconds`);
1124
1198
  }
1125
1199
  const controller = new AbortController();
1126
- const timeout = remainingMs ? setTimeout(() => controller.abort(), remainingMs) : null;
1200
+ const timeoutMs = remainingMs === null ? null : Math.min(remainingMs, timedWatchAttemptMs);
1201
+ const timeout = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : null;
1127
1202
  try {
1128
1203
  return await this.readReviewEvents(reviewId, controller.signal);
1129
1204
  } catch (error) {
1130
1205
  if (isAbortError(error)) {
1206
+ if (deadline && Date.now() < deadline) {
1207
+ throw new Error("watch stream ended before completion");
1208
+ }
1131
1209
  throw new Error(`watch timed out after ${timeoutSeconds} seconds`);
1132
1210
  }
1133
1211
  if (!isPrematureWatchEnd(error)) {
@@ -1172,20 +1250,20 @@ var ServerClient = class {
1172
1250
  }
1173
1251
  }
1174
1252
  }
1175
- async get(path5, guard, label) {
1176
- const response = await fetch(`${this.baseUrl}${path5}`);
1253
+ async get(path8, guard, label) {
1254
+ const response = await fetch(`${this.baseUrl}${path8}`);
1177
1255
  return parseResponse(response, guard, label);
1178
1256
  }
1179
- async post(path5, body, guard, label) {
1180
- const response = await fetch(`${this.baseUrl}${path5}`, {
1257
+ async post(path8, body, guard, label) {
1258
+ const response = await fetch(`${this.baseUrl}${path8}`, {
1181
1259
  method: "POST",
1182
1260
  headers: { "content-type": "application/json" },
1183
1261
  body: JSON.stringify(body)
1184
1262
  });
1185
1263
  return parseResponse(response, guard, label);
1186
1264
  }
1187
- async delete(path5, guard, label) {
1188
- const response = await fetch(`${this.baseUrl}${path5}`, { method: "DELETE" });
1265
+ async delete(path8, guard, label) {
1266
+ const response = await fetch(`${this.baseUrl}${path8}`, { method: "DELETE" });
1189
1267
  return parseResponse(response, guard, label);
1190
1268
  }
1191
1269
  };
@@ -1210,6 +1288,8 @@ async function sleep(milliseconds) {
1210
1288
  var execFileAsync = promisify(execFile);
1211
1289
  var gracefulShutdownTimeoutMs = 2e3;
1212
1290
  var forceShutdownTimeoutMs = 1e3;
1291
+ var serverLockTimeoutMs = 8e3;
1292
+ var staleServerLockMs = 3e4;
1213
1293
  function serverUrl(info) {
1214
1294
  return `http://localhost:${info.port}`;
1215
1295
  }
@@ -1225,17 +1305,26 @@ async function isServerResponsive(info) {
1225
1305
  }
1226
1306
  }
1227
1307
  async function ensureServer(options = {}) {
1228
- const existing = await readServerInfo();
1229
- if (existing && await isServerResponsive(existing)) {
1230
- return existing;
1231
- }
1232
- return startServer(options);
1308
+ return withServerLock(async () => {
1309
+ const existing = await readServerInfo();
1310
+ await reapStaleDaemons(existing);
1311
+ if (existing && await isServerResponsive(existing)) {
1312
+ return existing;
1313
+ }
1314
+ return startServerUnlocked(existing, options);
1315
+ });
1233
1316
  }
1234
1317
  async function startServer(options = {}) {
1235
- const existing = await readServerInfo();
1236
- if (existing && await isServerResponsive(existing)) {
1237
- return existing;
1238
- }
1318
+ return withServerLock(async () => {
1319
+ const existing = await readServerInfo();
1320
+ await reapStaleDaemons(existing);
1321
+ if (existing && await isServerResponsive(existing)) {
1322
+ return existing;
1323
+ }
1324
+ return startServerUnlocked(existing, options);
1325
+ });
1326
+ }
1327
+ async function startServerUnlocked(existing, options = {}) {
1239
1328
  if (existing) {
1240
1329
  await retireServer(existing);
1241
1330
  }
@@ -1274,9 +1363,16 @@ async function launchServer(port) {
1274
1363
  port,
1275
1364
  version: packageVersion,
1276
1365
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1277
- stateDir: globalStateDir()
1366
+ stateDir: globalStateDir(),
1367
+ cwd: process.cwd(),
1368
+ daemonPath
1278
1369
  };
1279
- await writeServerInfo(info);
1370
+ try {
1371
+ await writeServerInfo(info);
1372
+ } catch (error) {
1373
+ await terminatePid(info.pid);
1374
+ throw error;
1375
+ }
1280
1376
  const deadline = Date.now() + 8e3;
1281
1377
  while (Date.now() < deadline) {
1282
1378
  if (await isServerResponsive(info)) {
@@ -1288,36 +1384,135 @@ async function launchServer(port) {
1288
1384
  await removeServerInfoForPid(info.pid);
1289
1385
  throw new Error(`Server did not become responsive. See ${globalServerLogFile()}`);
1290
1386
  }
1387
+ async function withServerLock(fn) {
1388
+ const release = await acquireServerLock();
1389
+ try {
1390
+ return await fn();
1391
+ } finally {
1392
+ await release();
1393
+ }
1394
+ }
1395
+ async function acquireServerLock() {
1396
+ await ensureDir(globalStateDir());
1397
+ const lockDir = globalServerLockDir();
1398
+ const ownerFile = serverLockOwnerFile();
1399
+ const deadline = Date.now() + serverLockTimeoutMs;
1400
+ while (true) {
1401
+ try {
1402
+ await mkdir2(lockDir);
1403
+ try {
1404
+ await writeFile3(
1405
+ ownerFile,
1406
+ `${JSON.stringify({ pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)}
1407
+ `
1408
+ );
1409
+ } catch (error) {
1410
+ await rm4(lockDir, { recursive: true, force: true }).catch(() => void 0);
1411
+ throw error;
1412
+ }
1413
+ return () => rm4(lockDir, { recursive: true, force: true });
1414
+ } catch (error) {
1415
+ if (!isAlreadyExistsError(error)) {
1416
+ throw error;
1417
+ }
1418
+ if (await removeStaleServerLock(lockDir, ownerFile)) {
1419
+ continue;
1420
+ }
1421
+ if (Date.now() >= deadline) {
1422
+ throw new Error(`Timed out waiting for Gloss server lock at ${lockDir}`);
1423
+ }
1424
+ await sleep2(50);
1425
+ }
1426
+ }
1427
+ }
1428
+ async function removeStaleServerLock(lockDir, ownerFile) {
1429
+ const owner = await readServerLockOwner(ownerFile);
1430
+ if (owner?.pid && !isPidAlive(owner.pid)) {
1431
+ await rm4(lockDir, { recursive: true, force: true });
1432
+ return true;
1433
+ }
1434
+ if (!owner && await isOldLockDir(lockDir)) {
1435
+ await rm4(lockDir, { recursive: true, force: true });
1436
+ return true;
1437
+ }
1438
+ return false;
1439
+ }
1440
+ async function readServerLockOwner(ownerFile) {
1441
+ try {
1442
+ const raw = await readFile3(ownerFile, "utf8");
1443
+ const parsed = JSON.parse(raw);
1444
+ return typeof parsed.pid === "number" && Number.isFinite(parsed.pid) ? { pid: parsed.pid } : null;
1445
+ } catch {
1446
+ return null;
1447
+ }
1448
+ }
1449
+ async function isOldLockDir(lockDir) {
1450
+ try {
1451
+ const info = await stat(lockDir);
1452
+ return Date.now() - info.mtimeMs > staleServerLockMs;
1453
+ } catch {
1454
+ return false;
1455
+ }
1456
+ }
1457
+ function serverLockOwnerFile() {
1458
+ return path5.join(globalServerLockDir(), "owner.json");
1459
+ }
1460
+ function isAlreadyExistsError(error) {
1461
+ return error instanceof Error && "code" in error && error.code === "EEXIST";
1462
+ }
1291
1463
  async function stopServer(options = {}) {
1292
1464
  if (options.all) {
1293
- const info2 = await readServerInfo();
1294
- const daemonPids = await listGlossDaemonPids();
1465
+ const { info: info2, warning: readWarning2 } = await readServerInfoForStop();
1466
+ const daemonPids = (await listGlossDaemonProcesses()).map((processInfo) => processInfo.pid);
1295
1467
  const stoppedPids = [];
1296
1468
  for (const pid of daemonPids) {
1297
1469
  if (await terminatePid(pid)) {
1298
1470
  stoppedPids.push(pid);
1299
1471
  }
1300
1472
  }
1301
- await rm3(globalServerFile(), { force: true });
1302
- return { stopped: stoppedPids.length > 0, info: info2, stoppedPids };
1473
+ return withWarning(
1474
+ { stopped: stoppedPids.length > 0, info: info2, stoppedPids },
1475
+ combineWarnings(readWarning2, await removeServerInfoFile())
1476
+ );
1303
1477
  }
1304
- const info = await readServerInfo();
1478
+ const { info, warning: readWarning } = await readServerInfoForStop();
1305
1479
  if (!info) {
1306
- return { stopped: false, info: null };
1480
+ return withWarning({ stopped: false, info: null }, readWarning);
1307
1481
  }
1308
1482
  if (!isPidAlive(info.pid)) {
1309
- await removeServerInfoForPid(info.pid);
1310
- return { stopped: false, info };
1483
+ return withWarning({ stopped: false, info }, await removeServerInfoForPid(info.pid));
1311
1484
  }
1312
1485
  if (!await isGlossDaemonPid(info.pid)) {
1313
- await removeServerInfoForPid(info.pid);
1314
- return { stopped: false, info };
1486
+ return withWarning({ stopped: false, info }, await removeServerInfoForPid(info.pid));
1315
1487
  }
1316
1488
  const stopped = await terminatePid(info.pid);
1489
+ let warning = null;
1317
1490
  if (stopped) {
1318
- await removeServerInfoForPid(info.pid);
1491
+ warning = await removeServerInfoForPid(info.pid);
1492
+ }
1493
+ return withWarning({ stopped, info }, warning);
1494
+ }
1495
+ async function reapStaleDaemons(managed) {
1496
+ if (process.env.GLOSS_SKIP_STALE_DAEMON_REAP === "1") {
1497
+ return [];
1498
+ }
1499
+ const processes = await listGlossDaemonProcesses();
1500
+ const managedProcess = managed ? processes.find((processInfo) => processInfo.pid === managed.pid) : null;
1501
+ const managedSource = managedProcess ? daemonSourceKey(managedProcess) : managed?.daemonPath ? daemonPathSourceKey(managed.daemonPath) : null;
1502
+ const reapedPids = [];
1503
+ for (const processInfo of processes) {
1504
+ const shouldReap = isMissingDaemonSource(processInfo) || isStaleHomebrewDaemon(processInfo) || managedSource !== null && processInfo.pid !== managed?.pid && daemonSourceKey(processInfo) === managedSource;
1505
+ if (!shouldReap) {
1506
+ continue;
1507
+ }
1508
+ if (await terminatePid(processInfo.pid)) {
1509
+ reapedPids.push(processInfo.pid);
1510
+ if (processInfo.pid === managed?.pid) {
1511
+ await removeServerInfoForPid(processInfo.pid);
1512
+ }
1513
+ }
1319
1514
  }
1320
- return { stopped, info };
1515
+ return reapedPids;
1321
1516
  }
1322
1517
  function isPidAlive(pid) {
1323
1518
  if (pid <= 0) {
@@ -1361,19 +1556,34 @@ async function waitForPidExit(pid, timeoutMs) {
1361
1556
  if (!isPidAlive(pid)) {
1362
1557
  return true;
1363
1558
  }
1364
- await new Promise((resolve) => setTimeout(resolve, 50));
1559
+ await sleep2(50);
1365
1560
  }
1366
1561
  return !isPidAlive(pid);
1367
1562
  }
1368
1563
  async function removeServerInfoForPid(pid) {
1369
1564
  const current = await readServerInfo().catch(() => null);
1370
1565
  if (!current || current.pid === pid) {
1371
- await rm3(globalServerFile(), { force: true });
1566
+ return removeServerInfoFile();
1372
1567
  }
1568
+ return null;
1569
+ }
1570
+ function withWarning(result, warning) {
1571
+ return warning ? { ...result, warning } : result;
1572
+ }
1573
+ async function readServerInfoForStop() {
1574
+ try {
1575
+ return { info: await readServerInfo(), warning: null };
1576
+ } catch (error) {
1577
+ return { info: null, warning: error instanceof Error ? error.message : String(error) };
1578
+ }
1579
+ }
1580
+ function combineWarnings(...warnings) {
1581
+ const present = warnings.filter((warning) => Boolean(warning));
1582
+ return present.length > 0 ? present.join(" ") : null;
1373
1583
  }
1374
1584
  async function isGlossDaemonPid(pid) {
1375
1585
  const command = await readProcessCommand(pid);
1376
- return command ? isGlossDaemonCommand(command) : false;
1586
+ return command ? parseGlossDaemonCommand(command) !== null : false;
1377
1587
  }
1378
1588
  async function readProcessCommand(pid) {
1379
1589
  try {
@@ -1384,6 +1594,9 @@ async function readProcessCommand(pid) {
1384
1594
  }
1385
1595
  }
1386
1596
  async function listGlossDaemonPids() {
1597
+ return (await listGlossDaemonProcesses()).map((processInfo) => processInfo.pid);
1598
+ }
1599
+ async function listGlossDaemonProcesses() {
1387
1600
  let stdout;
1388
1601
  try {
1389
1602
  ({ stdout } = await execFileAsync("ps", ["-axo", "pid=,user=,command=", "-ww"]));
@@ -1391,25 +1604,60 @@ async function listGlossDaemonPids() {
1391
1604
  return [];
1392
1605
  }
1393
1606
  const currentUser = userInfo().username;
1394
- return parseGlossDaemonPids(stdout, currentUser, process.pid);
1607
+ return parseGlossDaemonProcesses(stdout, currentUser, process.pid);
1395
1608
  }
1396
- function parseGlossDaemonPids(stdout, currentUser, currentPid = process.pid) {
1609
+ function parseGlossDaemonProcesses(stdout, currentUser, currentPid = process.pid) {
1397
1610
  return stdout.split("\n").map((line) => /^\s*(\d+)\s+(\S+)\s+(.+)$/.exec(line)).filter((match) => Boolean(match)).map((match) => ({
1398
1611
  pid: Number(match[1]),
1399
1612
  user: match[2],
1400
1613
  command: match[3]
1614
+ })).map((processInfo) => ({
1615
+ ...processInfo,
1616
+ parsed: parseGlossDaemonCommand(processInfo.command)
1401
1617
  })).filter(
1402
- ({ pid, user, command }) => pid !== currentPid && user === currentUser && isGlossDaemonCommand(command)
1403
- ).map(({ pid }) => pid);
1618
+ (processInfo) => processInfo.pid !== currentPid && processInfo.user === currentUser && processInfo.parsed !== null
1619
+ ).map(({ pid, user, command, parsed }) => ({
1620
+ pid,
1621
+ user,
1622
+ command,
1623
+ daemonPath: parsed.daemonPath,
1624
+ ...parsed.homebrewVersion ? { homebrewVersion: parsed.homebrewVersion } : {}
1625
+ }));
1626
+ }
1627
+ function parseGlossDaemonCommand(command) {
1628
+ const match = /(?:^|\s)(?:\S*\/)?node\s+(\S*dist\/server\/daemon\.js)(?:\s|$)/.exec(command);
1629
+ if (!match) {
1630
+ return null;
1631
+ }
1632
+ const daemonPath = match[1];
1633
+ const homebrewVersion = /\/Cellar\/gloss\/([^/]+)\/libexec\/lib\/node_modules\/getgloss\/dist\/server\/daemon\.js$/.exec(
1634
+ daemonPath
1635
+ )?.[1];
1636
+ return {
1637
+ daemonPath,
1638
+ ...homebrewVersion ? { homebrewVersion } : {}
1639
+ };
1640
+ }
1641
+ function isMissingDaemonSource(processInfo) {
1642
+ return !existsSync(processInfo.daemonPath);
1404
1643
  }
1405
- function isGlossDaemonCommand(command) {
1406
- return /(?:^|\s)(?:\S*\/)?node\s+\S*dist\/server\/daemon\.js(?:\s|$)/.test(command);
1644
+ function isStaleHomebrewDaemon(processInfo) {
1645
+ return Boolean(processInfo.homebrewVersion && processInfo.homebrewVersion !== packageVersion);
1646
+ }
1647
+ function daemonSourceKey(processInfo) {
1648
+ return daemonPathSourceKey(processInfo.daemonPath);
1649
+ }
1650
+ function daemonPathSourceKey(daemonPath) {
1651
+ return path5.normalize(daemonPath);
1652
+ }
1653
+ async function sleep2(milliseconds) {
1654
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
1407
1655
  }
1408
1656
 
1409
1657
  // src/server/store.ts
1410
1658
  import { createHash } from "crypto";
1411
- import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
1412
- import path4 from "path";
1659
+ import { readdir as readdir2, readFile as readFile4 } from "fs/promises";
1660
+ import path6 from "path";
1413
1661
  import { ulid } from "ulid";
1414
1662
 
1415
1663
  // src/shared/comments.ts
@@ -1880,16 +2128,25 @@ var ReviewStore = class {
1880
2128
  const reviewLoads = [];
1881
2129
  for (const entry of entries) {
1882
2130
  if (entry.isDirectory()) {
1883
- reviewLoads.push(this.loadReview(entry.name));
2131
+ reviewLoads.push(this.loadReviewForList(entry.name));
1884
2132
  }
1885
2133
  }
1886
2134
  await Promise.all(reviewLoads);
1887
2135
  }
2136
+ async loadReviewForList(id) {
2137
+ try {
2138
+ return await this.loadReview(id);
2139
+ } catch (error) {
2140
+ process.stderr.write(`Warning: Skipping corrupt review ${id}: ${formatError(error)}
2141
+ `);
2142
+ return null;
2143
+ }
2144
+ }
1888
2145
  async loadReview(id) {
1889
2146
  const metaPath = globalReviewMetaFile(id);
1890
2147
  let metaRaw;
1891
2148
  try {
1892
- metaRaw = await readFile3(metaPath, "utf8");
2149
+ metaRaw = await readFile4(metaPath, "utf8");
1893
2150
  } catch (error) {
1894
2151
  if (isFileNotFound(error)) {
1895
2152
  return this.loadReviewFromTurnsOnly(id);
@@ -1971,8 +2228,8 @@ var ReviewStore = class {
1971
2228
  let diffRaw;
1972
2229
  try {
1973
2230
  [metaRaw, diffRaw] = await Promise.all([
1974
- readFile3(metaPath, "utf8"),
1975
- readFile3(diffPath, "utf8")
2231
+ readFile4(metaPath, "utf8"),
2232
+ readFile4(diffPath, "utf8")
1976
2233
  ]);
1977
2234
  } catch (error) {
1978
2235
  if (isFileNotFound(error)) {
@@ -2002,7 +2259,7 @@ var ReviewStore = class {
2002
2259
  const diffPath = globalReviewDiffFile(id);
2003
2260
  let diffRaw;
2004
2261
  try {
2005
- diffRaw = await readFile3(diffPath, "utf8");
2262
+ diffRaw = await readFile4(diffPath, "utf8");
2006
2263
  } catch (error) {
2007
2264
  if (isFileNotFound(error)) {
2008
2265
  return null;
@@ -2182,9 +2439,9 @@ function reconcileTurn(meta, diff, feedback, resolution) {
2182
2439
  status,
2183
2440
  submittedAt: feedback?.timestamp ?? meta.submittedAt,
2184
2441
  resolvedAt: status === "resolved" ? resolution?.resolvedAt ?? meta.resolvedAt : void 0,
2185
- feedbackPath: feedback ? meta.feedbackPath ?? path4.join(meta.artifactDir, "feedback.json") : void 0,
2186
- markdownPath: feedback ? meta.markdownPath ?? path4.join(meta.artifactDir, "feedback.md") : void 0,
2187
- resolvedPath: resolution ? meta.resolvedPath ?? path4.join(meta.artifactDir, "resolved.json") : void 0,
2442
+ feedbackPath: feedback ? meta.feedbackPath ?? path6.join(meta.artifactDir, "feedback.json") : void 0,
2443
+ markdownPath: feedback ? meta.markdownPath ?? path6.join(meta.artifactDir, "feedback.md") : void 0,
2444
+ resolvedPath: resolution ? meta.resolvedPath ?? path6.join(meta.artifactDir, "resolved.json") : void 0,
2188
2445
  diff,
2189
2446
  ...feedback ? { feedback } : {},
2190
2447
  ...resolution ? { resolution } : {}
@@ -2217,7 +2474,7 @@ function requiredPath(value, label) {
2217
2474
  async function readOptionalJsonFile(filePath, guard, label) {
2218
2475
  let raw;
2219
2476
  try {
2220
- raw = await readFile3(filePath, "utf8");
2477
+ raw = await readFile4(filePath, "utf8");
2221
2478
  } catch (error) {
2222
2479
  if (isFileNotFound(error)) {
2223
2480
  return void 0;
@@ -2367,7 +2624,9 @@ program.command("start").description("Start or reuse the background server").opt
2367
2624
  });
2368
2625
  program.command("status").description("Show server and active reviews").action(async () => {
2369
2626
  const globals = program.opts();
2370
- const info = await readServerInfo();
2627
+ let info = await readServerInfo();
2628
+ await reapStaleDaemons(info);
2629
+ info = await readServerInfo();
2371
2630
  const responsive = info ? await isServerResponsive(info) : false;
2372
2631
  const reviews = await listReviewsForStatus({ responsive, server: info });
2373
2632
  const status = { running: responsive, server: info, reviews };
@@ -2378,9 +2637,7 @@ program.command("status").description("Show server and active reviews").action(a
2378
2637
  program.command("stop").description("Stop the managed background server").option("--all", "stop all Gloss daemon processes for the current user").action(async (options) => {
2379
2638
  const globals = program.opts();
2380
2639
  const result = await stopServer({ all: options.all });
2381
- globals.json ? printJson(result) : printPlain(
2382
- options.all && result.stoppedPids ? `Stopped ${result.stoppedPids.length} Gloss daemon(s)` : result.stopped ? "Gloss server stopped" : "Gloss server was not running"
2383
- );
2640
+ globals.json ? printJson(result) : printPlain(formatStopResult(result, options.all === true));
2384
2641
  });
2385
2642
  program.command("clear").description("Delete old completed review artifacts").option(
2386
2643
  "--older-than <days>",
@@ -2438,11 +2695,19 @@ program.command("doctor").description("Diagnose setup and validate git/state").a
2438
2695
  detail: error instanceof Error ? error.message : String(error)
2439
2696
  });
2440
2697
  }
2441
- const info = await readServerInfo();
2698
+ checks.push(await checkStateDirAccess());
2699
+ checks.push(await checkServerInfoAccess());
2700
+ let info = null;
2701
+ let serverStateError = null;
2702
+ try {
2703
+ info = await readServerInfo();
2704
+ } catch (error) {
2705
+ serverStateError = error;
2706
+ }
2442
2707
  checks.push({
2443
2708
  name: "server",
2444
2709
  ok: info ? await isServerResponsive(info) : false,
2445
- detail: info ? serverUrl(info) : "not started"
2710
+ detail: info ? serverUrl(info) : serverStateError ? formatError(serverStateError) : "not started"
2446
2711
  });
2447
2712
  try {
2448
2713
  const daemonPids = await listGlossDaemonPids();
@@ -2487,7 +2752,7 @@ async function watchReviewWithReconnect(reviewId, initialInfo, timeoutSeconds, o
2487
2752
  if (!isReconnectableWatchError(error)) {
2488
2753
  throw error;
2489
2754
  }
2490
- await sleep2(500);
2755
+ await sleep3(500);
2491
2756
  const nextInfo = await ensureServer();
2492
2757
  if (nextInfo.port !== info.port) {
2493
2758
  await onServerChanged(nextInfo);
@@ -2496,6 +2761,46 @@ async function watchReviewWithReconnect(reviewId, initialInfo, timeoutSeconds, o
2496
2761
  }
2497
2762
  }
2498
2763
  }
2764
+ function formatStopResult(result, all) {
2765
+ const status = all && result.stoppedPids ? `Stopped ${result.stoppedPids.length} Gloss daemon(s)` : result.stopped ? "Gloss server stopped" : "Gloss server was not running";
2766
+ return result.warning ? `${status}
2767
+ Warning: ${result.warning}` : status;
2768
+ }
2769
+ async function checkStateDirAccess() {
2770
+ const probePath = path7.join(globalStateDir(), `.doctor-${process.pid}-${randomUUID3()}.tmp`);
2771
+ try {
2772
+ await ensureDir(globalStateDir());
2773
+ await access(globalStateDir(), constants.R_OK | constants.W_OK | constants.X_OK);
2774
+ await writeFile4(probePath, "");
2775
+ await rm5(probePath, { force: true });
2776
+ return { name: "state-dir", ok: true, detail: stateDirDetail() };
2777
+ } catch (error) {
2778
+ await rm5(probePath, { force: true }).catch(() => void 0);
2779
+ return {
2780
+ name: "state-dir",
2781
+ ok: false,
2782
+ detail: `${stateDirDetail()}: ${formatError(error)}. Set GLOSS_STATE_DIR to a writable directory for sandboxed agents.`
2783
+ };
2784
+ }
2785
+ }
2786
+ async function checkServerInfoAccess() {
2787
+ try {
2788
+ await access(globalServerFile(), constants.R_OK | constants.W_OK);
2789
+ return { name: "server-json", ok: true, detail: globalServerFile() };
2790
+ } catch (error) {
2791
+ if (isFileNotFound(error)) {
2792
+ return { name: "server-json", ok: true, detail: "not present" };
2793
+ }
2794
+ return {
2795
+ name: "server-json",
2796
+ ok: false,
2797
+ detail: serverInfoPermissionMessage("access", error)
2798
+ };
2799
+ }
2800
+ }
2801
+ function stateDirDetail() {
2802
+ return process.env.GLOSS_STATE_DIR ? `${globalStateDir()} (from GLOSS_STATE_DIR)` : `${globalStateDir()} (default; set GLOSS_STATE_DIR for a writable sandbox state dir)`;
2803
+ }
2499
2804
  async function baseForExistingReview(client, reviewId) {
2500
2805
  const record = await client.getReview(reviewId);
2501
2806
  return record.diff.scope.mode === "explicit" ? record.diff.scope.requestedBase ?? record.diff.base.ref : null;
@@ -2526,7 +2831,7 @@ function isWatchTimeout(error) {
2526
2831
  function isReconnectableWatchError(error) {
2527
2832
  return error instanceof Error && !/^watch failed: [45]\d\d /.test(error.message);
2528
2833
  }
2529
- async function sleep2(milliseconds) {
2834
+ async function sleep3(milliseconds) {
2530
2835
  await new Promise((resolve) => setTimeout(resolve, milliseconds));
2531
2836
  }
2532
2837
  program.parseAsync(process.argv).catch((error) => {