@diffohq/diffo 0.8.0 → 0.10.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.
Files changed (98) hide show
  1. package/README.md +8 -6
  2. package/dist/cli.mjs +625 -43
  3. package/dist/client/assets/{abnfDiagram-O67JEVCF-BUIFF3Lv.js → abnfDiagram-O67JEVCF-DBlSJW53.js} +1 -1
  4. package/dist/client/assets/{arc-x6Ulf5MI.js → arc-BZZ_ET2N.js} +1 -1
  5. package/dist/client/assets/architecture-7GRP2DOG-Cds-sOVr.js +1 -0
  6. package/dist/client/assets/{architectureDiagram-NJMV4G6O-D25O1A2B.js → architectureDiagram-NJMV4G6O-CmS7e2K4.js} +1 -1
  7. package/dist/client/assets/{blockDiagram-BEXU5L5S-Dy7P3IZw.js → blockDiagram-BEXU5L5S-Kj8H2ODF.js} +1 -1
  8. package/dist/client/assets/{c4Diagram-YGBWAQC7-BnE5HTw4.js → c4Diagram-YGBWAQC7-CixbFJxb.js} +1 -1
  9. package/dist/client/assets/channel-BI4dhOPo.js +1 -0
  10. package/dist/client/assets/{chunk-3FUC2YCW-6C72_i5n.js → chunk-3FUC2YCW-CRdo0Gw5.js} +1 -1
  11. package/dist/client/assets/{chunk-5DYCD2WN-B0IgEc5I.js → chunk-5DYCD2WN-Bw9n6qPh.js} +1 -1
  12. package/dist/client/assets/{chunk-742MDFTN-BNLZzbxp.js → chunk-742MDFTN-BsvOSyDz.js} +1 -1
  13. package/dist/client/assets/{chunk-7INBJB4K-K1DkKka3.js → chunk-7INBJB4K-ie7EoCoW.js} +1 -1
  14. package/dist/client/assets/{chunk-7M6MHVWA-B-KIWC1T.js → chunk-7M6MHVWA-DJlbYDLH.js} +1 -1
  15. package/dist/client/assets/{chunk-7PRAP22T-DR6CzDYx.js → chunk-7PRAP22T-Hs5y39V8.js} +1 -1
  16. package/dist/client/assets/{chunk-GTNCS2PH-BWkvnYRd.js → chunk-GTNCS2PH-DNq6Ojc7.js} +1 -1
  17. package/dist/client/assets/{chunk-GWA4HPMP-l8NLPoZc.js → chunk-GWA4HPMP-vgn0kxbp.js} +1 -1
  18. package/dist/client/assets/{chunk-MBY4JIJT-k1mwME9y.js → chunk-MBY4JIJT-C7fMuqSl.js} +1 -1
  19. package/dist/client/assets/{chunk-NETBCI7D-DV1F8c_R.js → chunk-NETBCI7D-ZJtjvGwt.js} +1 -1
  20. package/dist/client/assets/{chunk-O7XYJQB3-Bb8gIxaB.js → chunk-O7XYJQB3-DESjnZZT.js} +1 -1
  21. package/dist/client/assets/{chunk-UA2S7LBM-BL7m6fd_.js → chunk-UA2S7LBM-Cmf087s8.js} +1 -1
  22. package/dist/client/assets/{chunk-WEXAMYUT-CAnQv1qY.js → chunk-WEXAMYUT-U1TW5ZAF.js} +1 -1
  23. package/dist/client/assets/{chunk-XXDRQBXY-DooI1miH.js → chunk-XXDRQBXY-D1B1IYm1.js} +1 -1
  24. package/dist/client/assets/{chunk-Z7XXMR3K-DPVaCcdM.js → chunk-Z7XXMR3K-D3BUH0qv.js} +1 -1
  25. package/dist/client/assets/{chunk-ZIGJFQKS-Bz4IWpFy.js → chunk-ZIGJFQKS-Bx6yZhwg.js} +1 -1
  26. package/dist/client/assets/{classDiagram-v2-NBCMYWYE-Dso7ogb5.js → classDiagram-v2-NBCMYWYE-sjiBGJdD.js} +1 -1
  27. package/dist/client/assets/{core-Dy-hJrbg.js → core-6IJUkucC.js} +1 -1
  28. package/dist/client/assets/{cose-bilkent-JH36ORCC-Baj8S6bg.js → cose-bilkent-JH36ORCC-xaMCCs4V.js} +1 -1
  29. package/dist/client/assets/{cynefin-OW5HDTMX-BkA1ccYX.js → cynefin-OW5HDTMX-C5k9L5NF.js} +1 -1
  30. package/dist/client/assets/{cynefinDiagram-VND7K2PF-nyK9OZGN.js → cynefinDiagram-VND7K2PF-BqiILl4G.js} +1 -1
  31. package/dist/client/assets/{dagre-6A5THRUB-BP_dyQLS.js → dagre-6A5THRUB-BtgX5KvU.js} +1 -1
  32. package/dist/client/assets/{diagram-22UHCM2B-BIt_aIJx.js → diagram-22UHCM2B-BcDIKY8C.js} +1 -1
  33. package/dist/client/assets/{diagram-3UASUU5V-UX2eUwyi.js → diagram-3UASUU5V-C-DrBJ6K.js} +1 -1
  34. package/dist/client/assets/{diagram-ATOU4E4O-CEYK5oZO.js → diagram-ATOU4E4O-B0W6FX6y.js} +1 -1
  35. package/dist/client/assets/{diagram-CDSNMT55-4uIYrmGp.js → diagram-CDSNMT55-D1O9NYQ3.js} +1 -1
  36. package/dist/client/assets/{diagram-MLGK6HIB-5BGrc92k.js → diagram-MLGK6HIB-WC8izmk2.js} +1 -1
  37. package/dist/client/assets/{diagram-MPIPVDR6-0BG375Nf.js → diagram-MPIPVDR6-D5ko9V-k.js} +1 -1
  38. package/dist/client/assets/{dist-uIEEXDIm.js → dist-BBmACywa.js} +1 -1
  39. package/dist/client/assets/{dist-hfzhbdYS.js → dist-Bss8uvIz.js} +1 -1
  40. package/dist/client/assets/{ebnfDiagram-ZINNZB2B-CnAveRvy.js → ebnfDiagram-ZINNZB2B-BFIYDkrv.js} +1 -1
  41. package/dist/client/assets/{elk-276RUBZZ-CejJ0q5w.js → elk-276RUBZZ-WsSc0CyB.js} +1 -1
  42. package/dist/client/assets/{engine-oniguruma-BpOI5GVJ.js → engine-oniguruma-DmL6leO7.js} +1 -1
  43. package/dist/client/assets/{erDiagram-OPXOYQCR-Lg3_iVkX.js → erDiagram-OPXOYQCR-CjaKeGKh.js} +1 -1
  44. package/dist/client/assets/eventmodeling-NTZA5JFV-DE5ql6lo.js +1 -0
  45. package/dist/client/assets/flowDiagram-KWPJA3E3-CObKVtKJ.js +1 -0
  46. package/dist/client/assets/{ganttDiagram-FUAMR5RP-D5nJpDg8.js → ganttDiagram-FUAMR5RP-22OFMoat.js} +1 -1
  47. package/dist/client/assets/{gitGraph-4MIJSDKK-BLNIFBUu.js → gitGraph-4MIJSDKK-BP0idayz.js} +1 -1
  48. package/dist/client/assets/{gitGraphDiagram-X574FWY7-YHwgA3VE.js → gitGraphDiagram-X574FWY7-Cx5rPzjc.js} +1 -1
  49. package/dist/client/assets/{index-CE0LasmM.css → index-CL4K6Y07.css} +1 -1
  50. package/dist/client/assets/index-DXMhk4ok.js +93 -0
  51. package/dist/client/assets/{info-A6RAGUB7-Bfa8ErwC.js → info-A6RAGUB7-BvZZu8IY.js} +1 -1
  52. package/dist/client/assets/{infoDiagram-VRGFBTTK-Dzp_xQpV.js → infoDiagram-VRGFBTTK-Bbk1aZco.js} +1 -1
  53. package/dist/client/assets/{ishikawaDiagram-OU5B5YK6-Hun-xe-t.js → ishikawaDiagram-OU5B5YK6-DbyWrUBY.js} +1 -1
  54. package/dist/client/assets/{journeyDiagram-ZHPQQLJL-CT5fXqj_.js → journeyDiagram-ZHPQQLJL-Ka7zlagO.js} +1 -1
  55. package/dist/client/assets/{kanban-definition-PNTS6WVX-kFYcpsMx.js → kanban-definition-PNTS6WVX-Q_NAEdRt.js} +1 -1
  56. package/dist/client/assets/{line-Cda_OIfO.js → line-SDAA9fEr.js} +1 -1
  57. package/dist/client/assets/{linear-CDQGk4q-.js → linear-BPf2OGUv.js} +1 -1
  58. package/dist/client/assets/{mermaid-parser.core-CCoRJPAh.js → mermaid-parser.core-BjLzFSoy.js} +3 -3
  59. package/dist/client/assets/{mermaid.core-SnjyC0ZV.js → mermaid.core-vvUsQbpX.js} +4 -4
  60. package/dist/client/assets/{mindmap-definition-NLK3R4M7-C3oLUr5b.js → mindmap-definition-NLK3R4M7-DuVzu_4M.js} +1 -1
  61. package/dist/client/assets/{packet-AYTQ26CC-BT6BgV6a.js → packet-AYTQ26CC-A66za-wc.js} +1 -1
  62. package/dist/client/assets/{pegDiagram-GJSIUBJH-OT5BsfLg.js → pegDiagram-GJSIUBJH-ClH92MDY.js} +1 -1
  63. package/dist/client/assets/{pie-WAS4IAKB-Bou89QXR.js → pie-WAS4IAKB-sJ2H2jF4.js} +1 -1
  64. package/dist/client/assets/{pieDiagram-5QR66LMP-AzEtJfJu.js → pieDiagram-5QR66LMP-BmlcB4fD.js} +1 -1
  65. package/dist/client/assets/{quadrantDiagram-O4NWA36T-BUKCkyd7.js → quadrantDiagram-O4NWA36T-BUdNHhtc.js} +1 -1
  66. package/dist/client/assets/{radar-RG4KPBEZ-D77V4y6h.js → radar-RG4KPBEZ-B3MRkz7l.js} +1 -1
  67. package/dist/client/assets/{railroad-74A4TZTK-CIYb8bnG.js → railroad-74A4TZTK-CPQddZw9.js} +1 -1
  68. package/dist/client/assets/railroad-abnf-HS5TGJTU-CijG3JBn.js +1 -0
  69. package/dist/client/assets/railroad-ebnf-LZEXJU2U-CNgXgJgc.js +1 -0
  70. package/dist/client/assets/railroad-peg-WCYAUIDC-BkwGmMtj.js +1 -0
  71. package/dist/client/assets/{railroadDiagram-XR7U4H2S-DhUQyIW9.js → railroadDiagram-XR7U4H2S-B2wicmNk.js} +1 -1
  72. package/dist/client/assets/{requirementDiagram-PLB6GJNP-5HRstoAa.js → requirementDiagram-PLB6GJNP-4fGmNry2.js} +1 -1
  73. package/dist/client/assets/{sankeyDiagram-IPEJSGJF-sNbgZbEH.js → sankeyDiagram-IPEJSGJF-ByuhL9nO.js} +1 -1
  74. package/dist/client/assets/{sequenceDiagram-PO4LG4MO-B_vIrQ3k.js → sequenceDiagram-PO4LG4MO-CHeFXNrS.js} +1 -1
  75. package/dist/client/assets/{src-BWz11yEV.js → src-DuEx6MBc.js} +1 -1
  76. package/dist/client/assets/{stateDiagram-v2-GCMORJYK-BnRt6hQF.js → stateDiagram-v2-GCMORJYK-Dsfly8rU.js} +1 -1
  77. package/dist/client/assets/{swimlanes-2SLR337P-CLlJdLDU.js → swimlanes-2SLR337P-DDb1xXAV.js} +1 -1
  78. package/dist/client/assets/swimlanesDiagram-TC7HE7FX-DXpYpXP-.js +8 -0
  79. package/dist/client/assets/{timeline-definition-EJHVYXUP-sXwAwUmH.js → timeline-definition-EJHVYXUP-iC4lxMiK.js} +1 -1
  80. package/dist/client/assets/{treeView-Q6P3EWNA-BmhLEMPr.js → treeView-Q6P3EWNA-zXF3RWVa.js} +1 -1
  81. package/dist/client/assets/{treemap-WGGIJYW6-D_PGkPFQ.js → treemap-WGGIJYW6-BvA719M0.js} +1 -1
  82. package/dist/client/assets/{usecaseDiagram-POWQR4AR-GARihMJS.js → usecaseDiagram-POWQR4AR-CXzh5cdW.js} +1 -1
  83. package/dist/client/assets/{vennDiagram-UO4OBE2U-DXSYoTtG.js → vennDiagram-UO4OBE2U-BuMQeNKp.js} +1 -1
  84. package/dist/client/assets/{wardley-WFR3VGLG-Caebv6bl.js → wardley-WFR3VGLG-m1zZI6-6.js} +1 -1
  85. package/dist/client/assets/{wardleyDiagram-VNRHLVJA-BnFa4EKC.js → wardleyDiagram-VNRHLVJA-DiB2QtGU.js} +1 -1
  86. package/dist/client/assets/{xychartDiagram-PMCCYNJV-XS2BbOyB.js → xychartDiagram-PMCCYNJV-CKRPoaV8.js} +1 -1
  87. package/dist/client/index.html +2 -2
  88. package/package.json +8 -8
  89. package/plugin.json +1 -1
  90. package/dist/client/assets/architecture-7GRP2DOG-Cx2ezWq8.js +0 -1
  91. package/dist/client/assets/channel-IK-3bv-L.js +0 -1
  92. package/dist/client/assets/eventmodeling-NTZA5JFV-Dh34QIVY.js +0 -1
  93. package/dist/client/assets/flowDiagram-KWPJA3E3-Bcos9jHd.js +0 -1
  94. package/dist/client/assets/index-DYt1d3uP.js +0 -93
  95. package/dist/client/assets/railroad-abnf-HS5TGJTU-OVikttJY.js +0 -1
  96. package/dist/client/assets/railroad-ebnf-LZEXJU2U-DPAI1fGw.js +0 -1
  97. package/dist/client/assets/railroad-peg-WCYAUIDC-CcsZpvwt.js +0 -1
  98. package/dist/client/assets/swimlanesDiagram-TC7HE7FX-D6iEiuTl.js +0 -8
