@diffohq/diffo 0.17.1 → 0.18.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 (173) hide show
  1. package/dist/cli.mjs +598 -381
  2. package/dist/client/assets/{abnfDiagram-O67JEVCF-C4y4PNNf.js → abnfDiagram-WEYOIA3C-B6Y8XL3T.js} +1 -1
  3. package/dist/client/assets/{arc-CFoc9vPw.js → arc-BMKLX6de.js} +1 -1
  4. package/dist/client/assets/architecture-WOLXFQ4H-GCPjgG_6.js +1 -0
  5. package/dist/client/assets/architectureDiagram-ZHVYKT53-D0haekvL.js +36 -0
  6. package/dist/client/assets/{blockDiagram-BEXU5L5S-CpA03aJs.js → blockDiagram-RJAE7QA3-9ozRFq3h.js} +3 -3
  7. package/dist/client/assets/c-Cz4nF6n_.js +1 -0
  8. package/dist/client/assets/{c4Diagram-YGBWAQC7-CYMsQutS.js → c4Diagram-XDPPPE54-3atKbk2-.js} +2 -2
  9. package/dist/client/assets/channel-CY0_tHjG.js +1 -0
  10. package/dist/client/assets/{chunk-GTNCS2PH-B4TC-ScJ.js → chunk-2E7WHVMD-B-hVLdxW.js} +3 -3
  11. package/dist/client/assets/{chunk-NETBCI7D-B5tMFrlX.js → chunk-4EMNKR2T-9R_Ywqe-.js} +1 -1
  12. package/dist/client/assets/{chunk-GWA4HPMP-CQHzk-qZ.js → chunk-55XCU76K-CRfSz7WS.js} +1 -1
  13. package/dist/client/assets/chunk-7S6XSTFO-jZvVFXd4.js +2 -0
  14. package/dist/client/assets/{chunk-MBY4JIJT-CH5sGI-E.js → chunk-AQDTSKZB-DM1KPnfu.js} +8 -8
  15. package/dist/client/assets/chunk-DSNLSHJZ-D-0UEL_k.js +127 -0
  16. package/dist/client/assets/chunk-HAGFOTED-C_UwN40v.js +62 -0
  17. package/dist/client/assets/chunk-HNE4Q36P-Ng5JYsLi.js +1 -0
  18. package/dist/client/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  19. package/dist/client/assets/chunk-NQOKRVIF-BGCe7Zmm.js +213 -0
  20. package/dist/client/assets/{chunk-7PRAP22T-jooxFnA3.js → chunk-UHW7PFBP-Hz5urMmR.js} +1 -1
  21. package/dist/client/assets/{chunk-WEXAMYUT-Dnrs1Ld4.js → chunk-VQ5W62QI-CvIK79s8.js} +1 -1
  22. package/dist/client/assets/chunk-WQQL4CDK-DP0cdd_-.js +10 -0
  23. package/dist/client/assets/{chunk-XXDRQBXY-BDPDpedV.js → chunk-XXDRQBXY-dz3bjW-Z.js} +1 -1
  24. package/dist/client/assets/{chunk-742MDFTN-BTP6JNdv.js → chunk-YZO67HFG-BGaQ4Np3.js} +1 -1
  25. package/dist/client/assets/{chunk-5DYCD2WN-BmQXtfyC.js → chunk-ZAUX2LTX-CaHpxHrZ.js} +1 -1
  26. package/dist/client/assets/chunk-ZCHMQSOR-BQAh24-J.js +2 -0
  27. package/dist/client/assets/{classDiagram-v2-NBCMYWYE-DS3TfzLh.js → classDiagram-v2-4MYWR3UA-C1uGZilp.js} +3 -3
  28. package/dist/client/assets/{core-C38j58LO.js → core-DmmPKspM.js} +1 -1
  29. package/dist/client/assets/cose-bilkent-JH36ORCC-CA0uoIGo.js +1 -0
  30. package/dist/client/assets/cpp-DzjeI0uH.js +1 -0
  31. package/dist/client/assets/css-DgaLhXQH.js +1 -0
  32. package/dist/client/assets/cynefin-EF2NZ3EQ-D7-HiRmU.js +1 -0
  33. package/dist/client/assets/cynefinDiagram-WF24UDRP-Ct_qs0cP.js +62 -0
  34. package/dist/client/assets/cytoscape.esm-BegBNzOV.js +321 -0
  35. package/dist/client/assets/dagre-NK4GIZVX-Cffqg6NJ.js +4 -0
  36. package/dist/client/assets/{diagram-3UASUU5V-DSX_Tpmv.js → diagram-CBK3MWTC-ByBNedZy.js} +11 -11
  37. package/dist/client/assets/{diagram-MLGK6HIB-BFgscT9R.js → diagram-I5XIIV2D-ByMRPZMW.js} +1 -1
  38. package/dist/client/assets/{diagram-CDSNMT55-BiLgPVAJ.js → diagram-ODIL2UCW-C0OayomU.js} +4 -4
  39. package/dist/client/assets/diagram-PFBEJUXE-BezcNJg0.js +3 -0
  40. package/dist/client/assets/diagram-RJA3I4VV-Bl3cl2pD.js +41 -0
  41. package/dist/client/assets/diagram-TYUNFSNR-CyGZb5mO.js +200 -0
  42. package/dist/client/assets/{dist-CMYkujym.js → dist-Uc27UV0T.js} +1 -1
  43. package/dist/client/assets/{dist-B77Lw20A.js → dist-YWSBp2hZ.js} +16 -16
  44. package/dist/client/assets/{ebnfDiagram-ZINNZB2B-x4kmvZzD.js → ebnfDiagram-V4P3TTPT-11k4sRCB.js} +1 -1
  45. package/dist/client/assets/elk-QONTBCTQ-DM4AV9FF.js +27 -0
  46. package/dist/client/assets/{engine-oniguruma-Ds-VbuSq.js → engine-oniguruma-MxRvz1no.js} +1 -1
  47. package/dist/client/assets/{erDiagram-OPXOYQCR-Bo1yx5ec.js → erDiagram-KYQIGQAA-D38TGssZ.js} +2 -2
  48. package/dist/client/assets/eventmodeling-K75KTNOO-DF0H3qdx.js +1 -0
  49. package/dist/client/assets/flowDiagram-IUGLLWBU-rSziK2Bd.js +1 -0
  50. package/dist/client/assets/ganttDiagram-R7GPNTJY-DXtKy5K6.js +292 -0
  51. package/dist/client/assets/gitGraph-VSP46ZUC-BJmPpwux.js +1 -0
  52. package/dist/client/assets/gitGraphDiagram-PDYL7XHY-FwrK2brd.js +106 -0
  53. package/dist/client/assets/gn-CAXYAgV-.js +1 -0
  54. package/dist/client/assets/html-BWDvXlHg.js +1 -0
  55. package/dist/client/assets/index-Caehfsn0.css +1 -0
  56. package/dist/client/assets/index-_VrQDc2L.js +91 -0
  57. package/dist/client/assets/info-OHQRW6UA-W1kRy4TV.js +1 -0
  58. package/dist/client/assets/{infoDiagram-VRGFBTTK-CautEhHX.js → infoDiagram-MVDRKAX5-giO2ZEe8.js} +1 -1
  59. package/dist/client/assets/{ishikawaDiagram-OU5B5YK6-C8BUCcyV.js → ishikawaDiagram-HPEHV2GE-DTOlB5LG.js} +3 -3
  60. package/dist/client/assets/java-_ATy9zCa.js +1 -0
  61. package/dist/client/assets/javascript-CpIGwZ_Y.js +1 -0
  62. package/dist/client/assets/{journeyDiagram-ZHPQQLJL-BA5WG8Xf.js → journeyDiagram-BOSHW7O7-DqENuBSc.js} +3 -3
  63. package/dist/client/assets/json-C3WlssX3.js +1 -0
  64. package/dist/client/assets/jsx-C4yDKi0A.js +1 -0
  65. package/dist/client/assets/{kanban-definition-PNTS6WVX-DRXzEJBa.js → kanban-definition-FNSA4HC5-CIYIECg_.js} +9 -9
  66. package/dist/client/assets/katex-B_JcIwqj.js +257 -0
  67. package/dist/client/assets/{line-DAcfMF01.js → line-D_x4AjTF.js} +1 -1
  68. package/dist/client/assets/{linear-uYi6Pah9.js → linear-CS4Fq7f5.js} +1 -1
  69. package/dist/client/assets/mermaid-parser.core-720dQRcD.js +7 -0
  70. package/dist/client/assets/mermaid.core-DNKhW2d1.js +44 -0
  71. package/dist/client/assets/{mindmap-definition-NLK3R4M7-Bn4VqIyk.js → mindmap-definition-6OY57UG3-Tyz7I7bd.js} +30 -28
  72. package/dist/client/assets/objective-c-D1A_Heim.js +1 -0
  73. package/dist/client/assets/objective-cpp-BsSzOQcm.js +1 -0
  74. package/dist/client/assets/packet-JDAUHWVQ-CIT4RofU.js +1 -0
  75. package/dist/client/assets/{pegDiagram-GJSIUBJH-BD1vaxu_.js → pegDiagram-ECIUJNBX-BDv64Oqu.js} +1 -1
  76. package/dist/client/assets/pie-XZMESJXO-B7TeMgE-.js +1 -0
  77. package/dist/client/assets/pieDiagram-GNMPY74T-DZ5ISynf.js +39 -0
  78. package/dist/client/assets/proto-DB4EqR-F.js +1 -0
  79. package/dist/client/assets/{quadrantDiagram-O4NWA36T-Co7yTeQ3.js → quadrantDiagram-D57K4MC3-q3ZFhuD4.js} +3 -3
  80. package/dist/client/assets/radar-ABXABTNO-DfOTCpQ-.js +1 -0
  81. package/dist/client/assets/railroad-I3PHUGI6-DtLPMTjt.js +1 -0
  82. package/dist/client/assets/railroad-abnf-LNEFI6M7-DPObEtfh.js +1 -0
  83. package/dist/client/assets/railroad-ebnf-DWYD2UWJ-g6Y97jri.js +1 -0
  84. package/dist/client/assets/railroad-peg-JR7BVNR7-CfUBWVcL.js +1 -0
  85. package/dist/client/assets/{railroadDiagram-XR7U4H2S-B2dydE7a.js → railroadDiagram-Y72NRBL7-CKGjfL5n.js} +1 -1
  86. package/dist/client/assets/{requirementDiagram-PLB6GJNP-orHF7QGe.js → requirementDiagram-EQYSW5RA-kfSYKuPQ.js} +4 -4
  87. package/dist/client/assets/sankeyDiagram-SY24JHO5-Dn5ou0D7.js +40 -0
  88. package/dist/client/assets/sequenceDiagram-BLEHNHQU-B6Qcw9U_.js +169 -0
  89. package/dist/client/assets/shellscript-CVTB3cr6.js +1 -0
  90. package/dist/client/assets/sql-CfM2IJm3.js +1 -0
  91. package/dist/client/assets/src-Bz4qgR7m.js +1 -0
  92. package/dist/client/assets/{stateDiagram-v2-GCMORJYK-CIs80eMe.js → stateDiagram-v2-A6IESKWZ-B56x9sAq.js} +2 -2
  93. package/dist/client/assets/swimlanes-B3ENRKFQ-BCQJqf0x.js +1 -0
  94. package/dist/client/assets/swimlanesDiagram-AYQRV4AK-C1YHgM4T.js +8 -0
  95. package/dist/client/assets/{timeline-definition-EJHVYXUP-Bbz9sh3O.js → timeline-definition-JXYKGLGB-lEItkXBD.js} +4 -4
  96. package/dist/client/assets/treeView-D4JQ5SDB-CYZkbtcm.js +1 -0
  97. package/dist/client/assets/treemap-SAJKECNS-B96tHxAb.js +1 -0
  98. package/dist/client/assets/tsx-CGosfAUg.js +1 -0
  99. package/dist/client/assets/typescript-Bb1J8qyy.js +1 -0
  100. package/dist/client/assets/usecaseDiagram-WPOLBNNX-CvTDEhna.js +328 -0
  101. package/dist/client/assets/{vennDiagram-UO4OBE2U-DvMMdO42.js → vennDiagram-Q3Y3IDCW-C2Ix7bQn.js} +3 -3
  102. package/dist/client/assets/wardley-7MLQ67FV-EAIVeoto.js +1 -0
  103. package/dist/client/assets/{wardleyDiagram-VNRHLVJA-CX0grRSr.js → wardleyDiagram-IVAIDKWA--Okbz3eY.js} +3 -3
  104. package/dist/client/assets/xml-DuKfbPI6.js +1 -0
  105. package/dist/client/assets/xychartDiagram-4URNHDU4-CpPxhDeV.js +7 -0
  106. package/dist/client/assets/yaml-DDA_XMNu.js +1 -0
  107. package/dist/client/index.html +2 -2
  108. package/package.json +1 -1
  109. package/plugin.json +1 -1
  110. package/skills/diffo/SKILL.md +7 -4
  111. package/dist/client/assets/architecture-7GRP2DOG-D0AGQ72s.js +0 -1
  112. package/dist/client/assets/architectureDiagram-NJMV4G6O-Dr2fN1vr.js +0 -36
  113. package/dist/client/assets/c-DWGmR78Y.js +0 -1
  114. package/dist/client/assets/channel-BktdIn5n.js +0 -1
  115. package/dist/client/assets/chunk-3FUC2YCW-BjQ1itX5.js +0 -2
  116. package/dist/client/assets/chunk-7INBJB4K-BsVmPyov.js +0 -62
  117. package/dist/client/assets/chunk-7M6MHVWA-CsHA_fwz.js +0 -213
  118. package/dist/client/assets/chunk-FOHPRMQF-DzxwRta7.js +0 -161
  119. package/dist/client/assets/chunk-O7XYJQB3-LPw4VP6e.js +0 -126
  120. package/dist/client/assets/chunk-UA2S7LBM-DVU8-vtb.js +0 -1
  121. package/dist/client/assets/chunk-Z7XXMR3K-C5ewXtqS.js +0 -10
  122. package/dist/client/assets/chunk-ZIGJFQKS-C3kX-TWN.js +0 -2
  123. package/dist/client/assets/cose-bilkent-JH36ORCC-DbTnvWg3.js +0 -1
  124. package/dist/client/assets/cpp-CJIxL8VH.js +0 -1
  125. package/dist/client/assets/css-DUjzNfgv.js +0 -1
  126. package/dist/client/assets/cynefin-OW5HDTMX-BNjAi5zC.js +0 -1
  127. package/dist/client/assets/cynefinDiagram-VND7K2PF-DO7VFekp.js +0 -62
  128. package/dist/client/assets/cytoscape.esm-Yq6u8L66.js +0 -321
  129. package/dist/client/assets/dagre-6A5THRUB-_yf_g5Eu.js +0 -4
  130. package/dist/client/assets/diagram-22UHCM2B-C_0zfVyF.js +0 -200
  131. package/dist/client/assets/diagram-ATOU4E4O-BjdH8AkF.js +0 -3
  132. package/dist/client/assets/diagram-MPIPVDR6-BuoIMiDa.js +0 -41
  133. package/dist/client/assets/elk-276RUBZZ-YAyM0RBx.js +0 -27
  134. package/dist/client/assets/eventmodeling-NTZA5JFV-G8nAQ-r0.js +0 -1
  135. package/dist/client/assets/flowDiagram-KWPJA3E3-JYut8MuN.js +0 -1
  136. package/dist/client/assets/ganttDiagram-FUAMR5RP-CIi9ZaVr.js +0 -292
  137. package/dist/client/assets/gitGraph-4MIJSDKK-DPr2GO72.js +0 -1
  138. package/dist/client/assets/gitGraphDiagram-X574FWY7-8akAig1Y.js +0 -106
  139. package/dist/client/assets/html-BlpXnbzK.js +0 -1
  140. package/dist/client/assets/index-ClMx9yVg.css +0 -1
  141. package/dist/client/assets/index-IIP4HzAT.js +0 -93
  142. package/dist/client/assets/info-A6RAGUB7-Gtb-Ecti.js +0 -1
  143. package/dist/client/assets/java-DxlWrXwe.js +0 -1
  144. package/dist/client/assets/javascript-Yelw-5ZN.js +0 -1
  145. package/dist/client/assets/json-CaljABEy.js +0 -1
  146. package/dist/client/assets/jsx-hEbJ82HF.js +0 -1
  147. package/dist/client/assets/katex-ZlcWpGUi.js +0 -257
  148. package/dist/client/assets/mermaid-parser.core-CUupN43F.js +0 -7
  149. package/dist/client/assets/mermaid.core-P7Zn0OkI.js +0 -44
  150. package/dist/client/assets/packet-AYTQ26CC-CCcEthJT.js +0 -1
  151. package/dist/client/assets/pie-WAS4IAKB-DJrFAJNb.js +0 -1
  152. package/dist/client/assets/pieDiagram-5QR66LMP-BHamJTzC.js +0 -39
  153. package/dist/client/assets/radar-RG4KPBEZ-C2VAxB0O.js +0 -1
  154. package/dist/client/assets/railroad-74A4TZTK-dcSfLPuR.js +0 -1
  155. package/dist/client/assets/railroad-abnf-HS5TGJTU-CZqf5C4_.js +0 -1
  156. package/dist/client/assets/railroad-ebnf-LZEXJU2U-BkzQPC_g.js +0 -1
  157. package/dist/client/assets/railroad-peg-WCYAUIDC-CnjsL3Wl.js +0 -1
  158. package/dist/client/assets/sankeyDiagram-IPEJSGJF-C_OsMuGS.js +0 -40
  159. package/dist/client/assets/sequenceDiagram-PO4LG4MO-Dh6-7-gO.js +0 -169
  160. package/dist/client/assets/shellscript-BiGZg9Om.js +0 -1
  161. package/dist/client/assets/sql-BOsskAmu.js +0 -1
  162. package/dist/client/assets/src-CoUI9Pvh.js +0 -1
  163. package/dist/client/assets/swimlanes-2SLR337P-Dwo3Yt9h.js +0 -1
  164. package/dist/client/assets/swimlanesDiagram-TC7HE7FX-CINzeMsu.js +0 -8
  165. package/dist/client/assets/treeView-Q6P3EWNA-HHdzOu-n.js +0 -1
  166. package/dist/client/assets/treemap-WGGIJYW6-Bc3AMsxh.js +0 -1
  167. package/dist/client/assets/tsx-5f3ulCja.js +0 -1
  168. package/dist/client/assets/typescript-Utq2Cl8c.js +0 -1
  169. package/dist/client/assets/usecaseDiagram-POWQR4AR-Dx1EDbri.js +0 -328
  170. package/dist/client/assets/wardley-WFR3VGLG-BqMvXOfu.js +0 -1
  171. package/dist/client/assets/xml-CoDIzWF0.js +0 -1
  172. package/dist/client/assets/xychartDiagram-PMCCYNJV-BQQA3oHd.js +0 -7
  173. package/dist/client/assets/yaml-CxbubiML.js +0 -1