package/dist/cli.mjs CHANGED
@@ -17,9 +17,24 @@ import { stream, streamSSE } from "hono/streaming";
17
17
  * token only: under Claude Code the transient `zsh -c` carries
18
18
  * `~/.claude/shell-snapshots/…` in its arguments, which would match HARNESS. */
19
19
  const SHELL = /^-?(?:\/[^ ]*\/)?(?:sh|bash|zsh|fish|dash|ksh|csh|tcsh)(?:\s|$)/;
20
+ /** The coding agents whose processes the ancestor walk recognises. Also the
21
+ * vocabulary of the `agent` field in usage data, so a name never travels unless
22
+ * it is on this list. */
23
+ const HARNESS_NAMES = [
24
+ "claude",
25
+ "cursor",
26
+ "codex",
27
+ "copilot",
28
+ "windsurf",
29
+ "zed",
30
+ "aider",
31
+ "goose",
32
+ "cline",
33
+ "amp"
34
+ ];
20
35
  /** A harness name at the start of a path segment or token, never inside a word:
21
36
  * `/Users/sam/example/` must not read as `amp`. */
22
- const HARNESS = /(?:^|[/\s@_.-])(?:claude|cursor|codex|copilot|windsurf|zed|aider|goose|cline|amp)/i;
37
+ const HARNESS = new RegExp(`(?:^|[/\\s@_.-])(${HARNESS_NAMES.join("|")})`, "i");
23
38
  function ownInvocation() {
24
39
  return {
25
40
  script: process.argv[1] ?? "",
@@ -34,15 +49,26 @@ function isOwnLauncher(command, own) {
34
49
  const tail = own.args.length > 0 ? `${base} ${own.args.join(" ")}` : own.script;
35
50
  return command === tail || command.endsWith(` ${tail}`) || command.endsWith(`/${tail}`);
36
51
  }
37
- function pickSessionAncestor(ancestors, own = ownInvocation()) {
52
+ function pickSession(ancestors, own) {
38
53
  for (const row of ancestors) {
39
54
  const command = row.command.trim();
40
55
  if (SHELL.test(command)) continue;
41
56
  if (isOwnLauncher(command, own)) continue;
42
- if (HARNESS.test(command)) return row.pid;
57
+ const match = HARNESS.exec(command);
58
+ if (match) return {
59
+ pid: row.pid,
60
+ harness: match[1].toLowerCase()
61
+ };
43
62
  }
44
63
  return null;
45
64
  }
65
+ function pickSessionAncestor(ancestors, own = ownInvocation()) {
66
+ return pickSession(ancestors, own)?.pid ?? null;
67
+ }
68
+ /** Which agent the session is, by the same walk that finds it. */
69
+ function pickSessionHarness(ancestors, own = ownInvocation()) {
70
+ return pickSession(ancestors, own)?.harness ?? null;
71
+ }
46
72
  function readRow(pid) {
47
73
  try {
48
74
  const ppid = Number.parseInt(execFileSync("ps", [
@@ -87,6 +113,16 @@ function detectSessionPid() {
87
113
  return null;
88
114
  }
89
115
  }
116
+ /** The agent this CLI runs under, or null from a plain terminal (and on Windows,
117
+ * where the walk is not available). Only ever a name from HARNESS_NAMES. */
118
+ function detectHarness() {
119
+ if (process.platform === "win32") return null;
120
+ try {
121
+ return pickSessionHarness(readAncestors(process.ppid));
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
90
126
  //#endregion
91
127
  //#region src/shared/review.ts
92
128
  const THREAD_INTENTS = ["fix", "question"];
@@ -1019,6 +1055,8 @@ For the reviewer:
1019
1055
  setup Register diffo with the coding agents on this machine
1020
1056
  (Claude Code, Cursor, VS Code, Copilot, and the shared
1021
1057
  ~/.agents skills dir read by Codex, Gemini, Amp, Goose, …)
1058
+ telemetry Show whether anonymous usage data is on, or turn it
1059
+ off / on (telemetry off · telemetry on)
1022
1060
 
1023
1061
  For the agent (the AI that wrote the change):
1024
1062
  poll Wait for the reviewer's feedback (blocking long-poll;
@@ -1358,7 +1396,33 @@ are left as they are, and opening a review keeps installed skills fresh
1358
1396
  automatically after upgrades.
1359
1397
 
1360
1398
  Example:
1361
- diffo setup`
1399
+ diffo setup`,
1400
+ telemetry: `diffo telemetry: anonymous usage data — see it, turn it off, turn it on
1401
+
1402
+ Usage: diffo telemetry [status|on|off]
1403
+
1404
+ Diffo reports two small anonymous events per review, "opened" and "finished":
1405
+ its version, your OS and Node major, the kind of review (working tree, branch,
1406
+ or pull request), which agent opened it, counts (threads, comments, whether
1407
+ layers were used, a coarse duration), and whether it runs from a source
1408
+ checkout (dev: true, so the maintainers' own use is filtered out). Never code,
1409
+ paths, repository or branch names, comment text, or anything about the pull
1410
+ request. One random id per machine tells returning machines from new ones;
1411
+ turning reporting off deletes it. Nothing is sent until the review page has
1412
+ shown you the notice once, and nothing from the review that showed it.
1413
+
1414
+ status what is on, why it is off, and where the data goes (the default)
1415
+ off stop reporting on this machine, for good (a machine that has
1416
+ reported sends one last event saying so, then nothing)
1417
+ on start again (a fresh id, not the old history)
1418
+
1419
+ DIFFO_TELEMETRY_DISABLED=1 or DO_NOT_TRACK=1 turns it off for one process or
1420
+ for a fleet; CI does the same. DIFFO_TELEMETRY_DEBUG=1 writes every payload to
1421
+ the server log before it is sent. The full list, and where it goes:
1422
+ https://diffohq.github.io/diffo/telemetry
1423
+
1424
+ Example:
1425
+ diffo telemetry off`
1362
1426
  };
1363
1427
  function helpFor(topic) {
1364
1428
  return topic && VERB_HELP[topic] || HELP_TEXT;
@@ -1370,6 +1434,7 @@ const VERBS = /* @__PURE__ */ new Set([
1370
1434
  "layers",
1371
1435
  "end",
1372
1436
  "setup",
1437
+ "telemetry",
1373
1438
  "status",
1374
1439
  "stop",
1375
1440
  "clean",
@@ -1653,9 +1718,38 @@ function parseCleanVerb(rest) {
1653
1718
  all: parsed.value.values.all === true
1654
1719
  };
1655
1720
  }
1721
+ function parseTelemetryVerb(rest) {
1722
+ const parsed = tryParse(() => parseArgs({
1723
+ args: rest,
1724
+ allowPositionals: true,
1725
+ options: { help: {
1726
+ type: "boolean",
1727
+ short: "h"
1728
+ } }
1729
+ }));
1730
+ if (!parsed.ok) return {
1731
+ kind: "error",
1732
+ message: parsed.message
1733
+ };
1734
+ const { values, positionals } = parsed.value;
1735
+ if (values.help) return {
1736
+ kind: "help",
1737
+ topic: "telemetry"
1738
+ };
1739
+ const action = positionals[0] ?? "status";
1740
+ if (positionals.length > 1 || action !== "status" && action !== "on" && action !== "off") return {
1741
+ kind: "error",
1742
+ message: "telemetry takes one of: status, on, off"
1743
+ };
1744
+ return {
1745
+ kind: "telemetry",
1746
+ action
1747
+ };
1748
+ }
1656
1749
  function parseVerb(verb, rest) {
1657
1750
  if (verb === "layers") return parseLayersVerb(rest);
1658
1751
  if (verb === "clean") return parseCleanVerb(rest);
1752
+ if (verb === "telemetry") return parseTelemetryVerb(rest);
1659
1753
  const parsed = tryParse(() => parseArgs({
1660
1754
  args: rest,
1661
1755
  allowPositionals: true,
@@ -1865,7 +1959,6 @@ query($owner: String!, $name: String!, $number: Int!) {
1865
1959
  messageHeadline
1866
1960
  committedDate
1867
1961
  author { name user { login avatarUrl } }
1868
- statusCheckRollup { state }
1869
1962
  }
1870
1963
  }
1871
1964
  }
@@ -1880,6 +1973,24 @@ query($owner: String!, $name: String!, $number: Int!) {
1880
1973
  }
1881
1974
  }
1882
1975
  }`;
1976
+ /**
1977
+ * CI status, asked for on its own. Reading it needs the checks and statuses
1978
+ * scopes, which a fine-grained or SAML-limited token may lack while reading
1979
+ * everything else about the pull request; GraphQL answers that with a
1980
+ * FORBIDDEN error on this one field, and `gh` turns any error into a failed
1981
+ * call. Kept out of the PR query so a token that cannot see CI still opens
1982
+ * the review.
1983
+ */
1984
+ const CHECKS_QUERY = `
1985
+ query($owner: String!, $name: String!, $number: Int!) {
1986
+ repository(owner: $owner, name: $name) {
1987
+ pullRequest(number: $number) {
1988
+ commits(last: 1) {
1989
+ nodes { commit { statusCheckRollup { state } } }
1990
+ }
1991
+ }
1992
+ }
1993
+ }`;
1883
1994
  /** The pages after the first of each connection the PR query opens. */
1884
1995
  const REVIEWS_QUERY = `
1885
1996
  query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
@@ -2126,18 +2237,32 @@ var GhClient = class {
2126
2237
  reviewNodes
2127
2238
  };
2128
2239
  }
2240
+ /** CI on the head commit, best effort: a token without the checks scope
2241
+ * gets `unknown` and the review opens anyway. Never throws. */
2242
+ async checks(ref) {
2243
+ try {
2244
+ const rollup = ((await this.graphql(ref.host, CHECKS_QUERY, {
2245
+ owner: ref.owner,
2246
+ name: ref.repo,
2247
+ number: ref.number
2248
+ })).repository?.pullRequest?.commits.nodes.at(-1)?.commit)?.statusCheckRollup;
2249
+ return checkState(rollup?.state);
2250
+ } catch {
2251
+ return "unknown";
2252
+ }
2253
+ }
2129
2254
  async getPr(ref) {
2130
- const { viewer, pr, reviewNodes } = await this.head(ref);
2131
- return prInfo(ref, viewer, pr, reviewNodes);
2255
+ const [{ viewer, pr, reviewNodes }, checks] = await Promise.all([this.head(ref), this.checks(ref)]);
2256
+ return prInfo(ref, viewer, pr, reviewNodes, checks);
2132
2257
  }
2133
2258
  async listThreads(ref) {
2134
2259
  const { pr, reviewNodes } = await this.head(ref);
2135
2260
  return this.conversation(ref, pr, reviewNodes);
2136
2261
  }
2137
2262
  async fetchPr(ref) {
2138
- const { viewer, pr, reviewNodes } = await this.head(ref);
2263
+ const [{ viewer, pr, reviewNodes }, checks] = await Promise.all([this.head(ref), this.checks(ref)]);
2139
2264
  return {
2140
- pr: prInfo(ref, viewer, pr, reviewNodes),
2265
+ pr: prInfo(ref, viewer, pr, reviewNodes, checks),
2141
2266
  threads: await this.conversation(ref, pr, reviewNodes)
2142
2267
  };
2143
2268
  }
@@ -2251,9 +2376,8 @@ var GhClient = class {
2251
2376
  }
2252
2377
  };
2253
2378
  /** The PR query's answer as a PrInfo, with every review page in hand. */
2254
- function prInfo(ref, viewer, pr, reviewNodes) {
2255
- const commitNodes = pr.commits.nodes ?? [];
2256
- const commits = commitNodes.map((n) => {
2379
+ function prInfo(ref, viewer, pr, reviewNodes, checks) {
2380
+ const commits = (pr.commits.nodes ?? []).map((n) => {
2257
2381
  const c = n.commit;
2258
2382
  const author = c.author;
2259
2383
  return {
@@ -2263,7 +2387,6 @@ function prInfo(ref, viewer, pr, reviewNodes) {
2263
2387
  at: String(c.committedDate ?? "")
2264
2388
  };
2265
2389
  });
2266
- const rollup = (commitNodes.at(-1)?.commit)?.statusCheckRollup;
2267
2390
  const reviews = reviewNodes.map((r) => ({
2268
2391
  id: String(r.id),
2269
2392
  author: user(r.author),
@@ -2296,7 +2419,7 @@ function prInfo(ref, viewer, pr, reviewNodes) {
2296
2419
  sha: String(pr.headRefOid)
2297
2420
  },
2298
2421
  commits,
2299
- checks: { state: checkState(rollup?.state) },
2422
+ checks: { state: checks },
2300
2423
  reviews: reviews.filter((r) => r.state !== "PENDING"),
2301
2424
  approvals: [...latest.values()].filter((s) => s === "APPROVED").length,
2302
2425
  changesRequested: [...latest.values()].filter((s) => s === "CHANGES_REQUESTED").length,
@@ -2514,6 +2637,25 @@ function branchExists(root, branch) {
2514
2637
  return err.status !== 1;
2515
2638
  }
2516
2639
  }
2640
+ /** {@link branchExists}, off the event loop, for the daemon's background sweep
2641
+ * over every repo it has ever reviewed. Same lean: true unless git said no. */
2642
+ async function branchExistsAsync(root, branch) {
2643
+ try {
2644
+ await execFileAsync("git", [
2645
+ "show-ref",
2646
+ "--verify",
2647
+ "--quiet",
2648
+ `refs/heads/${branch}`
2649
+ ], {
2650
+ cwd: root,
2651
+ encoding: "utf-8",
2652
+ maxBuffer: MAX_BUFFER
2653
+ });
2654
+ return true;
2655
+ } catch (err) {
2656
+ return err.code !== 1;
2657
+ }
2658
+ }
2517
2659
  /**
2518
2660
  * The name of the linked worktree we're in, or null in the main one. A linked
2519
2661
  * worktree's git dir is `<main>/.git/worktrees/<name>`, while the main worktree's
@@ -3148,7 +3290,17 @@ const SCHEMA_VERSION = 3;
3148
3290
  const REVIEWS_RESHAPED_AT = 2;
3149
3291
  var DiffoDb = class {
3150
3292
  db;
3293
+ closed = false;
3151
3294
  path;
3295
+ /**
3296
+ * Opening is cheap on purpose: the schema, and one SQL statement for the TTL.
3297
+ * Nothing here touches the filesystem beyond the database file, and nothing
3298
+ * asks git. It used to: every open checked every review's branch with a
3299
+ * `git show-ref`, across every repo this machine ever reviewed — 73 git
3300
+ * processes, 1.5s, on a database a few weeks old, and an open constructs
3301
+ * this five times. The checks live in {@link pruneRepo} (one repo, before its
3302
+ * review loads) and {@link maintain} (everything, in the daemon's background).
3303
+ */
3152
3304
  constructor(path = defaultDbPath()) {
3153
3305
  this.path = path;
3154
3306
  mkdirSync(dirname(path), {
@@ -3160,7 +3312,7 @@ var DiffoDb = class {
3160
3312
  chmodSync(path, 384);
3161
3313
  this.db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 3000; PRAGMA journal_size_limit = 4194304;");
3162
3314
  this.ensureSchema();
3163
- this.pruneVanishedRepos();
3315
+ this.pruneExpiredReviews();
3164
3316
  }
3165
3317
  /**
3166
3318
  * Creates what is missing and retires what is unknown. Runs at open, and
@@ -3229,38 +3381,69 @@ var DiffoDb = class {
3229
3381
  }
3230
3382
  }
3231
3383
  /**
3232
- * Rows about a worktree that is gone can never be reached again — the path *is*
3233
- * the key. An unmounted volume looks the same as a deleted worktree, so its rows
3234
- * go too; the `servers` row needs its process dead as well, or a live server's
3235
- * claim could be dropped over a moment of unreachability.
3384
+ * Reviews untouched for `REVIEW_TTL_DAYS` go on every open: one statement,
3385
+ * no git, and the backstop for everything the two targeted sweeps below leave
3386
+ * for later.
3387
+ */
3388
+ pruneExpiredReviews() {
3389
+ const cutoff = (/* @__PURE__ */ new Date(Date.now() - 5184e6)).toISOString();
3390
+ const stale = this.db.prepare("DELETE FROM reviews WHERE updated_at < ?").run(cutoff);
3391
+ if (Number(stale.changes) > 0) this.checkpoint();
3392
+ }
3393
+ /**
3394
+ * Retire this one repo's reviews for branches it no longer has. The server
3395
+ * runs it before loading the review it is about to serve, so a branch
3396
+ * deleted since the last server start is forgotten before a namesake could
3397
+ * inherit its threads. Only this repo's branches are asked about, so the
3398
+ * cost is a handful of `git show-ref`s, once per server start, never per CLI
3399
+ * call. Branch existence is asked of git rather than assumed — when git can't
3400
+ * answer the row is kept, because guessing here deletes threads.
3401
+ */
3402
+ pruneRepo(repoPath) {
3403
+ const rows = this.db.prepare("SELECT DISTINCT branch FROM reviews WHERE repo_path = ?").all(repoPath);
3404
+ let dropped = 0;
3405
+ for (const { branch } of rows) {
3406
+ if (branch === "" || branchExists(repoPath, branch)) continue;
3407
+ dropped += this.dropReviews(repoPath, branch);
3408
+ }
3409
+ return dropped;
3410
+ }
3411
+ /**
3412
+ * The full sweep, for a long-lived process with time on its hands: rows about
3413
+ * repos that are gone, and reviews for branches that are. The daemon runs it
3414
+ * once, a few seconds after it is serving; `diffo clean` runs it on demand.
3415
+ * Git is asked asynchronously and one repo at a time, so a sweep over every
3416
+ * repo this machine ever reviewed never blocks a request. A `close()` midway
3417
+ * ends it quietly between steps.
3418
+ *
3419
+ * Rows about a worktree that is gone can never be reached again — the path
3420
+ * *is* the key. An unmounted volume looks the same as a deleted worktree, so
3421
+ * its rows go too; the `servers` row needs its process dead as well, or a live
3422
+ * server's claim could be dropped over a moment of unreachability.
3236
3423
  */
3237
- pruneVanishedRepos() {
3238
- for (const { repo_path } of this.distinctRepoPaths("reviews")) if (!existsSync(repo_path)) this.db.prepare("DELETE FROM reviews WHERE repo_path = ?").run(repo_path);
3424
+ async maintain() {
3425
+ const report = { reviews: 0 };
3426
+ if (this.closed) return report;
3427
+ for (const { repo_path } of this.distinctRepoPaths("reviews")) if (!existsSync(repo_path)) report.reviews += this.dropReviews(repo_path);
3239
3428
  for (const { repo_path } of this.distinctRepoPaths("repo_ports")) if (!existsSync(repo_path)) this.db.prepare("DELETE FROM repo_ports WHERE repo_path = ?").run(repo_path);
3240
3429
  const worktrees = this.db.prepare("SELECT worktree_path, repo_path FROM worktrees").all();
3241
3430
  for (const { worktree_path, repo_path } of worktrees) if (!existsSync(repo_path)) this.db.prepare("DELETE FROM worktrees WHERE worktree_path = ?").run(worktree_path);
3242
3431
  const servers = this.db.prepare("SELECT repo_path, pid FROM servers").all();
3243
3432
  for (const { repo_path, pid } of servers) if (!existsSync(repo_path) && !pidAlive$1(pid)) this.db.prepare("DELETE FROM servers WHERE repo_path = ?").run(repo_path);
3244
- this.pruneStaleReviews();
3245
- }
3246
- /**
3247
- * Reviews the reviewer can never return to: a branch that no longer exists, or
3248
- * one untouched for `REVIEW_TTL_DAYS`. Branch existence is asked of git rather
3249
- * than assumed — if git can't answer the row is kept, because guessing here
3250
- * deletes threads.
3251
- */
3252
- pruneStaleReviews() {
3253
- const cutoff = (/* @__PURE__ */ new Date(Date.now() - 5184e6)).toISOString();
3254
- const stale = this.db.prepare("DELETE FROM reviews WHERE updated_at < ?").run(cutoff);
3255
- if (Number(stale.changes) > 0) {
3256
- this.checkpoint();
3257
- return;
3258
- }
3259
3433
  const rows = this.db.prepare("SELECT DISTINCT repo_path, branch FROM reviews").all();
3260
3434
  for (const { repo_path, branch } of rows) {
3261
- if (branch === "" || branchExists(repo_path, branch)) continue;
3262
- this.db.prepare("DELETE FROM reviews WHERE repo_path = ? AND branch = ?").run(repo_path, branch);
3435
+ if (branch === "") continue;
3436
+ const exists = await branchExistsAsync(repo_path, branch);
3437
+ if (this.closed) return report;
3438
+ if (!exists) report.reviews += this.dropReviews(repo_path, branch);
3263
3439
  }
3440
+ if (report.reviews > 0) this.checkpoint();
3441
+ return report;
3442
+ }
3443
+ /** Every review of a repo, or only one branch's. Returns the rows dropped. */
3444
+ dropReviews(repoPath, branch) {
3445
+ const result = branch === void 0 ? this.db.prepare("DELETE FROM reviews WHERE repo_path = ?").run(repoPath) : this.db.prepare("DELETE FROM reviews WHERE repo_path = ? AND branch = ?").run(repoPath, branch);
3446
+ return Number(result.changes);
3264
3447
  }
3265
3448
  /** Fold the write-ahead log back into the database file and truncate it. WAL
3266
3449
  * only shrinks at a checkpoint, which a long-lived server never reaches. */
@@ -3358,6 +3541,7 @@ var DiffoDb = class {
3358
3541
  else this.db.prepare("DELETE FROM servers WHERE repo_path = ? AND port = ?").run(repoPath, port);
3359
3542
  }
3360
3543
  close() {
3544
+ this.closed = true;
3361
3545
  this.db.close();
3362
3546
  }
3363
3547
  };
@@ -3423,6 +3607,9 @@ var DeliveryQueue = class DeliveryQueue {
3423
3607
  stalled = false;
3424
3608
  stallTimer = null;
3425
3609
  betweenPolls = false;
3610
+ /** Why the agent is parked between polls: a reply just landed, or its poll
3611
+ * ended on its own and the re-poll is expected. */
3612
+ parked = "replied";
3426
3613
  graceTimer = null;
3427
3614
  lastDetach = null;
3428
3615
  since = Date.now();
@@ -3570,7 +3757,7 @@ var DeliveryQueue = class DeliveryQueue {
3570
3757
  if (this.awaitingReply) return this.stalled ? "stalled" : "delivered";
3571
3758
  if (this.outlining() !== null) return "delivered";
3572
3759
  if (this.waiter) return "polling";
3573
- if (this.betweenPolls) return "replied";
3760
+ if (this.betweenPolls) return this.parked;
3574
3761
  return this.lastDetach ?? "no-agent";
3575
3762
  }
3576
3763
  hasListener() {
@@ -3623,9 +3810,10 @@ var DeliveryQueue = class DeliveryQueue {
3623
3810
  if (this.stallTimer !== null) clearTimeout(this.stallTimer);
3624
3811
  this.stallTimer = null;
3625
3812
  }
3626
- armGrace() {
3813
+ armGrace(why = "replied") {
3627
3814
  this.clearGrace();
3628
3815
  this.betweenPolls = true;
3816
+ this.parked = why;
3629
3817
  const drop = () => {
3630
3818
  this.graceTimer = null;
3631
3819
  if (!this.betweenPolls) return;
@@ -3660,6 +3848,24 @@ var DeliveryQueue = class DeliveryQueue {
3660
3848
  this.graceTimer = null;
3661
3849
  this.betweenPolls = false;
3662
3850
  }
3851
+ /**
3852
+ * A poll ended without `diffo end`: the poll window closed, or the process
3853
+ * died under its harness. Neither means the agent left — the CLI tells the
3854
+ * session to re-run the poll, and it usually does within moments — so the
3855
+ * reviewer keeps an agent instead of being asked to invite one again. The
3856
+ * agent parks in the same grace a reply uses: a named session is watched
3857
+ * until its process is gone (or the cap), an unnamed one gets the fixed
3858
+ * window. Only a session that is already dead reads as gone at once. When
3859
+ * the grace runs out without a re-poll, the reason it fell to waiting is
3860
+ * still the dead poll.
3861
+ */
3862
+ pollDropped() {
3863
+ this.lastDetach = "disconnected";
3864
+ const pid = this.owner;
3865
+ const isAlive = this.liveness.isAlive ?? pidAlive;
3866
+ if (!(pid !== null && !isAlive(pid)) && !this.awaitingReply && this.outlining() === null) this.armGrace("repolling");
3867
+ this.notify();
3868
+ }
3663
3869
  enqueueThreads(threadIds) {
3664
3870
  const bucket = this.bucket();
3665
3871
  for (const id of threadIds) bucket.threads.add(id);
@@ -3747,8 +3953,7 @@ var DeliveryQueue = class DeliveryQueue {
3747
3953
  onAbort?.(() => {
3748
3954
  if (this.waiter === waiter) {
3749
3955
  this.releaseWaiter(null);
3750
- this.lastDetach = "disconnected";
3751
- this.notify();
3956
+ this.pollDropped();
3752
3957
  }
3753
3958
  });
3754
3959
  });
@@ -5993,6 +6198,286 @@ var ChangesetStore = class {
5993
6198
  }
5994
6199
  };
5995
6200
  //#endregion
6201
+ //#region src/server/telemetry.ts
6202
+ /**
6203
+ * Anonymous usage data, Homebrew-style: on by default, announced in the review
6204
+ * page before the first event leaves, one command or variable to turn off. Two
6205
+ * events per review, `review_opened` and `review_finished`, and one last
6206
+ * `telemetry_disabled` when a machine that has reported turns it off; `send`
6207
+ * below is the authoritative list of what they carry, and the docs page links here. Never
6208
+ * paths, repo or branch names, code, comment text, or anything IP-derived. The
6209
+ * one identifier is a random UUID per machine, in a file of its own
6210
+ * (`~/.diffo/telemetry-id`) rather than the database: `DIFFO_DB` moves the
6211
+ * database, and one machine must count as one machine wherever it keeps state.
6212
+ */
6213
+ const TELEMETRY_DOCS_URL = "https://diffohq.github.io/diffo/telemetry";
6214
+ /** PostHog Cloud, EU region. */
6215
+ const TELEMETRY_ENDPOINT = "https://eu.i.posthog.com/capture/";
6216
+ /** Public and write-only by design: adds events, reads nothing. */
6217
+ const TELEMETRY_KEY = "phc_nZqoJQbyccraPZwxsHaaAUPjf5z8k2QqdnWYedyhQzCx";
6218
+ /** A blocked host must never be felt. */
6219
+ const SEND_TIMEOUT_MS = 2e3;
6220
+ function defaultMachineIdPath() {
6221
+ return join(homedir(), ".diffo", "telemetry-id");
6222
+ }
6223
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6224
+ /** One UUID and a newline, owner-readable like the database next to it. */
6225
+ function fileMachineIdStore(path = defaultMachineIdPath()) {
6226
+ const read = () => {
6227
+ try {
6228
+ const id = readFileSync(path, "utf8").trim();
6229
+ return UUID.test(id) ? id : null;
6230
+ } catch {
6231
+ return null;
6232
+ }
6233
+ };
6234
+ const write = (id, flag) => {
6235
+ try {
6236
+ mkdirSync(dirname(path), {
6237
+ recursive: true,
6238
+ mode: 448
6239
+ });
6240
+ writeFileSync(path, `${id}\n`, {
6241
+ flag,
6242
+ mode: 384
6243
+ });
6244
+ return id;
6245
+ } catch (err) {
6246
+ if (flag === "wx" && err.code === "EEXIST") return read() ?? write(id, "w");
6247
+ return null;
6248
+ }
6249
+ };
6250
+ return {
6251
+ read,
6252
+ claim: (id) => write(id, "wx"),
6253
+ clear() {
6254
+ try {
6255
+ rmSync(path, { force: true });
6256
+ } catch {}
6257
+ }
6258
+ };
6259
+ }
6260
+ const KEY = {
6261
+ enabled: "telemetry.enabled",
6262
+ notice: "telemetry.notice",
6263
+ noticeAt: "telemetry.notice_at"
6264
+ };
6265
+ const DEBUG_VAR = "DIFFO_TELEMETRY_DEBUG";
6266
+ /** `0`, `false` and the empty string mean unset, as most tools read them. */
6267
+ function isSet(value) {
6268
+ if (value === void 0) return false;
6269
+ const v = value.trim().toLowerCase();
6270
+ return v !== "" && v !== "0" && v !== "false";
6271
+ }
6272
+ /** The variable that turns usage data off, in precedence order, or null. */
6273
+ function envOptOut(env) {
6274
+ if (isSet(env.DO_NOT_TRACK)) return "DO_NOT_TRACK";
6275
+ if (isSet(env.DIFFO_TELEMETRY_DISABLED)) return "DIFFO_TELEMETRY_DISABLED";
6276
+ if (isSet(env.CI)) return "CI";
6277
+ return null;
6278
+ }
6279
+ function reviewKindOf(spec, pr) {
6280
+ if (pr) return "pr";
6281
+ return spec.kind === "branch" ? "branch" : "tree";
6282
+ }
6283
+ const AGENT_NAMES = /* @__PURE__ */ new Set([...HARNESS_NAMES, "unknown"]);
6284
+ /** Whatever the CLI reported, narrowed to a name this build knows. */
6285
+ function agentNameOf(raw) {
6286
+ return typeof raw === "string" && AGENT_NAMES.has(raw) ? raw : "other";
6287
+ }
6288
+ /** Coarse on purpose: low cardinality, and nobody's afternoon timed. */
6289
+ function durationBucket(ms) {
6290
+ const min = ms / 6e4;
6291
+ if (min < 5) return "<5m";
6292
+ if (min < 15) return "5-15m";
6293
+ if (min < 30) return "15-30m";
6294
+ if (min < 60) return "30-60m";
6295
+ return "1h+";
6296
+ }
6297
+ var Telemetry = class {
6298
+ store;
6299
+ ids;
6300
+ env;
6301
+ fetchImpl;
6302
+ now;
6303
+ log;
6304
+ dev;
6305
+ base;
6306
+ /** Decided at open and kept for the finish, so the review that first shows the
6307
+ * notice reports nothing at all. Undefined until the first open. */
6308
+ armedReview;
6309
+ openedAt;
6310
+ inFlight = [];
6311
+ /** The id this process settled on when the disk refused to keep one. */
6312
+ fallbackId;
6313
+ constructor(deps) {
6314
+ this.store = deps.store;
6315
+ this.ids = deps.ids;
6316
+ this.env = deps.env ?? process.env;
6317
+ this.fetchImpl = deps.fetch ?? globalThis.fetch;
6318
+ this.now = deps.now ?? Date.now;
6319
+ this.log = deps.log ?? (() => {});
6320
+ this.dev = deps.dev ?? false;
6321
+ this.base = {
6322
+ version: deps.version ?? VERSION,
6323
+ platform: deps.platform ?? process.platform,
6324
+ arch: deps.arch ?? process.arch,
6325
+ node: deps.nodeMajor ?? Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10),
6326
+ dev: this.dev
6327
+ };
6328
+ }
6329
+ status() {
6330
+ const shared = {
6331
+ notice: this.notice(),
6332
+ machineId: this.readMachineId(),
6333
+ dev: this.dev,
6334
+ docs: TELEMETRY_DOCS_URL
6335
+ };
6336
+ const variable = envOptOut(this.env);
6337
+ if (variable !== null) return {
6338
+ enabled: false,
6339
+ source: "env",
6340
+ variable,
6341
+ ...shared
6342
+ };
6343
+ if (this.store.get(KEY.enabled) === "false") return {
6344
+ enabled: false,
6345
+ source: "setting",
6346
+ ...shared
6347
+ };
6348
+ return {
6349
+ enabled: true,
6350
+ source: "default",
6351
+ ...shared
6352
+ };
6353
+ }
6354
+ /**
6355
+ * Either way the machine id goes: back on means a new history, not the old one.
6356
+ * Turning off a machine that has reported sends one last `telemetry_disabled`,
6357
+ * so opt-outs can be counted; a machine that never reported, or one an
6358
+ * environment variable already silenced, sends nothing. Returns whether it did.
6359
+ */
6360
+ setEnabled(on, source) {
6361
+ const farewell = !on && this.status().enabled && this.readMachineId() !== null;
6362
+ if (farewell) this.send("telemetry_disabled", { source });
6363
+ this.store.set(KEY.enabled, on ? "true" : "false");
6364
+ this.ids.clear();
6365
+ this.fallbackId = void 0;
6366
+ if (on) this.store.set(KEY.notice, "acknowledged");
6367
+ return farewell;
6368
+ }
6369
+ /** The review page rendered the notice; nothing is sent before this. */
6370
+ noticeShown() {
6371
+ if (this.notice() !== "pending") return;
6372
+ this.store.set(KEY.notice, "shown");
6373
+ this.store.set(KEY.noticeAt, new Date(this.now()).toISOString());
6374
+ }
6375
+ /** The reviewer dismissed the notice; it stays gone. */
6376
+ acknowledge() {
6377
+ if (this.notice() === "pending") this.noticeShown();
6378
+ this.store.set(KEY.notice, "acknowledged");
6379
+ }
6380
+ /** A `diffo` open landed. Decides whether this review reports, and says so. */
6381
+ reviewOpened(props) {
6382
+ const armed = this.armed();
6383
+ this.armedReview = armed;
6384
+ this.openedAt = this.now();
6385
+ if (armed) this.send("review_opened", {
6386
+ kind: props.kind,
6387
+ agent: props.agent
6388
+ });
6389
+ return armed;
6390
+ }
6391
+ /** Only for a review that was opened reporting, and is still on. */
6392
+ reviewFinished(props) {
6393
+ if (this.armedReview !== true || !this.armed()) return;
6394
+ this.send("review_finished", {
6395
+ kind: props.kind,
6396
+ threads: props.threads,
6397
+ comments: props.comments,
6398
+ layers: props.layers,
6399
+ ...props.event ? { event: props.event } : {},
6400
+ ...this.openedAt !== void 0 ? { duration: durationBucket(this.now() - this.openedAt) } : {}
6401
+ });
6402
+ }
6403
+ /** Every send so far, settled. Tests, and an orderly shutdown. */
6404
+ async flush() {
6405
+ await Promise.all(this.inFlight.splice(0));
6406
+ }
6407
+ notice() {
6408
+ const raw = this.store.get(KEY.notice);
6409
+ return raw === "shown" || raw === "acknowledged" ? raw : "pending";
6410
+ }
6411
+ /** On, and the notice has been on a screen at least once. */
6412
+ armed() {
6413
+ return this.status().enabled && this.notice() !== "pending";
6414
+ }
6415
+ readMachineId() {
6416
+ return this.ids.read() ?? this.fallbackId ?? null;
6417
+ }
6418
+ /** The id on disk, minted on the first send; a disk that refuses it gets one
6419
+ * id per process rather than one per event. */
6420
+ machineId() {
6421
+ const existing = this.readMachineId();
6422
+ if (existing) return existing;
6423
+ const id = randomUUID();
6424
+ const claimed = this.ids.claim(id);
6425
+ if (claimed === null) this.fallbackId = id;
6426
+ return claimed ?? id;
6427
+ }
6428
+ send(event, properties) {
6429
+ const payload = {
6430
+ api_key: TELEMETRY_KEY,
6431
+ event,
6432
+ distinct_id: this.machineId(),
6433
+ timestamp: new Date(this.now()).toISOString(),
6434
+ properties: {
6435
+ $lib: "diffo",
6436
+ $lib_version: this.base.version,
6437
+ $process_person_profile: false,
6438
+ $geoip_disable: true,
6439
+ ...this.base,
6440
+ ...properties
6441
+ }
6442
+ };
6443
+ if (isSet(this.env[DEBUG_VAR])) this.log(`telemetry: ${JSON.stringify(payload)}`);
6444
+ let request;
6445
+ try {
6446
+ request = this.fetchImpl(TELEMETRY_ENDPOINT, {
6447
+ method: "POST",
6448
+ headers: { "Content-Type": "application/json" },
6449
+ body: JSON.stringify(payload),
6450
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS)
6451
+ });
6452
+ } catch {
6453
+ request = Promise.resolve();
6454
+ }
6455
+ const debug = isSet(this.env[DEBUG_VAR]);
6456
+ this.inFlight.push(request.then((res) => {
6457
+ if (debug) this.log(`telemetry: ${event} → ${res?.status ?? "sent"}`);
6458
+ }, (err) => {
6459
+ if (debug) this.log(`telemetry: ${event} failed: ${err?.message ?? String(err)}`);
6460
+ }));
6461
+ }
6462
+ };
6463
+ /** `diffo telemetry [status|on|off]`, for a human's terminal. */
6464
+ function describeTelemetry(status, asked, farewell = false) {
6465
+ const lines = [];
6466
+ if (status.enabled) {
6467
+ lines.push(asked === "on" ? "usage data: on (turned on)" : "usage data: on", " what: two anonymous events per review (opened, finished) — version, OS,", " kind of review, agent, counts. Never code, paths, or comment text", " off: diffo telemetry off, or DIFFO_TELEMETRY_DISABLED=1 (DO_NOT_TRACK=1 counts too)");
6468
+ if (status.notice === "pending") lines.push(" note: nothing has been sent yet; the review page shows a notice first");
6469
+ if (status.dev) lines.push(" dev: running from a source checkout; every event carries dev: true");
6470
+ } else {
6471
+ const why = status.source === "env" ? `${status.variable} is set` : asked === "off" ? "turned off" : "turned off with `diffo telemetry off`";
6472
+ lines.push(`usage data: off (${why})`);
6473
+ if (asked === "off" && farewell) lines.push(" sent: one last event saying this machine turned it off; nothing follows");
6474
+ if (asked === "on" && status.source === "env") lines.push(` note: it stays off while ${status.variable} is set`);
6475
+ if (status.source === "setting") lines.push(" on: diffo telemetry on");
6476
+ }
6477
+ lines.push(` more: ${status.docs}`);
6478
+ return lines.join("\n");
6479
+ }
6480
+ //#endregion
5996
6481
  //#region src/server/watcher.ts
5997
6482
  const DEBOUNCE_MS = 300;
5998
6483
  const MAX_COOLDOWN_MS = 5e3;
@@ -6115,6 +6600,9 @@ const POLL_HEARTBEAT_MS = 15e3;
6115
6600
  * aborts, and a listening poll blocks the daemon's idle reap.
6116
6601
  */
6117
6602
  const POLL_MAX_MS = 18e5;
6603
+ /** How long after listening the daemon starts the machine-wide database sweep:
6604
+ * past the first page load, which is what the sweep must never delay. */
6605
+ const MAINTENANCE_DELAY_MS = 5e3;
6118
6606
  function createApp(ctx, store, review, queue) {
6119
6607
  const app = new Hono();
6120
6608
  if (review && queue) queue.onBatchClosed((closed) => {
@@ -6185,6 +6673,31 @@ function createApp(ctx, store, review, queue) {
6185
6673
  ctx.uiSettings.set("theme", theme);
6186
6674
  return c.json({ ok: true });
6187
6675
  });
6676
+ const reviewKind = () => reviewKindOf(ctx.spec, ctx.pr !== void 0);
6677
+ app.get("/api/telemetry", (c) => {
6678
+ if (!ctx.telemetry) return c.json({ error: "telemetry unavailable" }, 503);
6679
+ return c.json(ctx.telemetry.status());
6680
+ });
6681
+ app.put("/api/telemetry", async (c) => {
6682
+ if (!ctx.telemetry) return c.json({ error: "telemetry unavailable" }, 503);
6683
+ const body = await c.req.json().catch(() => null);
6684
+ if (typeof body?.enabled === "boolean") ctx.telemetry.setEnabled(body.enabled, "ui");
6685
+ else if (body?.notice === "shown") ctx.telemetry.noticeShown();
6686
+ else if (body?.notice === "acknowledged") ctx.telemetry.acknowledge();
6687
+ else return c.json({ error: "need {enabled: boolean} or {notice: \"shown\" | \"acknowledged\"}" }, 400);
6688
+ return c.json(ctx.telemetry.status());
6689
+ });
6690
+ app.post("/api/telemetry/opened", async (c) => {
6691
+ const body = await c.req.json().catch(() => null);
6692
+ const armed = ctx.telemetry?.reviewOpened({
6693
+ kind: reviewKind(),
6694
+ agent: agentNameOf(body?.agent)
6695
+ }) ?? false;
6696
+ return c.json({
6697
+ ok: true,
6698
+ armed
6699
+ });
6700
+ });
6188
6701
  const repoInfo = () => store?.get().repo ?? {
6189
6702
  path: resolve(ctx.root),
6190
6703
  name: "",
@@ -6543,6 +7056,13 @@ function createApp(ctx, store, review, queue) {
6543
7056
  queue.enqueueFinish(coverage);
6544
7057
  }
6545
7058
  review.clearWithheld(batch.map((t) => t.id));
7059
+ ctx.telemetry?.reviewFinished({
7060
+ kind: reviewKind(),
7061
+ threads: batch.length,
7062
+ comments: batch.reduce((n, t) => n + t.messages.filter((m) => m.author === "reviewer").length, 0),
7063
+ layers: (review.get().layers?.items.length ?? 0) > 0,
7064
+ ...event !== null ? { event } : {}
7065
+ });
6546
7066
  return c.json({
6547
7067
  threads: review.get().threads,
6548
7068
  prompt,
@@ -6865,11 +7385,22 @@ function startServer(options) {
6865
7385
  throw new RepoAlreadyServedError(holder);
6866
7386
  }
6867
7387
  db.setPreferredPort(resolve(options.root), options.port);
7388
+ const log = (message) => console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { hour12: false })}] ${message}`);
7389
+ const retired = db.pruneRepo(resolve(options.root));
7390
+ if (retired > 0) log(`retired ${retired} ${retired === 1 ? "review" : "reviews"} for branches this repo no longer has`);
6868
7391
  const review = new ReviewStore(options.root, db, options.spec);
6869
7392
  const queue = new DeliveryQueue();
6870
7393
  queue.rescope(store.get().repo.branch);
6871
7394
  rehydrateQueue(review, queue);
6872
- const log = (message) => console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { hour12: false })}] ${message}`);
7395
+ const telemetry = options.telemetry ?? new Telemetry({
7396
+ store: {
7397
+ get: (key) => db.getUiSetting(key),
7398
+ set: (key, value) => db.setUiSetting(key, value)
7399
+ },
7400
+ ids: fileMachineIdStore(),
7401
+ log,
7402
+ dev: SRC_STAMP !== null || IS_DEV
7403
+ });
6873
7404
  queue.subscribe((presence) => log(`agent ${presence}`));
6874
7405
  const unsubscribeBatch = queue.onBatchClosed((closed) => {
6875
7406
  const took = Math.round((closed.closedAt - closed.deliveredAt) / 1e3);
@@ -6931,6 +7462,7 @@ function startServer(options) {
6931
7462
  ...options,
6932
7463
  ...prContext ? { pr: prContext } : {},
6933
7464
  idle,
7465
+ telemetry,
6934
7466
  onShutdownRequest: options.onShutdownRequest ?? (() => process.exit(0)),
6935
7467
  uiSettings: {
6936
7468
  get: (key) => db.getUiSetting(key),
@@ -6942,6 +7474,12 @@ function startServer(options) {
6942
7474
  port: options.port,
6943
7475
  hostname: "127.0.0.1"
6944
7476
  });
7477
+ const maintenance = setTimeout(() => {
7478
+ db.maintain().then((report) => {
7479
+ if (report.reviews > 0) log(`retired ${report.reviews} ${report.reviews === 1 ? "review" : "reviews"} whose repo or branch is gone`);
7480
+ }).catch((err) => log(`database sweep failed: ${err.message}`));
7481
+ }, MAINTENANCE_DELAY_MS);
7482
+ maintenance.unref();
6945
7483
  const deregister = () => {
6946
7484
  try {
6947
7485
  db.removeServer(review.repoPath, options.port);
@@ -6963,7 +7501,9 @@ function startServer(options) {
6963
7501
  store,
6964
7502
  review,
6965
7503
  queue,
7504
+ telemetry,
6966
7505
  stopWatching: async () => {
7506
+ clearTimeout(maintenance);
6967
7507
  idle?.stop();
6968
7508
  puller?.stop();
6969
7509
  unsubscribeReconcile();
@@ -7569,6 +8109,7 @@ if (command.kind === "setup") {
7569
8109
  if (command.kind === "clean") {
7570
8110
  const db = new DiffoDb();
7571
8111
  try {
8112
+ await db.maintain();
7572
8113
  const entries = sweepWorktrees(db, void 0, {
7573
8114
  force: command.force,
7574
8115
  all: command.all
@@ -7686,6 +8227,21 @@ function printBaseHint(spec, files) {
7686
8227
  if (!hint) return;
7687
8228
  console.log(`this branch has ${hint.commits} ${hint.commits === 1 ? "commit" : "commits"} since ${hint.base}. Review them with \`diffo --base ${hint.base}\``);
7688
8229
  }
8230
+ /**
8231
+ * Tells the server a review was opened, and by which agent — the server decides
8232
+ * whether that is reported (see server/telemetry.ts). Best effort: an open never
8233
+ * waits on, or fails over, usage data.
8234
+ */
8235
+ async function announceOpen(port) {
8236
+ try {
8237
+ await fetch(apiUrl(port, "/api/telemetry/opened"), {
8238
+ method: "POST",
8239
+ headers: { "Content-Type": "application/json" },
8240
+ body: JSON.stringify({ agent: detectHarness() ?? "unknown" }),
8241
+ signal: AbortSignal.timeout(2e3)
8242
+ });
8243
+ } catch {}
8244
+ }
7689
8245
  async function fetchChangesetInfo(port) {
7690
8246
  try {
7691
8247
  return await (await fetch(apiUrl(port, "/api/changeset"), { signal: AbortSignal.timeout(2e3) })).json();
@@ -7829,6 +8385,26 @@ if (command.kind === "stop") {
7829
8385
  }
7830
8386
  process.exit(0);
7831
8387
  }
8388
+ if (command.kind === "telemetry") {
8389
+ const db = new DiffoDb();
8390
+ try {
8391
+ const telemetry = new Telemetry({
8392
+ store: {
8393
+ get: (key) => db.getUiSetting(key),
8394
+ set: (key, value) => db.setUiSetting(key, value)
8395
+ },
8396
+ ids: fileMachineIdStore(),
8397
+ log: (line) => process.stderr.write(`diffo: ${line}\n`),
8398
+ dev: SRC_STAMP !== null || process.env.ENV === "development"
8399
+ });
8400
+ const farewell = command.action === "status" ? false : telemetry.setEnabled(command.action === "on", "cli");
8401
+ console.log(describeTelemetry(telemetry.status(), command.action, farewell));
8402
+ await telemetry.flush();
8403
+ } finally {
8404
+ db.close();
8405
+ }
8406
+ process.exit(0);
8407
+ }
7832
8408
  if (command.kind === "poll") {
7833
8409
  const port = await requireServer();
7834
8410
  process.stderr.write("diffo: waiting for the reviewer. Keep this process attended: a tracked\nbackground task or the foreground, never detached. If it dies, just\nre-run it; feedback is held in the review and survives.\n");
@@ -8047,6 +8623,7 @@ if (existing !== null && takeOverPort === null) {
8047
8623
  const url = reviewUrl(existing.port);
8048
8624
  console.log(prOpen ? "diffo is already watching this pull request" : "diffo is already watching this repo");
8049
8625
  await warnSpecMismatch(existing.port, spec);
8626
+ await announceOpen(existing.port);
8050
8627
  console.log(`→ ${url}`);
8051
8628
  await printAgentNextStep(existing.port, prOpen?.pr);
8052
8629
  process.exit(0);
@@ -8066,6 +8643,7 @@ if (existing !== null && takeOverPort !== null) {
8066
8643
  }
8067
8644
  if (!command.foreground) {
8068
8645
  const daemonPort = await requireServer(command.port, spec.kind === "branch" ? spec.base : void 0, SRC_STAMP, prOpen ? command.target : void 0);
8646
+ await announceOpen(daemonPort);
8069
8647
  const url = reviewUrl(daemonPort);
8070
8648
  await printChangesetSummary(daemonPort);
8071
8649
  console.log(`→ ${url}`);
@@ -8107,6 +8685,10 @@ try {
8107
8685
  }
8108
8686
  throw err;
8109
8687
  }
8688
+ started.telemetry.reviewOpened({
8689
+ kind: reviewKindOf(spec, prOpen !== null),
8690
+ agent: detectHarness() ?? "unknown"
8691
+ });
8110
8692
  const { stats, repo } = started.store.get();
8111
8693
  const url = reviewUrl(port);
8112
8694
  const label = prOpen ? `pull request #${prOpen.pr.number} "${prOpen.pr.title}"` : specLabel(spec);