package/dist/cli.mjs CHANGED
@@ -124,6 +124,59 @@ function detectHarness() {
124
124
  }
125
125
  }
126
126
  //#endregion
127
+ //#region src/shared/anchors.ts
128
+ function findHunk$1(files, id) {
129
+ for (const file of files) {
130
+ const hunk = file.hunks.find((h) => h.id === id);
131
+ if (hunk) return hunk;
132
+ }
133
+ }
134
+ /** Index of the line numbered `line` on `side`, or -1: a deleted line is not
135
+ * on the new side, an added one not on the old. */
136
+ function indexOfSideLine(lines, side, line) {
137
+ return lines.findIndex((l) => side === "old" ? l.kind !== "add" && l.oldNo === line : l.kind !== "del" && l.newNo === line);
138
+ }
139
+ /**
140
+ * The same hunk, numbered as another diff numbers it. A hunk id is
141
+ * content-addressed — the path and the changed lines, not where they sit — so
142
+ * the hunk a commit introduced and the one the full diff still shows are one
143
+ * id with two sets of line numbers whenever a later commit grew the file
144
+ * above it. A thread anchored in one numbering is renamed into the other by
145
+ * position in the hunk: line k of the hunk here is line k of the hunk there.
146
+ *
147
+ * Unchanged when either diff lacks the hunk, or when the two copies differ in
148
+ * shape (a context line more or less): then the numbers cannot be trusted and
149
+ * the anchor is left as it was, for placement to re-home by line as usual.
150
+ */
151
+ function translateHunkAnchor(anchor, from, to) {
152
+ const src = findHunk$1(from, anchor.hunkId);
153
+ const dst = findHunk$1(to, anchor.hunkId);
154
+ if (!src || !dst || src === dst) return anchor;
155
+ if (src.lines.length !== dst.lines.length) return anchor;
156
+ if (src.lines.some((l, i) => l.kind !== dst.lines[i].kind)) return anchor;
157
+ const rename = (n) => {
158
+ const i = indexOfSideLine(src.lines, anchor.side, n);
159
+ if (i === -1) return null;
160
+ const l = dst.lines[i];
161
+ return anchor.side === "old" ? l.oldNo : l.newNo;
162
+ };
163
+ const line = rename(anchor.line);
164
+ if (line === null) return anchor;
165
+ if (anchor.endLine === void 0) return line === anchor.line ? anchor : {
166
+ ...anchor,
167
+ line
168
+ };
169
+ const endLine = rename(anchor.endLine);
170
+ return endLine === null ? {
171
+ ...anchor,
172
+ line
173
+ } : {
174
+ ...anchor,
175
+ line,
176
+ endLine
177
+ };
178
+ }
179
+ //#endregion
127
180
  //#region src/shared/review.ts
128
181
  const THREAD_INTENTS = ["fix", "question"];
129
182
  /** A review comment on GitHub tops out at 65 536 characters; a suggestion that
@@ -260,11 +313,7 @@ function describeAnchor(anchor) {
260
313
  /** A comment can anchor to a long range; the frozen text keeps its head. */
261
314
  const ANCHORED_TEXT_CAP = 10;
262
315
  function findHunk(changeset, hunkId) {
263
- for (const file of changeset.files) {
264
- const hunk = file.hunks.find((h) => h.id === hunkId);
265
- if (hunk) return hunk;
266
- }
267
- return null;
316
+ return findHunk$1(changeset.files, hunkId) ?? null;
268
317
  }
269
318
  /**
270
319
  * Freeze what a new thread anchors to: the whole hunk as a diff snapshot, plus
@@ -362,6 +411,17 @@ function buildConnectAsk(isDev, repoPath, home) {
362
411
  */
363
412
  const POLL_STANCE = "a tracked background task when its completion wakes you on its own, so the reviewer can keep talking to you in chat meanwhile; if it would not, hold it in the foreground and do not end your turn while it runs — never a detached process";
364
413
  /**
414
+ * When the first poll starts: in the same response as the open, before the
415
+ * guide and before any chat work. The poll is the one step with no later
416
+ * surface — every other command's ack says "then poll", but an agent that
417
+ * shares the URL and turns to chat work never runs another diffo command, and
418
+ * the review sits with no listener until the user notices. Stated once here
419
+ * and interpolated into the open's next step, the skill and `help agent`.
420
+ */
421
+ const POLL_NOW = "now — in this same response, right after sharing the URL, before the guide and before anything the user asked for in chat: the review has no listener until it runs, and a turn that ends with it open and no attended poll leaves the reviewer talking to nobody";
422
+ /** The one exception to POLL_NOW, for harnesses whose poll holds the turn. */
423
+ const POLL_NOW_FOREGROUND = "only when that means a foreground poll does the guide go before it";
424
+ /**
365
425
  * The tab-title doctrine — the few words an agent hands its first poll, which
366
426
  * become the browser tab's name. Stated once and interpolated into every
367
427
  * surface that teaches it (the skill, `help agent`, the open-time next step),
@@ -642,7 +702,7 @@ function prFrame(pr) {
642
702
  */
643
703
  function guideNudge(hasGuide, D = AUTHOR_DOCTRINE) {
644
704
  if (hasGuide) return null;
645
- return `share the URL above with the user right now, as a message line, before anything else. Then, while they open it, orient them if this changeset needs it (${D.guide.when}): post a guide — one comment on the whole changeset: \`${CLI_COMMANDS.guide}\` (no file, so it anchors to the changeset; it appears live at the top of their review). It is ${D.guide.what}. Not in it: ${GUIDE.order}. How much: ${GUIDE.budget}. ${D.guide.stance}. \`diffo help guide\` has the diagram legend and one example.`;
705
+ return `share the URL above with the user right now, as a message line, before anything else. Then, with the poll running while they open it, orient them if this changeset needs it (${D.guide.when}): post a guide — one comment on the whole changeset: \`${CLI_COMMANDS.guide}\` (no file, so it anchors to the changeset; it appears live at the top of their review). It is ${D.guide.what}. Not in it: ${GUIDE.order}. How much: ${GUIDE.budget}. ${D.guide.stance}. \`diffo help guide\` has the diagram legend and one example.`;
646
706
  }
647
707
  /**
648
708
  * Printed by `diffo` (open) next to the guide nudge while the review has neither
@@ -1366,74 +1426,6 @@ function parseLayersInput(raw) {
1366
1426
  items
1367
1427
  };
1368
1428
  }
1369
- //#endregion
1370
- //#region src/cliArgs.ts
1371
- const HELP_TEXT = `diffo: review a changeset the way you'd read a book
1372
-
1373
- Usage: diffo [options] open (or resume) the review for this repo
1374
- diffo <target> [options] review against a branch, or review a pull request
1375
- diffo <command> [...] manage the server, or talk to the review
1376
- diffo help [command] show help for a command
1377
-
1378
- The target:
1379
- <branch> everything since forking from that branch (same as --base)
1380
- <PR URL> a GitHub pull request (also owner/repo#N, #N, or N)
1381
- (the short forms resolve against this repo's origin)
1382
-
1383
- For the reviewer:
1384
- pr <target> Review a pull request (the same as \`diffo <target>\`,
1385
- and the explicit answer for a pull request of your own;
1386
- help pr explains the flow)
1387
- status Show this repo's review server, if one is running
1388
- (--json for a machine-readable answer)
1389
- stop Stop this repo's review server
1390
- clean Remove the worktrees diffo made for pull requests whose
1391
- review is over (--force takes dirty ones, and ones a
1392
- running server still serves; --all takes every one)
1393
- setup Register diffo with the coding agents on this machine
1394
- (Claude Code, Cursor, VS Code, Copilot, and the shared
1395
- ~/.agents skills dir read by Codex, Gemini, Amp, Goose, …)
1396
- telemetry Show whether anonymous usage data is on, or turn it
1397
- off / on (telemetry off · telemetry on)
1398
-
1399
- For the agent (the AI that wrote the change):
1400
- poll Wait for the reviewer's feedback (blocking long-poll;
1401
- prints one JSON payload; safe to re-run any time)
1402
- (--title "<what the change is>" on the first poll names
1403
- the reviewer's browser tab)
1404
- reply <threadId> Post a reply to a review thread
1405
- (--message "<text>", or pipe the text on stdin)
1406
- comment [<file>] Start a comment thread as the agent: on a line (--line),
1407
- a file, or (with no file) the whole changeset; the
1408
- reviewer replies to take it up, or resolves it
1409
- layers Outline the changeset as steps to read in order
1410
- (--suggest ["why"] flags it at open; --json '<Layer[]>'
1411
- or --stdin posts the list, replacing the last one)
1412
- end Detach from the review politely
1413
- help agent The agent's whole protocol on one page
1414
- help guide The guide comment — the map of the change — with an example
1415
-
1416
- Options (for opening the review):
1417
- --base <branch> Review everything since forking from <branch>
1418
- (default: working tree vs HEAD)
1419
- -p, --port <port> Port to serve on (default: the port this repo last used,
1420
- else the first free one)
1421
- --no-open Don't open the browser automatically
1422
- --foreground Run the server in this terminal instead of leaving a
1423
- background one behind (Ctrl-C stops it)
1424
- -v, --version Show version
1425
- -h, --help Show this help
1426
-
1427
- Examples:
1428
- diffo open the review for this repo in the browser
1429
- diffo main review everything since forking from main
1430
- diffo https://github.com/o/r/pull/482
1431
- review pull request #482 with your agent as copilot
1432
- diffo status is a server running here, and where?
1433
- diffo reply t-3 -m "fixed" answer a review thread (agent side)
1434
-
1435
- Feedback lives in the review, not in any process: diffo, poll, status, stop,
1436
- and setup are all safe to re-run after an interruption.`;
1437
1429
  const VERB_HELP = {
1438
1430
  agent: `diffo help agent: the agent's whole protocol on one page
1439
1431
 
@@ -1452,7 +1444,15 @@ The loop:
1452
1444
  ending every message with it while you stay attached.
1453
1445
  Attached without ever seeing the URL (a takeover, a fresh session)?
1454
1446
  \`diffo status\` prints it.
1455
- 2. Guide: post one only when the changeset needs orientation:
1447
+ 2. Listen: run \`${CLI_COMMANDS.firstPoll}\` ${POLL_NOW}. Hold it as
1448
+ ${POLL_STANCE}; ${POLL_NOW_FOREGROUND}. It blocks until the
1449
+ reviewer acts, then prints one JSON payload naming the threads to act on.
1450
+ The title is ${TAB_TITLE.what}: ${TAB_TITLE.why}. Write it the way it is
1451
+ read: ${TAB_TITLE.shape} (${TAB_TITLE.examples}). Send it ${TAB_TITLE.when};
1452
+ every other poll is a plain \`diffo poll\`.
1453
+ Killed or timed out? Re-run it; feedback is held in the review, not the
1454
+ poll.
1455
+ 3. Guide: post one only when the changeset needs orientation:
1456
1456
  ${GUIDE.when}.
1457
1457
  Right after sharing the URL, while the reviewer opens the page, post ONE
1458
1458
  comment on the whole changeset (\`diffo comment -m "…"\`, no file). It is
@@ -1463,7 +1463,7 @@ The loop:
1463
1463
  Legend: ${GUIDE.legend}; \`diffo help guide\` has them, and one example.
1464
1464
  It lands live at the top of their review; never hold the URL back for it.
1465
1465
  If the changeset later shifts under the guide, ${GUIDE.update}.
1466
- 3. Layers: offer them when the changeset reads better in order:
1466
+ 4. Layers: offer them when the changeset reads better in order:
1467
1467
  ${LAYERS.suggest}.
1468
1468
  Flag it at open with \`diffo layers --suggest "<why, in one line>"\` and
1469
1469
  say so in your handoff ("say layers and I'll outline it"). Post the
@@ -1473,14 +1473,6 @@ The loop:
1473
1473
  Summary: ${LAYERS.summary}. Diagram: ${LAYERS.diagram}.
1474
1474
  Order: ${LAYERS.order}. ${LAYERS.mechanical}. ${LAYERS.stance}.
1475
1475
  ${LAYERS.replace}. \`diffo help layers\` has the shape.
1476
- 4. Listen: run \`${CLI_COMMANDS.firstPoll}\`; it blocks until the reviewer
1477
- acts, then prints one JSON payload naming the threads to act on. Run it
1478
- attended: ${POLL_STANCE}.
1479
- The title is ${TAB_TITLE.what}: ${TAB_TITLE.why}. Write it the way it is
1480
- read: ${TAB_TITLE.shape} (${TAB_TITLE.examples}). Send it ${TAB_TITLE.when};
1481
- every other poll is a plain \`diffo poll\`.
1482
- Killed or timed out? Re-run it; feedback is held in the review, not the
1483
- poll.
1484
1476
  5. Act: \`[issue]\` threads want a code change; \`[question]\` threads want an
1485
1477
  answer in the reply and no edit. Your edits reach the reviewer live.
1486
1478
  6. Reply: \`diffo reply <threadId> --message "<text>"\` (pipe long replies on
@@ -1501,8 +1493,11 @@ The loop:
1501
1493
  9. Detach: run \`diffo end\` when the review is over or the user moves on.
1502
1494
 
1503
1495
  Rules:
1504
- - Change only what the threads ask about; the reviewer is mid-read, and an
1505
- unrelated edit moves the diff under them.
1496
+ - An open review with no attended poll is a broken state: never end a turn
1497
+ in it. Ending every message with the URL does not make up for it.
1498
+ - Make no edits nobody asked for; the reviewer is mid-read, and an unrelated
1499
+ edit moves the diff under them. Edits the user asks for in chat are theirs
1500
+ to ask for: do them, attached — attaching never waits for chat work.
1506
1501
  - One attached agent at a time: the newest poll carries the review; don't
1507
1502
  re-poll to take it back from another session; tell the user instead. When
1508
1503
  a poll says it took the review over, or returns status "superseded", say so
@@ -1782,7 +1777,7 @@ Example:
1782
1777
  diffo telemetry off`
1783
1778
  };
1784
1779
  function helpFor(topic) {
1785
- return topic && VERB_HELP[topic] || HELP_TEXT;
1780
+ return topic && VERB_HELP[topic] || "diffo: review a changeset the way you'd read a book\n\nUsage: diffo [options] open (or resume) the review for this repo\n diffo <target> [options] review against a branch, or review a pull request\n diffo <command> [...] manage the server, or talk to the review\n diffo help [command] show help for a command\n\nThe target:\n <branch> everything since forking from that branch (same as --base)\n <PR URL> a GitHub pull request (also owner/repo#N, #N, or N)\n (the short forms resolve against this repo's origin)\n\nFor the reviewer:\n pr <target> Review a pull request (the same as `diffo <target>`,\n and the explicit answer for a pull request of your own;\n help pr explains the flow)\n status Show this repo's review server, if one is running\n (--json for a machine-readable answer)\n stop Stop this repo's review server\n clean Remove the worktrees diffo made for pull requests whose\n review is over (--force takes dirty ones, and ones a\n running server still serves; --all takes every one)\n setup Register diffo with the coding agents on this machine\n (Claude Code, Cursor, VS Code, Copilot, and the shared\n ~/.agents skills dir read by Codex, Gemini, Amp, Goose, …)\n telemetry Show whether anonymous usage data is on, or turn it\n off / on (telemetry off · telemetry on)\n\nFor the agent (the AI that wrote the change):\n poll Wait for the reviewer's feedback (blocking long-poll;\n prints one JSON payload; safe to re-run any time)\n (--title \"<what the change is>\" on the first poll names\n the reviewer's browser tab)\n reply <threadId> Post a reply to a review thread\n (--message \"<text>\", or pipe the text on stdin)\n comment [<file>] Start a comment thread as the agent: on a line (--line),\n a file, or (with no file) the whole changeset; the\n reviewer replies to take it up, or resolves it\n layers Outline the changeset as steps to read in order\n (--suggest [\"why\"] flags it at open; --json '<Layer[]>'\n or --stdin posts the list, replacing the last one)\n end Detach from the review politely\n help agent The agent's whole protocol on one page\n help guide The guide comment — the map of the change — with an example\n\nOptions (for opening the review):\n --base <branch> Review everything since forking from <branch>\n (default: working tree vs HEAD)\n -p, --port <port> Port to serve on (default: the port this repo last used,\n else the first free one)\n --no-open Don't open the browser automatically\n --foreground Run the server in this terminal instead of leaving a\n background one behind (Ctrl-C stops it)\n -v, --version Show version\n -h, --help Show this help\n\nExamples:\n diffo open the review for this repo in the browser\n diffo main review everything since forking from main\n diffo https://github.com/o/r/pull/482\n review pull request #482 with your agent as copilot\n diffo status is a server running here, and where?\n diffo reply t-3 -m \"fixed\" answer a review thread (agent side)\n\nFeedback lives in the review, not in any process: diffo, poll, status, stop,\nand setup are all safe to re-run after an interruption.";
1786
1781
  }
1787
1782
  const VERBS = /* @__PURE__ */ new Set([
1788
1783
  "poll",
@@ -3376,6 +3371,86 @@ async function getFullDiffAsync(root, spec) {
3376
3371
  const [raw, untracked] = await Promise.all([getRawDiffAsync(root, spec), getUntrackedDiffAsync(root)]);
3377
3372
  return [raw, untracked].filter(Boolean).join("\n");
3378
3373
  }
3374
+ const COMMIT_FIELDS = [
3375
+ "%H",
3376
+ "%h",
3377
+ "%an",
3378
+ "%aI",
3379
+ "%s",
3380
+ "%b"
3381
+ ].join("%x1f");
3382
+ /** `3 files changed, 10 insertions(+), 2 deletions(-)` — any part may be absent. */
3383
+ function parseShortstat(line) {
3384
+ const n = (re) => Number.parseInt(re.exec(line)?.[1] ?? "0", 10);
3385
+ return {
3386
+ files: n(/(\d+) files? changed/),
3387
+ additions: n(/(\d+) insertions?\(\+\)/),
3388
+ deletions: n(/(\d+) deletions?\(-\)/)
3389
+ };
3390
+ }
3391
+ /**
3392
+ * The commits a branch review spans, oldest first. A working-tree review has
3393
+ * none: its changeset is not made of commits. Merges are skipped — what they
3394
+ * bring in is the base branch, which this review is not about.
3395
+ */
3396
+ async function branchStart(root, base) {
3397
+ try {
3398
+ return (await gitAsync(root, [
3399
+ "merge-base",
3400
+ base,
3401
+ "HEAD"
3402
+ ])).trim();
3403
+ } catch {
3404
+ throw new MissingBaseError(base);
3405
+ }
3406
+ }
3407
+ /** Just the shas of `listCommitsAsync`, in the same order: a `rev-list`, no
3408
+ * per-commit stats, so a route that only needs to know whether a sha is in the
3409
+ * changeset does not pay for diffing every commit. */
3410
+ async function listCommitShasAsync(root, spec) {
3411
+ if (spec.kind !== "branch") return [];
3412
+ return (await gitAsync(root, [
3413
+ "rev-list",
3414
+ "--reverse",
3415
+ "--no-merges",
3416
+ `${await branchStart(root, spec.base)}..HEAD`
3417
+ ])).split("\n").filter((sha) => sha !== "");
3418
+ }
3419
+ async function listCommitsAsync(root, spec) {
3420
+ if (spec.kind !== "branch") return [];
3421
+ const mergeBase = await branchStart(root, spec.base);
3422
+ return (await gitAsync(root, [
3423
+ "log",
3424
+ "--reverse",
3425
+ "--no-merges",
3426
+ "--no-color",
3427
+ `--format=%x1e${COMMIT_FIELDS}%x1f`,
3428
+ "--shortstat",
3429
+ `${mergeBase}..HEAD`
3430
+ ])).split("").filter((chunk) => chunk.trim() !== "").map((chunk) => {
3431
+ const [sha = "", short = "", author = "", at = "", subject = "", body = "", stat = ""] = chunk.split("");
3432
+ return {
3433
+ sha,
3434
+ short,
3435
+ author,
3436
+ at,
3437
+ subject,
3438
+ body: body.trim(),
3439
+ stats: parseShortstat(stat)
3440
+ };
3441
+ });
3442
+ }
3443
+ /** One commit's own patch, against its first parent — or against nothing, for
3444
+ * a root commit, which `git show` handles where `sha^` would not resolve. */
3445
+ async function getCommitDiffAsync(root, sha) {
3446
+ return gitAsync(root, [
3447
+ "show",
3448
+ "--format=",
3449
+ "--patch",
3450
+ ...DIFF_FLAGS,
3451
+ sha
3452
+ ]);
3453
+ }
3379
3454
  function isSafeRepoPath(root, relPath) {
3380
3455
  return resolve(root, relPath).startsWith(resolve(root) + sep);
3381
3456
  }
@@ -3966,88 +4041,398 @@ const VERSION = (() => {
3966
4041
  return "0.0.0";
3967
4042
  })();
3968
4043
  //#endregion
3969
- //#region src/server/delivery.ts
3970
- const STALL_AFTER_MS = 3e5;
3971
- const REPLY_GRACE_MS = 9e4;
3972
- /** A re-poll within this window of a delivery reads as "hasn't started yet". */
3973
- const BATCH_SETTLE_MS = 2e3;
3974
- /** Signal 0 probes existence without touching the process; EPERM still proves it exists. */
3975
- function pidAlive(pid) {
3976
- try {
3977
- process.kill(pid, 0);
3978
- return true;
3979
- } catch (err) {
3980
- return err.code === "EPERM";
3981
- }
4044
+ //#region src/shared/types.ts
4045
+ /** The working-tree edits a branch review also carries, listed after the
4046
+ * commits as one more step to read. Not a sha, so it can never collide with one. */
4047
+ const UNCOMMITTED = "uncommitted";
4048
+ //#endregion
4049
+ //#region src/server/parse.ts
4050
+ const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
4051
+ ".png",
4052
+ ".jpg",
4053
+ ".jpeg",
4054
+ ".gif",
4055
+ ".webp",
4056
+ ".bmp",
4057
+ ".svg",
4058
+ ".ico",
4059
+ ".avif"
4060
+ ]);
4061
+ function isImagePath(path) {
4062
+ const dot = path.lastIndexOf(".");
4063
+ return dot !== -1 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
3982
4064
  }
3983
- var DeliveryQueue = class DeliveryQueue {
3984
- stallAfterMs;
3985
- replyGraceMs;
3986
- liveness;
3987
- batchSettleMs;
3988
- buckets = /* @__PURE__ */ new Map();
3989
- scope = "";
3990
- waiter = null;
3991
- awaitingReply = false;
3992
- /** Thread id -> epoch ms it was delivered. */
3993
- deliveredAt = /* @__PURE__ */ new Map();
3994
- listeners = /* @__PURE__ */ new Set();
3995
- stalled = false;
3996
- stallTimer = null;
3997
- betweenPolls = false;
3998
- /** Why the agent is parked between polls: a reply just landed, its poll
3999
- * ended on its own and the re-poll is expected, or it opened the review and
4000
- * is still orienting itself before its first poll. */
4001
- parked = "replied";
4002
- graceTimer = null;
4003
- lastDetach = null;
4004
- since = Date.now();
4005
- lastDetail = {
4006
- state: "waiting",
4007
- reason: "no-agent"
4065
+ /** Content-addressed hunk identity: the hash covers the file path and the changed
4066
+ * lines only — not line numbers, not context. Editing the hunk itself mints a new
4067
+ * id; the occurrence index separates identical hunks within one file. */
4068
+ function hunkId(path, lines, occurrence) {
4069
+ const changed = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind === "add" ? "+" : "-"}${l.text}`).join("\n");
4070
+ return createHash("sha256").update(`${path}\0${changed}\0${occurrence}`).digest("hex").slice(0, 16);
4071
+ }
4072
+ function parseSectionMeta(section) {
4073
+ return {
4074
+ renamedFrom: section.match(/^rename from (.+)$/m)?.[1] ?? null,
4075
+ renamedTo: section.match(/^rename to (.+)$/m)?.[1] ?? null,
4076
+ binary: /^Binary files .* differ$/m.test(section) || /^GIT binary patch$/m.test(section),
4077
+ symlink: /^(?:new file|deleted file|new|old) mode 120000$/m.test(section) || /^index [0-9a-f.]+ 120000$/m.test(section)
4008
4078
  };
4009
- owner = null;
4010
- /** The session pid that last received the full reply protocol. Later
4011
- * deliveries to the same session carry the compact form — the full text is
4012
- * the loop's largest recurring token cost. A takeover (new pid) or an
4013
- * anonymous session (null pid) always gets the full form again. */
4014
- protocolPid = null;
4015
- batch = null;
4016
- batchWatch = null;
4017
- waiterWatch = null;
4018
- batchListeners = /* @__PURE__ */ new Set();
4019
- constructor(stallAfterMs = STALL_AFTER_MS, replyGraceMs = REPLY_GRACE_MS, liveness = {}, batchSettleMs = BATCH_SETTLE_MS) {
4020
- this.stallAfterMs = stallAfterMs;
4021
- this.replyGraceMs = replyGraceMs;
4022
- this.liveness = liveness;
4023
- this.batchSettleMs = batchSettleMs;
4024
- }
4025
- onBatchClosed(listener) {
4026
- this.batchListeners.add(listener);
4027
- return () => this.batchListeners.delete(listener);
4079
+ }
4080
+ function splitSections(patch) {
4081
+ if (!patch.trim()) return [];
4082
+ const sections = [];
4083
+ const lines = patch.split("\n");
4084
+ let current = null;
4085
+ for (const line of lines) if (line.startsWith("diff --git ")) {
4086
+ if (current) sections.push(current.join("\n"));
4087
+ current = [line];
4088
+ } else if (current) current.push(line);
4089
+ if (current) sections.push(current.join("\n"));
4090
+ return sections;
4091
+ }
4092
+ /**
4093
+ * `@@ -oldStart,oldCount +newStart,newCount @@ scope`. Both counts are optional in
4094
+ * the format and mean 1 when absent (`@@ -1 +1 @@`). The trailing scope is git's own
4095
+ * guess at what you are inside.
4096
+ */
4097
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
4098
+ /**
4099
+ * Read one hunk body, starting at the line after its header. The declared counts
4100
+ * are authoritative: consume exactly that many old and new lines and stop, rather
4101
+ * than reading up to the next `@@` — git's own rule, and what keeps the blank line
4102
+ * every patch ends with out of the last hunk.
4103
+ */
4104
+ function parseHunkBody(lines, start, oldStart, newStart, oldCount, newCount) {
4105
+ const out = [];
4106
+ let oldNo = oldStart;
4107
+ let newNo = newStart;
4108
+ let remainingOld = oldCount;
4109
+ let remainingNew = newCount;
4110
+ let i = start;
4111
+ while (remainingOld > 0 || remainingNew > 0) {
4112
+ const line = lines[i];
4113
+ if (line === void 0) break;
4114
+ if (line.startsWith("@@") || line.startsWith("diff --git ")) break;
4115
+ i++;
4116
+ if (line.startsWith("\\")) continue;
4117
+ if (line.startsWith("+")) {
4118
+ out.push({
4119
+ kind: "add",
4120
+ oldNo: null,
4121
+ newNo: newNo++,
4122
+ text: line.slice(1)
4123
+ });
4124
+ remainingNew--;
4125
+ } else if (line.startsWith("-")) {
4126
+ out.push({
4127
+ kind: "del",
4128
+ oldNo: oldNo++,
4129
+ newNo: null,
4130
+ text: line.slice(1)
4131
+ });
4132
+ remainingOld--;
4133
+ } else {
4134
+ out.push({
4135
+ kind: "context",
4136
+ oldNo: oldNo++,
4137
+ newNo: newNo++,
4138
+ text: line.slice(1)
4139
+ });
4140
+ remainingOld--;
4141
+ remainingNew--;
4142
+ }
4028
4143
  }
4029
- closeBatch(reason) {
4030
- const batch = this.batch;
4031
- if (!batch) return;
4032
- const closed = {
4033
- threadIds: batch.threadIds,
4034
- unanswered: [...this.deliveredAt.keys()],
4035
- deliveredAt: batch.deliveredAt,
4036
- closedAt: Date.now(),
4037
- reason
4038
- };
4039
- this.batch = null;
4040
- this.deliveredAt.clear();
4041
- this.awaitingReply = false;
4042
- this.stalled = false;
4043
- this.clearStall();
4044
- this.clearBatchWatch();
4045
- for (const listener of this.batchListeners) listener(closed);
4144
+ return {
4145
+ lines: out,
4146
+ next: i
4147
+ };
4148
+ }
4149
+ function parseHunks(lines) {
4150
+ const hunks = [];
4151
+ let i = 0;
4152
+ while (i < lines.length) {
4153
+ const header = lines[i]?.match(HUNK_HEADER);
4154
+ if (!header) {
4155
+ i++;
4156
+ continue;
4157
+ }
4158
+ const oldStart = Number(header[1]);
4159
+ const newStart = Number(header[3]);
4160
+ const body = parseHunkBody(lines, i + 1, oldStart, newStart, header[2] === void 0 ? 1 : Number(header[2]), header[4] === void 0 ? 1 : Number(header[4]));
4161
+ const context = (header[5] ?? "").trim();
4162
+ hunks.push({
4163
+ oldStart,
4164
+ newStart,
4165
+ context: context === "" ? null : context,
4166
+ lines: body.lines
4167
+ });
4168
+ i = body.next;
4046
4169
  }
4047
- armBatchWatch() {
4048
- this.clearBatchWatch();
4049
- const isAlive = this.liveness.isAlive ?? pidAlive;
4050
- const timer = setInterval(() => {
4170
+ return hunks;
4171
+ }
4172
+ /**
4173
+ * `--- a/x` / `+++ b/x`, read only from the part of the section before the first
4174
+ * `@@`. That bound is load-bearing: inside a hunk body an added line starting with
4175
+ * `++` is spelled `+++ …`, so scanning the whole section would read file content as
4176
+ * a path header.
4177
+ */
4178
+ function parseSectionPaths(section, lines) {
4179
+ let from = null;
4180
+ let to = null;
4181
+ for (const line of lines) {
4182
+ if (line.startsWith("@@")) break;
4183
+ if (from === null && line.startsWith("--- ")) from = diffPath(line.slice(4));
4184
+ else if (to === null && line.startsWith("+++ ")) to = diffPath(line.slice(4));
4185
+ }
4186
+ return {
4187
+ from,
4188
+ to,
4189
+ isNew: from === "/dev/null" || /^new file mode /m.test(section),
4190
+ isDeleted: to === "/dev/null" || /^deleted file mode /m.test(section)
4191
+ };
4192
+ }
4193
+ function diffPath(raw) {
4194
+ const path = raw.split(" ", 1)[0] ?? raw;
4195
+ return path === "/dev/null" ? path : stripAB(path);
4196
+ }
4197
+ function parsePatch(patch) {
4198
+ const files = [];
4199
+ for (const section of splitSections(patch)) {
4200
+ const sectionLines = section.split("\n");
4201
+ const meta = parseSectionMeta(section);
4202
+ const paths = parseSectionPaths(section, sectionLines);
4203
+ let path;
4204
+ let oldPath = null;
4205
+ let status;
4206
+ if (meta.renamedTo) {
4207
+ path = meta.renamedTo;
4208
+ oldPath = meta.renamedFrom;
4209
+ status = "renamed";
4210
+ } else if (paths.isNew) {
4211
+ path = paths.to ?? headerPath(section);
4212
+ status = "added";
4213
+ } else if (paths.isDeleted) {
4214
+ path = paths.from ?? headerPath(section);
4215
+ status = "deleted";
4216
+ } else if (paths.to !== null && paths.to !== "/dev/null") {
4217
+ path = paths.to;
4218
+ status = "modified";
4219
+ } else {
4220
+ path = headerPath(section);
4221
+ status = "modified";
4222
+ }
4223
+ const kind = meta.symlink ? "symlink" : meta.binary ? isImagePath(path) ? "image" : "binary" : isImagePath(path) ? "image" : "text";
4224
+ const seen = /* @__PURE__ */ new Map();
4225
+ const hunks = meta.binary ? [] : parseHunks(sectionLines).map((hunk) => {
4226
+ const lines = hunk.lines;
4227
+ const contentKey = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind}:${l.text}`).join("\n");
4228
+ const occurrence = seen.get(contentKey) ?? 0;
4229
+ seen.set(contentKey, occurrence + 1);
4230
+ return {
4231
+ id: hunkId(path, lines, occurrence),
4232
+ path,
4233
+ oldStart: hunk.oldStart,
4234
+ newStart: hunk.newStart,
4235
+ lines,
4236
+ ...hunk.context === null ? {} : { context: hunk.context }
4237
+ };
4238
+ });
4239
+ files.push({
4240
+ path,
4241
+ oldPath,
4242
+ status,
4243
+ kind,
4244
+ staged: false,
4245
+ hunks
4246
+ });
4247
+ }
4248
+ return files;
4249
+ }
4250
+ function stripAB(p) {
4251
+ return p.startsWith("b/") || p.startsWith("a/") ? p.slice(2) : p;
4252
+ }
4253
+ function headerPath(section) {
4254
+ const header = section.split("\n", 1)[0] ?? "";
4255
+ return header.match(/^diff --git a\/(.*) b\/(.*)$/)?.[2] ?? header.replace(/^diff --git /, "");
4256
+ }
4257
+ //#endregion
4258
+ //#region src/server/commits.ts
4259
+ function stats$2(files) {
4260
+ let additions = 0;
4261
+ let deletions = 0;
4262
+ for (const file of files) for (const hunk of file.hunks) for (const line of hunk.lines) if (line.kind === "add") additions++;
4263
+ else if (line.kind === "del") deletions++;
4264
+ return {
4265
+ files: files.length,
4266
+ additions,
4267
+ deletions
4268
+ };
4269
+ }
4270
+ /** A commit's diff has no working-tree file to size a trailing gap from, so
4271
+ * `newLineCount` stays unknown and that gap is simply not offered. The other
4272
+ * gaps read the file at `rev`, the commit's own version. */
4273
+ const commitFiles = (raw, rev) => parsePatch(raw).map((file) => ({
4274
+ ...file,
4275
+ staged: false,
4276
+ newLineCount: null,
4277
+ ...rev ? { rev } : {}
4278
+ }));
4279
+ /**
4280
+ * The tab fetches every commit's diff at once, and each is a git process: a
4281
+ * few at a time keeps a long branch from spawning one per commit in a burst.
4282
+ */
4283
+ const GIT_SLOTS = 4;
4284
+ let gitBusy = 0;
4285
+ const gitWaiting = [];
4286
+ async function withGitSlot(work) {
4287
+ if (gitBusy >= GIT_SLOTS) await new Promise((resolve) => gitWaiting.push(resolve));
4288
+ gitBusy++;
4289
+ try {
4290
+ return await work();
4291
+ } finally {
4292
+ gitBusy--;
4293
+ gitWaiting.shift()?.();
4294
+ }
4295
+ }
4296
+ /** Whether `sha` is a full sha of one of the changeset's commits: the cheap
4297
+ * listing answers, so a reviewer-typed ref never reaches git as one. */
4298
+ async function isListedCommit(root, spec, sha) {
4299
+ if (!/^[0-9a-f]{40}$/.test(sha)) return false;
4300
+ return withGitSlot(async () => (await listCommitShasAsync(root, spec)).includes(sha));
4301
+ }
4302
+ const uncommittedFiles = async (root) => commitFiles(await getFullDiffAsync(root, { kind: "working-tree" }));
4303
+ /**
4304
+ * The changeset's commits, oldest first. A branch review's diff runs from the
4305
+ * merge-base to the *working tree*, so edits not yet committed are part of what
4306
+ * is under review: they follow the commits as one more entry, when there are any.
4307
+ */
4308
+ async function listCommits(root, spec) {
4309
+ const raw = await listCommitsAsync(root, spec);
4310
+ if (raw.length === 0) return { commits: [] };
4311
+ const commits = raw.map((c) => ({
4312
+ sha: c.sha,
4313
+ short: c.short,
4314
+ subject: c.subject,
4315
+ body: c.body,
4316
+ author: c.author,
4317
+ at: c.at,
4318
+ stats: c.stats
4319
+ }));
4320
+ const pending = await uncommittedFiles(root);
4321
+ if (pending.length > 0) commits.push({
4322
+ sha: UNCOMMITTED,
4323
+ short: "",
4324
+ subject: "Uncommitted changes",
4325
+ body: "",
4326
+ author: null,
4327
+ at: null,
4328
+ stats: stats$2(pending)
4329
+ });
4330
+ return { commits };
4331
+ }
4332
+ /**
4333
+ * One listed commit's own diff. The sha must be one `listCommits` returns: the
4334
+ * route never hands git a ref the reviewer typed, only a full sha it already
4335
+ * listed — checked against the cheap sha listing, not the one that diffs every
4336
+ * commit for its stats. Null when it is not in the changeset.
4337
+ */
4338
+ async function getCommitChangeset(root, spec, sha) {
4339
+ if (sha === "uncommitted") {
4340
+ if (spec.kind !== "branch") return null;
4341
+ return {
4342
+ sha,
4343
+ files: await uncommittedFiles(root)
4344
+ };
4345
+ }
4346
+ if (!await isListedCommit(root, spec, sha)) return null;
4347
+ const raw = await withGitSlot(() => getCommitDiffAsync(root, sha));
4348
+ return {
4349
+ sha,
4350
+ files: commitFiles(raw, sha)
4351
+ };
4352
+ }
4353
+ //#endregion
4354
+ //#region src/server/delivery.ts
4355
+ const STALL_AFTER_MS = 3e5;
4356
+ const REPLY_GRACE_MS = 9e4;
4357
+ /** A re-poll within this window of a delivery reads as "hasn't started yet". */
4358
+ const BATCH_SETTLE_MS = 2e3;
4359
+ /** Signal 0 probes existence without touching the process; EPERM still proves it exists. */
4360
+ function pidAlive(pid) {
4361
+ try {
4362
+ process.kill(pid, 0);
4363
+ return true;
4364
+ } catch (err) {
4365
+ return err.code === "EPERM";
4366
+ }
4367
+ }
4368
+ var DeliveryQueue = class DeliveryQueue {
4369
+ stallAfterMs;
4370
+ replyGraceMs;
4371
+ liveness;
4372
+ batchSettleMs;
4373
+ buckets = /* @__PURE__ */ new Map();
4374
+ scope = "";
4375
+ waiter = null;
4376
+ awaitingReply = false;
4377
+ /** Thread id -> epoch ms it was delivered. */
4378
+ deliveredAt = /* @__PURE__ */ new Map();
4379
+ listeners = /* @__PURE__ */ new Set();
4380
+ stalled = false;
4381
+ stallTimer = null;
4382
+ betweenPolls = false;
4383
+ /** Why the agent is parked between polls: a reply just landed, its poll
4384
+ * ended on its own and the re-poll is expected, or it opened the review and
4385
+ * is still orienting itself before its first poll. */
4386
+ parked = "replied";
4387
+ graceTimer = null;
4388
+ lastDetach = null;
4389
+ since = Date.now();
4390
+ lastDetail = {
4391
+ state: "waiting",
4392
+ reason: "no-agent"
4393
+ };
4394
+ owner = null;
4395
+ /** The session pid that last received the full reply protocol. Later
4396
+ * deliveries to the same session carry the compact form — the full text is
4397
+ * the loop's largest recurring token cost. A takeover (new pid) or an
4398
+ * anonymous session (null pid) always gets the full form again. */
4399
+ protocolPid = null;
4400
+ batch = null;
4401
+ batchWatch = null;
4402
+ waiterWatch = null;
4403
+ batchListeners = /* @__PURE__ */ new Set();
4404
+ constructor(stallAfterMs = STALL_AFTER_MS, replyGraceMs = REPLY_GRACE_MS, liveness = {}, batchSettleMs = BATCH_SETTLE_MS) {
4405
+ this.stallAfterMs = stallAfterMs;
4406
+ this.replyGraceMs = replyGraceMs;
4407
+ this.liveness = liveness;
4408
+ this.batchSettleMs = batchSettleMs;
4409
+ }
4410
+ onBatchClosed(listener) {
4411
+ this.batchListeners.add(listener);
4412
+ return () => this.batchListeners.delete(listener);
4413
+ }
4414
+ closeBatch(reason) {
4415
+ const batch = this.batch;
4416
+ if (!batch) return;
4417
+ const closed = {
4418
+ threadIds: batch.threadIds,
4419
+ unanswered: [...this.deliveredAt.keys()],
4420
+ deliveredAt: batch.deliveredAt,
4421
+ closedAt: Date.now(),
4422
+ reason
4423
+ };
4424
+ this.batch = null;
4425
+ this.deliveredAt.clear();
4426
+ this.awaitingReply = false;
4427
+ this.stalled = false;
4428
+ this.clearStall();
4429
+ this.clearBatchWatch();
4430
+ for (const listener of this.batchListeners) listener(closed);
4431
+ }
4432
+ armBatchWatch() {
4433
+ this.clearBatchWatch();
4434
+ const isAlive = this.liveness.isAlive ?? pidAlive;
4435
+ const timer = setInterval(() => {
4051
4436
  if (!this.batch) return this.clearBatchWatch();
4052
4437
  const pid = this.owner;
4053
4438
  if (pid === null || isAlive(pid)) return;
@@ -4808,6 +5193,14 @@ function githubPosition(anchor, body, files) {
4808
5193
  downgraded: false
4809
5194
  };
4810
5195
  const file = files.find((f) => f.path === anchor.path);
5196
+ if (anchor.commit !== void 0 && !file?.hunks.some((h) => h.id === anchor.hunkId)) return {
5197
+ draft: {
5198
+ path: anchor.path,
5199
+ body,
5200
+ subjectType: "FILE"
5201
+ },
5202
+ downgraded: true
5203
+ };
4811
5204
  const has = (n) => file?.hunks.some((h) => h.lines.some((l) => anchor.side === "old" ? l.kind !== "add" && l.oldNo === n : l.kind !== "del" && l.newNo === n)) ?? false;
4812
5205
  const end = anchor.endLine ?? anchor.line;
4813
5206
  if (!has(end) || !has(anchor.line)) return {
@@ -6239,13 +6632,15 @@ function parseAnchor(value) {
6239
6632
  if (anchor.side !== "old" && anchor.side !== "new") return null;
6240
6633
  if (typeof anchor.line !== "number") return null;
6241
6634
  const endLine = typeof anchor.endLine === "number" && Number.isInteger(anchor.endLine) && anchor.endLine > anchor.line ? anchor.endLine : void 0;
6635
+ const commit = typeof anchor.commit === "string" && /^[0-9a-f]{40}$/.test(anchor.commit) ? anchor.commit : void 0;
6242
6636
  return {
6243
6637
  kind: "hunk",
6244
6638
  hunkId: anchor.hunkId,
6245
6639
  path: anchor.path,
6246
6640
  side: anchor.side,
6247
6641
  line: anchor.line,
6248
- ...endLine !== void 0 ? { endLine } : {}
6642
+ ...endLine !== void 0 ? { endLine } : {},
6643
+ ...commit !== void 0 ? { commit } : {}
6249
6644
  };
6250
6645
  }
6251
6646
  if (anchor.kind === "file") return typeof anchor.path === "string" ? {
@@ -6446,215 +6841,6 @@ function normalizePr(value) {
6446
6841
  };
6447
6842
  }
6448
6843
  //#endregion
6449
- //#region src/server/parse.ts
6450
- const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
6451
- ".png",
6452
- ".jpg",
6453
- ".jpeg",
6454
- ".gif",
6455
- ".webp",
6456
- ".bmp",
6457
- ".svg",
6458
- ".ico",
6459
- ".avif"
6460
- ]);
6461
- function isImagePath(path) {
6462
- const dot = path.lastIndexOf(".");
6463
- return dot !== -1 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
6464
- }
6465
- /** Content-addressed hunk identity: the hash covers the file path and the changed
6466
- * lines only — not line numbers, not context. Editing the hunk itself mints a new
6467
- * id; the occurrence index separates identical hunks within one file. */
6468
- function hunkId(path, lines, occurrence) {
6469
- const changed = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind === "add" ? "+" : "-"}${l.text}`).join("\n");
6470
- return createHash("sha256").update(`${path}\0${changed}\0${occurrence}`).digest("hex").slice(0, 16);
6471
- }
6472
- function parseSectionMeta(section) {
6473
- return {
6474
- renamedFrom: section.match(/^rename from (.+)$/m)?.[1] ?? null,
6475
- renamedTo: section.match(/^rename to (.+)$/m)?.[1] ?? null,
6476
- binary: /^Binary files .* differ$/m.test(section) || /^GIT binary patch$/m.test(section),
6477
- symlink: /^(?:new file|deleted file|new|old) mode 120000$/m.test(section) || /^index [0-9a-f.]+ 120000$/m.test(section)
6478
- };
6479
- }
6480
- function splitSections(patch) {
6481
- if (!patch.trim()) return [];
6482
- const sections = [];
6483
- const lines = patch.split("\n");
6484
- let current = null;
6485
- for (const line of lines) if (line.startsWith("diff --git ")) {
6486
- if (current) sections.push(current.join("\n"));
6487
- current = [line];
6488
- } else if (current) current.push(line);
6489
- if (current) sections.push(current.join("\n"));
6490
- return sections;
6491
- }
6492
- /**
6493
- * `@@ -oldStart,oldCount +newStart,newCount @@ scope`. Both counts are optional in
6494
- * the format and mean 1 when absent (`@@ -1 +1 @@`). The trailing scope is git's own
6495
- * guess at what you are inside.
6496
- */
6497
- const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
6498
- /**
6499
- * Read one hunk body, starting at the line after its header. The declared counts
6500
- * are authoritative: consume exactly that many old and new lines and stop, rather
6501
- * than reading up to the next `@@` — git's own rule, and what keeps the blank line
6502
- * every patch ends with out of the last hunk.
6503
- */
6504
- function parseHunkBody(lines, start, oldStart, newStart, oldCount, newCount) {
6505
- const out = [];
6506
- let oldNo = oldStart;
6507
- let newNo = newStart;
6508
- let remainingOld = oldCount;
6509
- let remainingNew = newCount;
6510
- let i = start;
6511
- while (remainingOld > 0 || remainingNew > 0) {
6512
- const line = lines[i];
6513
- if (line === void 0) break;
6514
- if (line.startsWith("@@") || line.startsWith("diff --git ")) break;
6515
- i++;
6516
- if (line.startsWith("\\")) continue;
6517
- if (line.startsWith("+")) {
6518
- out.push({
6519
- kind: "add",
6520
- oldNo: null,
6521
- newNo: newNo++,
6522
- text: line.slice(1)
6523
- });
6524
- remainingNew--;
6525
- } else if (line.startsWith("-")) {
6526
- out.push({
6527
- kind: "del",
6528
- oldNo: oldNo++,
6529
- newNo: null,
6530
- text: line.slice(1)
6531
- });
6532
- remainingOld--;
6533
- } else {
6534
- out.push({
6535
- kind: "context",
6536
- oldNo: oldNo++,
6537
- newNo: newNo++,
6538
- text: line.slice(1)
6539
- });
6540
- remainingOld--;
6541
- remainingNew--;
6542
- }
6543
- }
6544
- return {
6545
- lines: out,
6546
- next: i
6547
- };
6548
- }
6549
- function parseHunks(lines) {
6550
- const hunks = [];
6551
- let i = 0;
6552
- while (i < lines.length) {
6553
- const header = lines[i]?.match(HUNK_HEADER);
6554
- if (!header) {
6555
- i++;
6556
- continue;
6557
- }
6558
- const oldStart = Number(header[1]);
6559
- const newStart = Number(header[3]);
6560
- const body = parseHunkBody(lines, i + 1, oldStart, newStart, header[2] === void 0 ? 1 : Number(header[2]), header[4] === void 0 ? 1 : Number(header[4]));
6561
- const context = (header[5] ?? "").trim();
6562
- hunks.push({
6563
- oldStart,
6564
- newStart,
6565
- context: context === "" ? null : context,
6566
- lines: body.lines
6567
- });
6568
- i = body.next;
6569
- }
6570
- return hunks;
6571
- }
6572
- /**
6573
- * `--- a/x` / `+++ b/x`, read only from the part of the section before the first
6574
- * `@@`. That bound is load-bearing: inside a hunk body an added line starting with
6575
- * `++` is spelled `+++ …`, so scanning the whole section would read file content as
6576
- * a path header.
6577
- */
6578
- function parseSectionPaths(section, lines) {
6579
- let from = null;
6580
- let to = null;
6581
- for (const line of lines) {
6582
- if (line.startsWith("@@")) break;
6583
- if (from === null && line.startsWith("--- ")) from = diffPath(line.slice(4));
6584
- else if (to === null && line.startsWith("+++ ")) to = diffPath(line.slice(4));
6585
- }
6586
- return {
6587
- from,
6588
- to,
6589
- isNew: from === "/dev/null" || /^new file mode /m.test(section),
6590
- isDeleted: to === "/dev/null" || /^deleted file mode /m.test(section)
6591
- };
6592
- }
6593
- function diffPath(raw) {
6594
- const path = raw.split(" ", 1)[0] ?? raw;
6595
- return path === "/dev/null" ? path : stripAB(path);
6596
- }
6597
- function parsePatch(patch) {
6598
- const files = [];
6599
- for (const section of splitSections(patch)) {
6600
- const sectionLines = section.split("\n");
6601
- const meta = parseSectionMeta(section);
6602
- const paths = parseSectionPaths(section, sectionLines);
6603
- let path;
6604
- let oldPath = null;
6605
- let status;
6606
- if (meta.renamedTo) {
6607
- path = meta.renamedTo;
6608
- oldPath = meta.renamedFrom;
6609
- status = "renamed";
6610
- } else if (paths.isNew) {
6611
- path = paths.to ?? headerPath(section);
6612
- status = "added";
6613
- } else if (paths.isDeleted) {
6614
- path = paths.from ?? headerPath(section);
6615
- status = "deleted";
6616
- } else if (paths.to !== null && paths.to !== "/dev/null") {
6617
- path = paths.to;
6618
- status = "modified";
6619
- } else {
6620
- path = headerPath(section);
6621
- status = "modified";
6622
- }
6623
- const kind = meta.symlink ? "symlink" : meta.binary ? isImagePath(path) ? "image" : "binary" : isImagePath(path) ? "image" : "text";
6624
- const seen = /* @__PURE__ */ new Map();
6625
- const hunks = meta.binary ? [] : parseHunks(sectionLines).map((hunk) => {
6626
- const lines = hunk.lines;
6627
- const contentKey = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind}:${l.text}`).join("\n");
6628
- const occurrence = seen.get(contentKey) ?? 0;
6629
- seen.set(contentKey, occurrence + 1);
6630
- return {
6631
- id: hunkId(path, lines, occurrence),
6632
- path,
6633
- oldStart: hunk.oldStart,
6634
- newStart: hunk.newStart,
6635
- lines,
6636
- ...hunk.context === null ? {} : { context: hunk.context }
6637
- };
6638
- });
6639
- files.push({
6640
- path,
6641
- oldPath,
6642
- status,
6643
- kind,
6644
- staged: false,
6645
- hunks
6646
- });
6647
- }
6648
- return files;
6649
- }
6650
- function stripAB(p) {
6651
- return p.startsWith("b/") || p.startsWith("a/") ? p.slice(2) : p;
6652
- }
6653
- function headerPath(section) {
6654
- const header = section.split("\n", 1)[0] ?? "";
6655
- return header.match(/^diff --git a\/(.*) b\/(.*)$/)?.[2] ?? header.replace(/^diff --git /, "");
6656
- }
6657
- //#endregion
6658
6844
  //#region src/server/changeset.ts
6659
6845
  function stats$1(files) {
6660
6846
  let additions = 0;
@@ -7365,8 +7551,17 @@ function createApp(ctx, store, review, queue) {
7365
7551
  edited: message.prComment.text.trim() !== text
7366
7552
  };
7367
7553
  }
7368
- const capture = store ? captureAnchor(store.get(), anchor, review.get().layers) : null;
7369
- const created = review.createThread(anchor, text, capture, intent, "reviewer", {
7554
+ const commitFiles = store && anchor.kind === "hunk" && typeof body?.commit === "string" ? (await getCommitChangeset(ctx.root, ctx.spec, body.commit).catch(() => null))?.files ?? null : null;
7555
+ const placed = commitFiles && anchor.kind === "hunk" ? findHunk$1(store.get().files, anchor.hunkId) ? translateHunkAnchor(anchor, commitFiles, store.get().files) : {
7556
+ ...anchor,
7557
+ commit: body.commit
7558
+ } : anchor;
7559
+ let capture = store ? captureAnchor(store.get(), placed, review.get().layers) : null;
7560
+ if (store && !capture && commitFiles) capture = captureAnchor({
7561
+ ...store.get(),
7562
+ files: commitFiles
7563
+ }, anchor);
7564
+ const created = review.createThread(placed, text, capture, intent, "reviewer", {
7370
7565
  ...audience ? { audience } : {},
7371
7566
  ...parentId ? { parentId } : {},
7372
7567
  ...origin ? { origin } : {}
@@ -7879,13 +8074,35 @@ function createApp(ctx, store, review, queue) {
7879
8074
  throw err;
7880
8075
  }
7881
8076
  });
7882
- app.get("/api/file", (c) => {
8077
+ app.get("/api/commits", async (c) => {
8078
+ try {
8079
+ return c.json(await listCommits(ctx.root, ctx.spec));
8080
+ } catch (err) {
8081
+ if (err instanceof MissingBaseError) return c.json({ error: err.message }, 400);
8082
+ throw err;
8083
+ }
8084
+ });
8085
+ app.get("/api/commits/:sha", async (c) => {
8086
+ try {
8087
+ const commit = await getCommitChangeset(ctx.root, ctx.spec, c.req.param("sha"));
8088
+ return commit ? c.json(commit) : c.json({ error: "no such commit in this changeset" }, 404);
8089
+ } catch (err) {
8090
+ if (err instanceof MissingBaseError) return c.json({ error: err.message }, 400);
8091
+ throw err;
8092
+ }
8093
+ });
8094
+ app.get("/api/file", async (c) => {
7883
8095
  const path = c.req.query("path");
7884
8096
  const side = c.req.query("side");
8097
+ const rev = c.req.query("rev");
7885
8098
  if (!path || side !== "base" && side !== "head") return c.json({ error: "need ?path= and ?side=base|head" }, 400);
7886
8099
  let bytes;
7887
8100
  try {
7888
- bytes = side === "head" ? getHeadFileBytes(ctx.root, path) : getBaseFileBytes(ctx.root, resolveBaseRef(ctx.root, ctx.spec), path);
8101
+ if (rev !== void 0) {
8102
+ if (side !== "head") return c.json({ error: "?rev= reads the head side only" }, 400);
8103
+ if (!await isListedCommit(ctx.root, ctx.spec, rev)) return c.json({ error: "no such commit in this changeset" }, 404);
8104
+ bytes = getBaseFileBytes(ctx.root, rev, path);
8105
+ } else bytes = side === "head" ? getHeadFileBytes(ctx.root, path) : getBaseFileBytes(ctx.root, resolveBaseRef(ctx.root, ctx.spec), path);
7889
8106
  } catch (err) {
7890
8107
  if (err instanceof MissingBaseError) return c.json({ error: err.message }, 400);
7891
8108
  throw err;
@@ -9198,11 +9415,11 @@ async function printAgentNextStep(port, pr) {
9198
9415
  const review = await fetchReviewState(port);
9199
9416
  const D = doctrineFor(pr ? { pr } : null);
9200
9417
  if (pr) console.log(`note: ${prOpenNote(repoPath, pr)}`);
9418
+ console.log(pr ? `next: run \`${CLI_COMMANDS.poll}\` ${POLL_NOW}. Hold it as ${POLL_STANCE}; ${POLL_NOW_FOREGROUND}. It receives the reviewer's private questions; the pull request already names their tab (\`diffo help agent\` prints the whole loop)` : `next: run \`${CLI_COMMANDS.firstPoll}\` ${POLL_NOW}. Hold it as ${POLL_STANCE}; ${POLL_NOW_FOREGROUND}. It receives the reviewer's feedback; the title is ${TAB_TITLE.what} (${TAB_TITLE.examples}): ${TAB_TITLE.why} (\`diffo help agent\` prints the whole loop)`);
9201
9419
  const nudge = review ? guideNudge(findGuideThread(review) !== void 0, D) : null;
9202
- if (nudge) console.log(`first: ${nudge}`);
9420
+ if (nudge) console.log(`then: ${nudge}`);
9203
9421
  const layers = review ? layersNudge(review, D) : null;
9204
9422
  if (layers) console.log(`also: ${layers}`);
9205
- console.log(pr ? `next: run \`${CLI_COMMANDS.poll}\` to receive the reviewer's private questions; the pull request already names their tab (\`diffo help agent\` prints the whole loop)` : `next: run \`${CLI_COMMANDS.firstPoll}\` to receive the reviewer's feedback; the title is ${TAB_TITLE.what} (${TAB_TITLE.examples}): ${TAB_TITLE.why} (\`diffo help agent\` prints the whole loop)`);
9206
9423
  }
9207
9424
  /**
9208
9425
  * Dev builds serve the client from the checkout's `dist/client`, which nothing