@diffohq/diffo 0.0.2 → 0.2.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 (105) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +493 -96
  3. package/dist/client/assets/{abnfDiagram-VCTEODGH--nG4YFZf.js → abnfDiagram-VCTEODGH-BK74b0Uj.js} +1 -1
  4. package/dist/client/assets/{arc-DUDGa5yC.js → arc-BZJjnFHJ.js} +1 -1
  5. package/dist/client/assets/architecture-7GRP2DOG-BMofp_0u.js +1 -0
  6. package/dist/client/assets/{architectureDiagram-5GKGNRK7-DvSKG4lO.js → architectureDiagram-5GKGNRK7-BBjd1L77.js} +1 -1
  7. package/dist/client/assets/{blockDiagram-NRAW4CY4-t9YA0Zds.js → blockDiagram-I7D4REHJ-BWSjZ0YR.js} +2 -2
  8. package/dist/client/assets/{c4Diagram-UCG6FXSJ-D0NJEiQf.js → c4Diagram-7LVT6UL2-z-xUGKCJ.js} +1 -1
  9. package/dist/client/assets/channel-CmX7fcUa.js +1 -0
  10. package/dist/client/assets/{chunk-TEH6E4GO-ByR-u0fj.js → chunk-4HAMMTFA-CAN-T2o-.js} +1 -1
  11. package/dist/client/assets/{chunk-75Z2AOVW-Bai5ank7.js → chunk-75Z2AOVW-B_bGdfJP.js} +1 -1
  12. package/dist/client/assets/{chunk-DU6HZSFF-D2kJr6Sd.js → chunk-DU6HZSFF-wyqPpFTy.js} +1 -1
  13. package/dist/client/assets/{chunk-F27PBJKO-Bfqt90G-.js → chunk-F27PBJKO-C6X74WsU.js} +1 -1
  14. package/dist/client/assets/{chunk-GMAD6QVW-DEk8GHWm.js → chunk-GMAD6QVW-DBossm8a.js} +1 -1
  15. package/dist/client/assets/{chunk-OBVCFTLP-DTR5L5DZ.js → chunk-GVQU2GXP-3NWb-Kjs.js} +1 -1
  16. package/dist/client/assets/{chunk-G27WJ6UU-BaH77Llj.js → chunk-IMKFNOWR-BCvjP66C.js} +1 -1
  17. package/dist/client/assets/{chunk-JQ64N6SF-DSC3eBmu.js → chunk-L3NEJ4N5-DppEbElX.js} +1 -1
  18. package/dist/client/assets/chunk-OSK3NFVY-BJ8i4cd-.js +10 -0
  19. package/dist/client/assets/{chunk-P2QGCYS3-B4CbyKz8.js → chunk-P2QGCYS3-fvNVbdI1.js} +1 -1
  20. package/dist/client/assets/{chunk-POPQ4Y6H-bUwnKEA6.js → chunk-POPQ4Y6H-DKwUAqbe.js} +1 -1
  21. package/dist/client/assets/{chunk-PWAF6VOD-zdMmz14g.js → chunk-PWAF6VOD-BRN4CzQ0.js} +1 -1
  22. package/dist/client/assets/{chunk-RHFEMEQ7-B2_qXJHf.js → chunk-SHT3W25Y-BfzjiqOe.js} +2 -2
  23. package/dist/client/assets/{chunk-SVP7TREG-2A6DuC2c.js → chunk-SVP7TREG-CsO-w42m.js} +1 -1
  24. package/dist/client/assets/{chunk-LCL6LL3I-CbXTQnS7.js → chunk-TICWLB2K-DPzmokY4.js} +1 -1
  25. package/dist/client/assets/{chunk-XXDRQBXY-D_O-C3As.js → chunk-XXDRQBXY-BikhrMBF.js} +1 -1
  26. package/dist/client/assets/classDiagram-ZZMXUADV-BSMgXeCj.js +1 -0
  27. package/dist/client/assets/classDiagram-v2-VYDZK3BY-BSMgXeCj.js +1 -0
  28. package/dist/client/assets/{core-Bnn4HFdG.js → core-BJUznnCg.js} +1 -1
  29. package/dist/client/assets/{cose-bilkent-JH36ORCC-Z148C14w.js → cose-bilkent-JH36ORCC-CBu7-BXr.js} +1 -1
  30. package/dist/client/assets/{cynefin-OW5HDTMX-_nvdHJ_6.js → cynefin-OW5HDTMX-iJEmw--0.js} +1 -1
  31. package/dist/client/assets/{cynefinDiagram-5FMLGOSQ-DgjK3dBx.js → cynefinDiagram-5FMLGOSQ-B4JnaLZu.js} +1 -1
  32. package/dist/client/assets/{cytoscape.esm-C2x7inlt.js → cytoscape.esm-CGd-uY3x.js} +1 -1
  33. package/dist/client/assets/{dagre-3AP2YEHR-ZpUDL3As.js → dagre-GXQ25YYZ-bmDafESr.js} +1 -1
  34. package/dist/client/assets/{diagram-S7CK7UJ4-CkZIS9iX.js → diagram-S7CK7UJ4-C1jP2NX5.js} +1 -1
  35. package/dist/client/assets/{diagram-UQ7AKVKN-CQ18pzfD.js → diagram-UQ7AKVKN-DssGkq6D.js} +1 -1
  36. package/dist/client/assets/{diagram-VSXAHHWV-BHksZhNT.js → diagram-VSXAHHWV-BMNFierD.js} +1 -1
  37. package/dist/client/assets/{diagram-VX7I27RA-JaMo95UB.js → diagram-VX7I27RA-BmHtC5T2.js} +1 -1
  38. package/dist/client/assets/{diagram-Z3DM3KII-BtbUi7Zp.js → diagram-Z3DM3KII-CzCV6KsS.js} +1 -1
  39. package/dist/client/assets/dist-BT7WB1vp.js +142 -0
  40. package/dist/client/assets/{dist-frF0Fo17.js → dist-bii_eoxi.js} +1 -1
  41. package/dist/client/assets/{ebnfDiagram-PWID7BFC-CrkTnETC.js → ebnfDiagram-PWID7BFC-DuVnjPWa.js} +1 -1
  42. package/dist/client/assets/{engine-oniguruma-BDMkTjCm.js → engine-oniguruma-BlIEwGaN.js} +1 -1
  43. package/dist/client/assets/{erDiagram-SSCWMZ5O-BU_DeMcX.js → erDiagram-RLTQ6QDP-CtwYHyZu.js} +1 -1
  44. package/dist/client/assets/eventmodeling-NTZA5JFV-DBQsn9JF.js +1 -0
  45. package/dist/client/assets/flowDiagram-HODETNUW-C_zt1wER.js +1 -0
  46. package/dist/client/assets/{ganttDiagram-EL5Y4UJY-DCoVu2Ob.js → ganttDiagram-EL5Y4UJY-CR8Sa06t.js} +1 -1
  47. package/dist/client/assets/{gitGraph-4MIJSDKK-D_vOLjJd.js → gitGraph-4MIJSDKK-DoWc_DJ0.js} +1 -1
  48. package/dist/client/assets/{gitGraphDiagram-WWUBYQGX-CbJeUVdZ.js → gitGraphDiagram-WWUBYQGX-C36JIyKh.js} +1 -1
  49. package/dist/client/assets/index-BUq9UmeC.js +91 -0
  50. package/dist/client/assets/index-BpeDzG-X.css +1 -0
  51. package/dist/client/assets/{info-A6RAGUB7-CRcr_2Kl.js → info-A6RAGUB7-DULcTga7.js} +1 -1
  52. package/dist/client/assets/{infoDiagram-RXCK75RN-B4UffpYj.js → infoDiagram-27XIBGKW-eQbUxJ9T.js} +1 -1
  53. package/dist/client/assets/{ishikawaDiagram-5VMMS53U-DO6STczE.js → ishikawaDiagram-5VMMS53U-CsPYEqSH.js} +1 -1
  54. package/dist/client/assets/{journeyDiagram-EYS64GPL-CJqu4rjo.js → journeyDiagram-3NMN7TZE-DbHeKN0X.js} +2 -2
  55. package/dist/client/assets/{kanban-definition-3QL26DDD-ugjVwpzB.js → kanban-definition-UXKFOSKX-CbYeTY2r.js} +1 -1
  56. package/dist/client/assets/{line-C42aEa5T.js → line-BeOqlVB1.js} +1 -1
  57. package/dist/client/assets/{linear-CAfNWogY.js → linear-D5d3UlAm.js} +1 -1
  58. package/dist/client/assets/{mermaid-parser.core-DIpynd7a.js → mermaid-parser.core-BLSiQUXp.js} +3 -3
  59. package/dist/client/assets/{mermaid.core-b7ipG6MC.js → mermaid.core-BoXUi08V.js} +5 -5
  60. package/dist/client/assets/{mindmap-definition-FBJOCRG2-Btow11vP.js → mindmap-definition-YA3MSWOX-o49TTsg4.js} +1 -1
  61. package/dist/client/assets/{packet-AYTQ26CC-DmMiSJ_n.js → packet-AYTQ26CC-Blxpg-nI.js} +1 -1
  62. package/dist/client/assets/{pegDiagram-XKGWAZYB-ZE6QXs-N.js → pegDiagram-XKGWAZYB-CZxoewnI.js} +1 -1
  63. package/dist/client/assets/{pie-WAS4IAKB-CWO1GXQk.js → pie-WAS4IAKB-D-fqeiW7.js} +1 -1
  64. package/dist/client/assets/{pieDiagram-E7YTZNPT-CkYClGbv.js → pieDiagram-E7YTZNPT-dAiwtigh.js} +1 -1
  65. package/dist/client/assets/{quadrantDiagram-AXDQQJYC-CLah-4Vu.js → quadrantDiagram-AXDQQJYC-mefTaSBl.js} +1 -1
  66. package/dist/client/assets/{radar-RG4KPBEZ-VI14cwZd.js → radar-RG4KPBEZ-VaODqy4w.js} +1 -1
  67. package/dist/client/assets/{railroad-74A4TZTK-s1iFKSLC.js → railroad-74A4TZTK-BwoigfOV.js} +1 -1
  68. package/dist/client/assets/railroad-abnf-HS5TGJTU-BYYKbIBi.js +1 -0
  69. package/dist/client/assets/railroad-ebnf-LZEXJU2U-BFA23uel.js +1 -0
  70. package/dist/client/assets/railroad-peg-WCYAUIDC-Cq02Hm_P.js +1 -0
  71. package/dist/client/assets/{railroadDiagram-O6MQD6OU-DSEEuZLE.js → railroadDiagram-O6MQD6OU-DdhfhlaU.js} +1 -1
  72. package/dist/client/assets/{requirementDiagram-EFPCY7ZU-DKPeLCXy.js → requirementDiagram-BXWQKSXE-BVrRfVMy.js} +1 -1
  73. package/dist/client/assets/{sankeyDiagram-P5KCCOFB-CoRGTY0Z.js → sankeyDiagram-P5KCCOFB-BalSSH4U.js} +1 -1
  74. package/dist/client/assets/{sequenceDiagram-WJ2MYXX4-vOT2DQAD.js → sequenceDiagram-WJ2MYXX4-B2KsE_Z_.js} +1 -1
  75. package/dist/client/assets/{src-COpTHlse.js → src-CfhDi0bm.js} +1 -1
  76. package/dist/client/assets/{stateDiagram-HBIQ2CUA-B1Q5gx1k.js → stateDiagram-D77RDMKH-CoH6bKG5.js} +1 -1
  77. package/dist/client/assets/stateDiagram-v2-MP3YSRHH-rXsredo-.js +1 -0
  78. package/dist/client/assets/{swimlanes-XN3QIQJK-BHsSTPP_.js → swimlanes-42K2YHIH-3IkzoE1X.js} +1 -1
  79. package/dist/client/assets/swimlanesDiagram-VR7AAH4N-BeXrrDxh.js +8 -0
  80. package/dist/client/assets/{timeline-definition-24CTP7MA-gMAK8Huz.js → timeline-definition-24CTP7MA-B2M9pP5h.js} +1 -1
  81. package/dist/client/assets/{treeView-Q6P3EWNA-D_ac7x_f.js → treeView-Q6P3EWNA-Brp55dHV.js} +1 -1
  82. package/dist/client/assets/{treemap-WGGIJYW6-CmKDF5Bk.js → treemap-WGGIJYW6-zRe9RDJV.js} +1 -1
  83. package/dist/client/assets/{vennDiagram-4TSXK5OY-BwoJOmDU.js → vennDiagram-4TSXK5OY-PNF-rFET.js} +1 -1
  84. package/dist/client/assets/{wardley-WFR3VGLG-DBwnOUBJ.js → wardley-WFR3VGLG-BSDDZIR6.js} +1 -1
  85. package/dist/client/assets/{wardleyDiagram-VM6X3IG4-DSpTGNPm.js → wardleyDiagram-VM6X3IG4-CFaIuWQk.js} +1 -1
  86. package/dist/client/assets/{xychartDiagram-S5SC5T6Z-I-7pacDu.js → xychartDiagram-S5SC5T6Z-et5vk4QS.js} +1 -1
  87. package/dist/client/index.html +2 -2
  88. package/package.json +2 -1
  89. package/plugin.json +1 -1
  90. package/skills/diffo/SKILL.md +52 -124
  91. package/dist/client/assets/architecture-7GRP2DOG-BS3omA-i.js +0 -1
  92. package/dist/client/assets/channel-C2N97jf-.js +0 -1
  93. package/dist/client/assets/chunk-R7TYR2AO-Bij6COIm.js +0 -10
  94. package/dist/client/assets/classDiagram-DTDB5LWJ-DkiwiYZN.js +0 -1
  95. package/dist/client/assets/classDiagram-v2-JRS7N3AN-DkiwiYZN.js +0 -1
  96. package/dist/client/assets/eventmodeling-NTZA5JFV-C6iBQfJc.js +0 -1
  97. package/dist/client/assets/flowDiagram-A5DVABFB-Dq3MklmQ.js +0 -1
  98. package/dist/client/assets/index-DUY5AD4r.js +0 -90
  99. package/dist/client/assets/index-pWZ6lr0e.css +0 -1
  100. package/dist/client/assets/railroad-abnf-HS5TGJTU-DCYBjW-8.js +0 -1
  101. package/dist/client/assets/railroad-ebnf-LZEXJU2U-Cpg28CUR.js +0 -1
  102. package/dist/client/assets/railroad-peg-WCYAUIDC-D3OHUWgZ.js +0 -1
  103. package/dist/client/assets/stateDiagram-v2-4QOOHH4V-Bq1OmWqW.js +0 -1
  104. package/dist/client/assets/swimlanesDiagram-VK2B7HYN-DACxnx54.js +0 -8
  105. /package/dist/client/assets/{sizeCapture-X5ZJPWSS-B0uUizjq.js → sizeCapture-INFHLROL-B0uUizjq.js} +0 -0
package/dist/cli.mjs CHANGED
@@ -1,17 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
4
- import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, watch, writeFileSync } from "node:fs";
4
+ import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, watch, writeFileSync } from "node:fs";
5
5
  import { createServer } from "node:net";
6
6
  import { homedir } from "node:os";
7
7
  import { basename, dirname, extname, join, resolve, sep } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { parseArgs, promisify } from "node:util";
10
+ import { createHash, randomUUID } from "node:crypto";
10
11
  import { readFile } from "node:fs/promises";
11
12
  import { serve } from "@hono/node-server";
12
13
  import { Hono } from "hono";
13
14
  import { stream, streamSSE } from "hono/streaming";
14
- import { createHash, randomUUID } from "node:crypto";
15
15
  //#region src/agentSession.ts
16
16
  /** A row whose leading token is a shell is never the session. Tested on the FIRST
17
17
  * token only: under Claude Code the transient `zsh -c` carries
@@ -94,11 +94,6 @@ function untouchedAgentVoice(thread) {
94
94
  function undeliveredThreadIds(threads) {
95
95
  return threads.filter((t) => (t.state === "sent" || t.state === "addressed") && t.unanswered !== true && t.withheld !== true && t.messages.at(-1)?.author === "reviewer").sort((a, b) => (a.sentAt ?? "").localeCompare(b.sentAt ?? "")).map((t) => t.id);
96
96
  }
97
- const REVIEW_VERDICTS = [
98
- "comment",
99
- "approve",
100
- "request-changes"
101
- ];
102
97
  const EMPTY_REVIEW = {
103
98
  version: 1,
104
99
  threads: []
@@ -128,26 +123,59 @@ function threadsInChangeset(files, threads) {
128
123
  past
129
124
  };
130
125
  }
126
+ /** The line part of a hunk anchor's label: `12`, or `12-20` for a range. */
127
+ function anchorSpan(anchor) {
128
+ return anchor.endLine === void 0 ? `${anchor.line}` : `${anchor.line}-${anchor.endLine}`;
129
+ }
131
130
  function describeAnchor(anchor) {
132
131
  if (anchor.kind === "changeset") return "the whole changeset";
133
132
  if (anchor.kind === "file") return anchor.path;
134
- return `${anchor.path}:${anchor.line} (${anchor.side} side)`;
133
+ return `${anchor.path}:${anchorSpan(anchor)} (${anchor.side} side)`;
135
134
  }
136
135
  //#endregion
137
136
  //#region src/server/prompt.ts
138
- function snapshotHunk(changeset, hunkId) {
137
+ /** A comment can anchor to a long range; the frozen text keeps its head. */
138
+ const ANCHORED_TEXT_CAP = 10;
139
+ function findHunk(changeset, hunkId) {
139
140
  for (const file of changeset.files) {
140
141
  const hunk = file.hunks.find((h) => h.id === hunkId);
141
- if (!hunk) continue;
142
- const marker = {
143
- add: "+",
144
- del: "-",
145
- context: " "
146
- };
147
- return hunk.lines.map((l) => marker[l.kind] + l.text).join("\n");
142
+ if (hunk) return hunk;
148
143
  }
149
144
  return null;
150
145
  }
146
+ /**
147
+ * Freeze what a new thread anchors to: the whole hunk as a diff snapshot, plus
148
+ * — for the anchored line range — where those rows sit in it and their text.
149
+ * Null for non-hunk anchors and for a hunk the changeset no longer has.
150
+ */
151
+ function captureAnchor(changeset, anchor) {
152
+ if (anchor.kind !== "hunk") return null;
153
+ const hunk = findHunk(changeset, anchor.hunkId);
154
+ if (!hunk) return null;
155
+ const marker = {
156
+ add: "+",
157
+ del: "-",
158
+ context: " "
159
+ };
160
+ const rows = hunk.lines.map((l) => marker[l.kind] + l.text);
161
+ const codeContext = rows.join("\n");
162
+ const last = anchor.endLine ?? anchor.line;
163
+ const covered = hunk.lines.map((l, row) => ({
164
+ no: anchor.side === "old" ? l.oldNo : l.newNo,
165
+ row
166
+ })).filter((r) => r.no !== null && r.no >= anchor.line && r.no <= last);
167
+ if (covered.length === 0) return { codeContext };
168
+ const start = covered[0].row;
169
+ const end = covered.at(-1).row;
170
+ return {
171
+ codeContext,
172
+ anchored: {
173
+ start,
174
+ end,
175
+ text: rows.slice(start, Math.min(end + 1, start + ANCHORED_TEXT_CAP)).join("\n")
176
+ }
177
+ };
178
+ }
151
179
  const NPX = `npx -y @diffohq/diffo`;
152
180
  /** This checkout's root — `src/server/prompt.ts` → up two. */
153
181
  const CHECKOUT_ROOT = fileURLToPath(new URL("../..", import.meta.url)).replace(/\/$/, "");
@@ -191,11 +219,13 @@ const INSTALL_SKILL = {
191
219
  const POLL_STANCE = "a tracked background task if your harness has one, the foreground if not — never a detached process";
192
220
  const JOIN_PROMPT = `join the diffo review: run \`${CLI_COMMANDS.poll}\` (${POLL_STANCE}) and follow the JSON payload it prints — each payload carries its own instructions`;
193
221
  function nextStepFor(kind, actionable) {
222
+ if (kind === "cleared") return `Post the guide if this changeset warrants one, then run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`;
194
223
  if (kind === "finish" && actionable === 0) return `Nothing to act on — run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}); the reviewer may follow up.`;
195
224
  return `${kind === "finish" ? "The reviewer is done reading — work the whole batch. " : ""}Act on each thread, reply with \`${CLI_COMMANDS.reply}\`, then run \`${CLI_COMMANDS.poll}\` again to keep listening.`;
196
225
  }
197
226
  const ACK_NEXT_STEP = {
198
227
  reply: `When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
228
+ replyMore: "Interim reply posted — the reviewer still sees you working on this thread. Post the follow-up as a plain reply (no --more) BEFORE your next poll: re-polling closes the batch and counts the promise as never kept.",
199
229
  comment: `It's in the review as your comment, labeled as yours — the reviewer replies to take it up, or resolves it. Continue with the review threads, then run \`${CLI_COMMANDS.poll}\`.`,
200
230
  end: "Detached. Do not reopen or re-poll this review unless the user asks — deliver anything remaining directly in the conversation."
201
231
  };
@@ -242,6 +272,11 @@ const INTENT_CONTRACT = {
242
272
  fix: "- `issue` threads want a code change. Address each one, or push back in the thread with your reasoning."
243
273
  };
244
274
  const UNLABELED_CONTRACT = "- Unlabeled threads: judge from the text — a question wants an answer, not an edit.";
275
+ /** The agent spoke last: the thread is answered until the reviewer says more.
276
+ * A reply that promised a follow-up (`--more`) is not an answer yet. */
277
+ function answeredByAgent(thread) {
278
+ return thread.messages.at(-1)?.author === "agent" && thread.awaitingFollowUp !== true;
279
+ }
245
280
  /** The closing note is the reviewer summing up, so it earns an answer even when it
246
281
  * asks nothing — a note with no reply is the review's one dead end. */
247
282
  const CLOSING_CONTRACT = "- The closing note speaks for the whole review: read it first, and reply to it — briefly if it only sums up, in full if it asks something.";
@@ -252,9 +287,34 @@ function intentContract(threads) {
252
287
  if (threads.some((t) => t.closingNote)) lines.push(CLOSING_CONTRACT);
253
288
  return lines;
254
289
  }
255
- function replyProtocol(threads) {
290
+ /**
291
+ * The short form, for a session that already received the full protocol this
292
+ * attachment (tracked per session pid by the DeliveryQueue). The full text is
293
+ * ~580 tokens and re-shipping it with every delivery was the loop's largest
294
+ * recurring cost; the compact form keeps only the per-batch contract — intent
295
+ * rules, the reply command, the re-poll — plus the pointer that reprints the rest.
296
+ */
297
+ function compactProtocol(threads) {
298
+ return `## How to respond
299
+
300
+ Same protocol as your earlier deliveries (\`${CLI} help agent\` reprints it in full):
301
+
302
+ 1. Act on each thread.
303
+ ${intentContract(threads).map((line) => ` ${line}`).join("\n")}
304
+ 2. Reply per thread as soon as it is handled — what changed and where (\`file:line\`), verify it the cheapest honest way first:
305
+
306
+ ${CLI_COMMANDS.reply}
307
+
308
+ 3. When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening.
309
+
310
+ Change only what these threads ask about — the reviewer is mid-read. Re-read
311
+ the current file before editing; the code may have moved. Resolving a thread
312
+ is the reviewer's call, never yours.`;
313
+ }
314
+ function replyProtocol(threads, mode = "full") {
315
+ if (mode === "compact") return compactProtocol(threads);
256
316
  const batchNote = threads.length > 1 ? "\nRead every thread before you start editing — threads can touch the same\ncode, and an edit for one can move what another is anchored to.\n" : "";
257
- const answeredNote = threads.some((t) => t.messages.at(-1)?.author === "agent") ? [" - Some threads end with your own earlier reply — if nothing new was asked since, don't reply to them again."] : [];
317
+ const answeredNote = threads.some((t) => answeredByAgent(t)) ? [" - Some threads end with your own earlier reply — if nothing new was asked since, don't reply to them again."] : [];
258
318
  return `## How to respond
259
319
 
260
320
  Diffo is running locally; talk to it through its CLI.
@@ -274,6 +334,10 @@ ${[...intentContract(threads).map((line) => ` ${line}`), ...answeredNote].join
274
334
  is fixed, verify it the cheapest honest way (run the relevant test, re-read
275
335
  the change) and mention what you checked.
276
336
  Reply as soon as a thread is handled; don't save replies for the end.
337
+ A reply that only promises a follow-up ("I'll investigate and report
338
+ back") is not an answer — post it with \`--more\` so the reviewer keeps
339
+ seeing you at work on that thread, then post the real answer as a plain
340
+ reply (no \`--more\`) before your next poll.
277
341
  Replies and comment threads render GitHub-flavored markdown, and a
278
342
  \`\`\`mermaid fence renders as a diagram — use one when a flow, sequence,
279
343
  or state picture explains the change better than prose. Keep it small
@@ -341,20 +405,84 @@ function siblingLines(siblings) {
341
405
  ].join("\n");
342
406
  }
343
407
  const SNAPSHOT_LINE_CAP = 25;
408
+ /** The heading's movement-proof identifier: the first anchored line's text,
409
+ * stripped of its diff marker. The line numbers in `describeAnchor` go stale
410
+ * the moment the code moves; this text is what the agent can still search for. */
411
+ function anchorQuote(thread) {
412
+ const first = thread.anchored?.text.split("\n").map((line) => line.slice(1).trim()).find((line) => line !== "")?.slice(0, 80);
413
+ if (!first) return "";
414
+ return thread.anchor.kind === "hunk" && thread.anchor.endLine !== void 0 ? ` — starts at: "${first}"` : ` — "${first}"`;
415
+ }
416
+ /** When `path:line` must not be trusted against the working tree: an old-side
417
+ * anchor never matches it, and a rotated hunk means the code moved under the
418
+ * numbers. At most one note — removed code can't also have "moved". */
419
+ function anchorNote(thread) {
420
+ if (thread.anchor.kind !== "hunk") return [];
421
+ if (thread.anchor.side === "old") return ["This comments on removed code — the anchored lines are the old version, not the current file."];
422
+ if (thread.codeChanged) return ["The code under this comment changed after the comment was written — its line numbers are stale. Find the code by its anchored lines, not by number."];
423
+ return [];
424
+ }
425
+ /** The `[from, to)` slice of the snapshot to show: the anchored rows centered
426
+ * inside the cap. A range longer than the cap keeps its head. */
427
+ function snapshotWindow(total, anchored) {
428
+ const size = Math.min(total, SNAPSHOT_LINE_CAP);
429
+ const span = anchored.end - anchored.start + 1;
430
+ const pad = Math.floor(Math.max(0, size - span) / 2);
431
+ const from = Math.min(Math.max(0, anchored.start - pad), total - size);
432
+ return {
433
+ from,
434
+ to: from + size
435
+ };
436
+ }
437
+ /** The snapshot as the agent sees it: windowed on the anchored rows, each of
438
+ * them marked with `>`. Threads from before `anchored` existed keep the old
439
+ * head-of-hunk window. */
440
+ function snapshotBlock(thread) {
441
+ const { codeContext, anchored } = thread;
442
+ if (!codeContext) {
443
+ if (!anchored) return [];
444
+ const shown = anchored.text.split("\n");
445
+ const cut = anchored.end - anchored.start + 1 - shown.length;
446
+ return [
447
+ "The commented lines, as they were when the comment was written:",
448
+ "```diff",
449
+ ...shown,
450
+ "```",
451
+ ...cut > 0 ? [`(… and ${cut} more commented lines)`] : []
452
+ ];
453
+ }
454
+ const lines = codeContext.split("\n");
455
+ const where = thread.anchor.kind === "changeset" ? "the file" : `\`${thread.anchor.path}\``;
456
+ if (thread.deliveredThrough) return [`(diff snapshot delivered to you earlier — read the current ${where} instead)`];
457
+ if (!anchored) return [
458
+ "The commented change:",
459
+ "```diff",
460
+ ...lines.slice(0, SNAPSHOT_LINE_CAP),
461
+ "```",
462
+ ...lines.length > SNAPSHOT_LINE_CAP ? [`(… and ${lines.length - SNAPSHOT_LINE_CAP} more snapshot lines — read the current ${where} instead)`] : []
463
+ ];
464
+ const { from, to } = snapshotWindow(lines.length, anchored);
465
+ const shown = lines.slice(from, to).map((line, i) => from + i >= anchored.start && from + i <= anchored.end ? `>${line}` : line);
466
+ const cut = lines.length - (to - from);
467
+ return [
468
+ "The commented change (`>` marks the commented lines):",
469
+ "```diff",
470
+ ...shown,
471
+ "```",
472
+ ...cut > 0 ? [`(windowed to the commented lines — ${cut} more snapshot lines around them; read the current ${where} for the full change)`] : []
473
+ ];
474
+ }
344
475
  function threadBlock(thread, index) {
345
476
  const label = startedByAgent(thread) ? " [your comment — the reviewer replied]" : thread.closingNote ? " [their closing note on the whole review]" : thread.intent ? ` [${INTENT_LABEL[thread.intent]}]` : "";
346
477
  const again = thread.unanswered ? " — YOU NEVER ANSWERED THIS" : "";
347
- const parts = [`### Thread ${index + 1}${label} — ${describeAnchor(thread.anchor)}${again}`, `id: ${thread.id}`];
348
- if (thread.codeContext) {
349
- const lines = thread.codeContext.split("\n");
350
- parts.push("The commented change:", "```diff", ...lines.slice(0, SNAPSHOT_LINE_CAP), "```");
351
- if (lines.length > SNAPSHOT_LINE_CAP) {
352
- const where = thread.anchor.kind === "changeset" ? "the file" : `\`${thread.anchor.path}\``;
353
- parts.push(`(… and ${lines.length - SNAPSHOT_LINE_CAP} more snapshot lines — read the current ${where} instead)`);
354
- }
355
- }
356
- parts.push("Messages:", ...thread.messages.map((m) => `- ${m.author}: ${m.text.replace(/\n/g, "\n ")}`));
357
- return parts.join("\n");
478
+ return [
479
+ `### Thread ${index + 1}${label} — ${describeAnchor(thread.anchor)}${anchorQuote(thread)}${again}`,
480
+ `id: ${thread.id}`,
481
+ ...anchorNote(thread),
482
+ ...snapshotBlock(thread),
483
+ "Messages:",
484
+ ...thread.messages.map((m) => `- ${m.author}: ${m.text.replace(/\n/g, "\n ")}`)
485
+ ].join("\n");
358
486
  }
359
487
  function buildThreadPrompt(thread, ctx) {
360
488
  const siblings = siblingLines(ctx.siblings ?? []);
@@ -365,7 +493,7 @@ function buildThreadPrompt(thread, ctx) {
365
493
  threadBlock(thread, 0),
366
494
  "",
367
495
  ...siblings ? [siblings, ""] : [],
368
- replyProtocol([thread]),
496
+ replyProtocol([thread], ctx.protocol),
369
497
  ""
370
498
  ].join("\n");
371
499
  }
@@ -377,7 +505,24 @@ function buildCoalescedPrompt(threads, ctx) {
377
505
  ...ctx.changeset ? [specLine(ctx.changeset), ""] : [],
378
506
  ...threads.map((t, i) => `${threadBlock(t, i)}\n`),
379
507
  ...siblings ? [siblings, ""] : [],
380
- replyProtocol(threads),
508
+ replyProtocol(threads, ctx.protocol),
509
+ ""
510
+ ].join("\n");
511
+ }
512
+ /**
513
+ * The reviewer started the review over — the previous round landed, and its
514
+ * threads and guide were cleared. Nothing to act on; the one thing owed is
515
+ * orientation for the fresh round, so this restates the guide doctrine the way
516
+ * the open-time nudge does (payloads must stand alone — see POLL_STANCE).
517
+ */
518
+ function buildClearedPrompt(ctx) {
519
+ return [
520
+ `The reviewer cleared the review in \`${ctx.repo.name}\` (branch \`${ctx.repo.branch}\`): the previous round landed, and its threads and guide are gone. What the reviewer sees now is a fresh round.`,
521
+ "",
522
+ ...ctx.changeset ? [specLine(ctx.changeset), ""] : [],
523
+ `There is no feedback to act on. But the fresh round has no guide — if this changeset needs one (${GUIDE.when}): post it — one comment on the whole changeset: ${GUIDE.what}: \`${CLI_COMMANDS.guide}\` (no file, so it anchors to the changeset). ${GUIDE.stance}.`,
524
+ "",
525
+ `Then run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
381
526
  ""
382
527
  ].join("\n");
383
528
  }
@@ -385,7 +530,9 @@ function buildFinishPrompt(threads, ctx, coverage) {
385
530
  const repo = ctx.repo;
386
531
  const sent = threads.filter((t) => t.state === "sent");
387
532
  const closing = sent.find((t) => t.closingNote);
388
- const actionable = closing ? [closing, ...sent.filter((t) => t !== closing)] : sent;
533
+ const ordered = closing ? [closing, ...sent.filter((t) => t !== closing)] : sent;
534
+ const answered = ordered.filter((t) => t !== closing && answeredByAgent(t));
535
+ const actionable = ordered.filter((t) => !answered.includes(t));
389
536
  const changed = coverage.changedFiles ?? [];
390
537
  const commented = coverage.commentedUnread ?? [];
391
538
  const filtered = coverage.filteredOut ?? [];
@@ -396,7 +543,6 @@ function buildFinishPrompt(threads, ctx, coverage) {
396
543
  coverage.skippedFiles.length > 0 ? ` Not marked read: ${coverage.skippedFiles.join(", ")}.` : ""
397
544
  ].join("");
398
545
  const files = coverage.totalFiles !== void 0 && coverage.totalFiles > 0 ? `${coverage.viewedFiles ?? 0}/${coverage.totalFiles} files read, ` : "";
399
- const verdictLine = coverage.verdict === "approve" ? "Verdict: **approved** — the reviewer is happy for this changeset to proceed." : coverage.verdict === "request-changes" ? "Verdict: **changes requested** — do not treat this review as done until the threads below are addressed." : null;
400
546
  const noteLines = coverage.note === void 0 ? [] : closing ? ["Their closing note is Thread 1 below — answer it there, like any other thread."] : ["Their closing note:", ...coverage.note.split("\n").map((l) => `> ${l}`)];
401
547
  const owed = actionable.filter((t) => t.unanswered);
402
548
  const owedLines = owed.length > 0 ? [
@@ -409,16 +555,18 @@ function buildFinishPrompt(threads, ctx, coverage) {
409
555
  const parts = [
410
556
  `A reviewer finished reading your changes in \`${repo.name}\` (branch \`${repo.branch}\`).`,
411
557
  "",
412
- ...verdictLine ? [verdictLine, ""] : [],
413
558
  ...noteLines.length > 0 ? [...noteLines, ""] : [],
414
559
  ...ctx.changeset ? [changesetFrame(ctx.changeset), ""] : [],
415
560
  `Coverage: ${files}${coverage.viewedHunks}/${coverage.totalHunks} hunks read.${skipped}`,
416
561
  "",
417
562
  ...owedLines
418
563
  ];
564
+ if (answered.length > 0) parts.push(`${answered.length} thread${answered.length === 1 ? "" : "s"} you already answered, with nothing new since — no reply owed, not repeated here:`, ...answered.map((t) => `- ${t.id} — ${describeAnchor(t.anchor)}`), "");
419
565
  if (coverage.skippedFiles.length > 0) parts.push("The reviewer left the not-marked-read files unread. If any of them hide", "a risky or subtle change you made, say so in a comment thread", `(\`${CLI_COMMANDS.comment}\`) — the reviewer replies to take it up, or resolves it.`, "");
420
- if (actionable.length === 0) parts.push("No open review threads — nothing to act on. Run", `\`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE});`, "the reviewer may follow up.", "");
421
- else parts.push(`${actionable.length} review thread${actionable.length === 1 ? "" : "s"} to act on:`, "", ...actionable.map((t, i) => `${threadBlock(t, i)}\n`), replyProtocol(actionable), "");
566
+ if (actionable.length === 0) {
567
+ const fullRead = coverage.totalHunks > 0 && coverage.viewedHunks >= coverage.totalHunks && changed.length === 0 && commented.length === 0 && filtered.length === 0 && coverage.skippedFiles.length === 0;
568
+ parts.push(fullRead && coverage.note === void 0 ? "They read everything and left nothing to address — take it as a green light to proceed." : "No open review threads — nothing to act on.", `Run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE});`, "the reviewer may follow up.", "");
569
+ } else parts.push(`${actionable.length} review thread${actionable.length === 1 ? "" : "s"} to act on:`, "", ...actionable.map((t, i) => `${threadBlock(t, i)}\n`), replyProtocol(actionable, ctx.protocol), "");
422
570
  return parts.join("\n");
423
571
  }
424
572
  //#endregion
@@ -481,11 +629,15 @@ The loop:
481
629
  away, leaving a background server watching the working tree. Never open a
482
630
  browser at the reviewer — end your message with the printed URL instead,
483
631
  and keep ending every message with it while you stay attached.
484
- 2. Guide (only when the changeset needs it — ${GUIDE.when}): before sharing
485
- the URL, post ONE comment on the whole changeset (\`diffo comment -m "…"\`,
486
- no file): ${GUIDE.what}.
487
- ${GUIDE.stance}. If the changeset later shifts under the guide,
488
- ${GUIDE.update}.
632
+ Attached without ever seeing the URL (a takeover, a fresh session)?
633
+ \`diffo status\` prints it.
634
+ 2. Guide — post one only when the changeset needs orientation:
635
+ ${GUIDE.when}.
636
+ Before sharing the URL, post ONE comment on the whole changeset
637
+ (\`diffo comment -m "…"\`, no file), containing:
638
+ ${GUIDE.what}.
639
+ ${GUIDE.stance}.
640
+ If the changeset later shifts under the guide, ${GUIDE.update}.
489
641
  3. Listen: run \`diffo poll\` — it blocks until the reviewer acts, then prints
490
642
  one JSON payload naming the threads to act on. Run it attended:
491
643
  ${POLL_STANCE}.
@@ -495,7 +647,10 @@ The loop:
495
647
  answer in the reply and no edit. Your edits reach the reviewer live.
496
648
  5. Reply: \`diffo reply <threadId> --message "<text>"\` (pipe long replies on
497
649
  stdin) — concise, addressed to the reviewer. Markdown renders; a
498
- \`\`\`mermaid fence draws a diagram.
650
+ \`\`\`mermaid fence draws a diagram. A reply that only promises a
651
+ follow-up ("I'll investigate and report back") goes out with \`--more\` —
652
+ the reviewer keeps seeing you at work — and the real answer follows as a
653
+ plain reply before the next poll.
499
654
  6. Comment (sparingly): \`diffo comment [<file>] [--line <n>] -m "<text>"\`
500
655
  starts a thread in your voice — a concern, or context that helps the read.
501
656
  7. Poll again only when the whole batch is handled — a new poll tells the
@@ -506,7 +661,9 @@ Rules:
506
661
  - Change only what the threads ask about — the reviewer is mid-read, and an
507
662
  unrelated edit moves the diff under them.
508
663
  - One attached agent at a time: the newest poll carries the review; don't
509
- re-poll to take it back from another session — tell the user instead.
664
+ re-poll to take it back from another session — tell the user instead. When
665
+ a poll says it took the review over, or returns status "superseded", say so
666
+ to the user: their feedback moved with it.
510
667
  - Resolving a thread is the reviewer's call, never yours.`,
511
668
  poll: `diffo poll — wait for the reviewer's feedback
512
669
 
@@ -531,6 +688,10 @@ Usage: diffo reply <threadId> --message "<text>"
531
688
 
532
689
  Thread ids arrive in poll payloads. Each run posts one message, so don't
533
690
  re-run a reply that succeeded.
691
+ --more marks the reply as interim: you are still working, and a follow-up
692
+ reply on this thread is promised. The reviewer keeps seeing the thread as
693
+ "with the agent" until your next plain reply; going quiet instead marks it
694
+ unanswered.
534
695
  Messages render GitHub-flavored markdown; a \`\`\`mermaid fence renders as a
535
696
  diagram in the review.
536
697
 
@@ -769,6 +930,7 @@ function parseVerb(verb, rest) {
769
930
  short: "m"
770
931
  },
771
932
  line: { type: "string" },
933
+ more: { type: "boolean" },
772
934
  json: { type: "boolean" },
773
935
  help: {
774
936
  type: "boolean",
@@ -789,6 +951,10 @@ function parseVerb(verb, rest) {
789
951
  kind: "error",
790
952
  message: `'${verb}' takes no --json`
791
953
  };
954
+ if (values.more && verb !== "reply") return {
955
+ kind: "error",
956
+ message: `'${verb}' takes no --more`
957
+ };
792
958
  if (verb === "poll" || verb === "end" || verb === "setup" || verb === "status" || verb === "stop") {
793
959
  if (positionals.length > 0) return {
794
960
  kind: "error",
@@ -821,7 +987,8 @@ function parseVerb(verb, rest) {
821
987
  return {
822
988
  kind: "reply",
823
989
  threadId,
824
- message: values.message ?? null
990
+ message: values.message ?? null,
991
+ more: values.more === true
825
992
  };
826
993
  }
827
994
  const file = positionals[0] ?? null;
@@ -849,6 +1016,57 @@ function parseVerb(verb, rest) {
849
1016
  };
850
1017
  }
851
1018
  //#endregion
1019
+ //#region src/devStamp.ts
1020
+ /**
1021
+ * A fingerprint of the dev checkout's source. The dev CLI runs `src/` straight
1022
+ * through tsx, so "which build is this?" isn't answered by the package version —
1023
+ * every edit is a new build with the same version string. The stamp is what
1024
+ * changes instead: the server reports the stamp of the source it loaded, the
1025
+ * next `diffo` open compares it to the source on disk, and a mismatch retires
1026
+ * the server exactly like a version bump would.
1027
+ *
1028
+ * Tests are skipped — they never run inside the server — and `index.html` is
1029
+ * included because the client bundle is built from it.
1030
+ */
1031
+ function computeSrcStamp(checkoutRoot) {
1032
+ const hash = createHash("sha256");
1033
+ const file = (path, name) => {
1034
+ hash.update(name);
1035
+ hash.update("\0");
1036
+ hash.update(readFileSync(path));
1037
+ hash.update("\0");
1038
+ };
1039
+ const walk = (dir, prefix) => {
1040
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
1041
+ for (const entry of entries) {
1042
+ if (entry.name.endsWith(".test.ts") || entry.name.endsWith(".test.tsx")) continue;
1043
+ const path = join(dir, entry.name);
1044
+ const name = `${prefix}${entry.name}`;
1045
+ if (entry.isDirectory()) walk(path, `${name}/`);
1046
+ else if (entry.isFile()) file(path, name);
1047
+ }
1048
+ };
1049
+ walk(join(checkoutRoot, "src"), "src/");
1050
+ try {
1051
+ file(join(checkoutRoot, "index.html"), "index.html");
1052
+ } catch {}
1053
+ return hash.digest("hex").slice(0, 16);
1054
+ }
1055
+ /**
1056
+ * This process's stamp, captured at import — for the server that means the
1057
+ * source it actually loaded, not whatever is on disk by the time someone asks.
1058
+ * Null outside dev (a published build has no `src/` to stamp, and its version
1059
+ * string already does this job).
1060
+ */
1061
+ const SRC_STAMP = (() => {
1062
+ if (!IS_DEV) return null;
1063
+ try {
1064
+ return computeSrcStamp(CHECKOUT_ROOT);
1065
+ } catch {
1066
+ return null;
1067
+ }
1068
+ })();
1069
+ //#endregion
852
1070
  //#region src/server/git.ts
853
1071
  const MAX_BUFFER = 52428800;
854
1072
  const SNIFF_BYTES = 8192;
@@ -1371,7 +1589,8 @@ var DiffoDb = class {
1371
1589
  const known = /* @__PURE__ */ new Set([
1372
1590
  "reviews",
1373
1591
  "servers",
1374
- "repo_ports"
1592
+ "repo_ports",
1593
+ "ui_settings"
1375
1594
  ]);
1376
1595
  const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
1377
1596
  for (const { name } of tables) if (!known.has(name)) this.db.exec(`DROP TABLE IF EXISTS "${name.replaceAll("\"", "\"\"")}"`);
@@ -1396,6 +1615,10 @@ var DiffoDb = class {
1396
1615
  repo_path TEXT PRIMARY KEY,
1397
1616
  port INTEGER NOT NULL
1398
1617
  );
1618
+ CREATE TABLE IF NOT EXISTS ui_settings (
1619
+ key TEXT PRIMARY KEY,
1620
+ value TEXT NOT NULL
1621
+ );
1399
1622
  PRAGMA user_version = ${SCHEMA_VERSION};
1400
1623
  `);
1401
1624
  this.pruneVanishedRepos();
@@ -1471,6 +1694,16 @@ var DiffoDb = class {
1471
1694
  this.db.prepare(`INSERT INTO repo_ports (repo_path, port) VALUES (?, ?)
1472
1695
  ON CONFLICT(repo_path) DO UPDATE SET port = excluded.port`).run(repoPath, port);
1473
1696
  }
1697
+ /** Reviewer preferences that must outlive one server's origin — every repo is
1698
+ * served from its own port, so localStorage alone can't hold a choice like
1699
+ * the theme. Keyed blobs, no scope: these are per-human, not per-repo. */
1700
+ getUiSetting(key) {
1701
+ return this.db.prepare("SELECT value FROM ui_settings WHERE key = ?").get(key)?.value ?? null;
1702
+ }
1703
+ setUiSetting(key, value) {
1704
+ this.db.prepare(`INSERT INTO ui_settings (key, value) VALUES (?, ?)
1705
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, value);
1706
+ }
1474
1707
  getServer(repoPath) {
1475
1708
  const row = this.db.prepare("SELECT port, pid, started_at FROM servers WHERE repo_path = ?").get(repoPath);
1476
1709
  return row ? {
@@ -1507,7 +1740,7 @@ const VERSION = (() => {
1507
1740
  dir = resolve(dir, "..");
1508
1741
  try {
1509
1742
  const pkg = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf-8"));
1510
- if (pkg.name === "diffo" && typeof pkg.version === "string") return pkg.version;
1743
+ if (pkg.name === "@diffohq/diffo" && typeof pkg.version === "string") return pkg.version;
1511
1744
  } catch {}
1512
1745
  }
1513
1746
  return "0.0.0";
@@ -1550,6 +1783,11 @@ var DeliveryQueue = class DeliveryQueue {
1550
1783
  reason: "no-agent"
1551
1784
  };
1552
1785
  owner = null;
1786
+ /** The session pid that last received the full reply protocol. Later
1787
+ * deliveries to the same session carry the compact form — the full text is
1788
+ * the loop's largest recurring token cost. A takeover (new pid) or an
1789
+ * anonymous session (null pid) always gets the full form again. */
1790
+ protocolPid = null;
1553
1791
  batch = null;
1554
1792
  batchWatch = null;
1555
1793
  waiterWatch = null;
@@ -1610,6 +1848,12 @@ var DeliveryQueue = class DeliveryQueue {
1610
1848
  ownerPid() {
1611
1849
  return this.ownerConnected() ? this.owner : null;
1612
1850
  }
1851
+ needsFullProtocol() {
1852
+ return this.owner === null || this.owner !== this.protocolPid;
1853
+ }
1854
+ markProtocolSent() {
1855
+ this.protocolPid = this.owner;
1856
+ }
1613
1857
  /**
1614
1858
  * A poll attaches for its session; the newest poll always carries the review.
1615
1859
  * Returns the pid of a still-live session it took the review from, so both
@@ -1635,7 +1879,8 @@ var DeliveryQueue = class DeliveryQueue {
1635
1879
  if (!bucket) {
1636
1880
  bucket = {
1637
1881
  threads: /* @__PURE__ */ new Set(),
1638
- finish: null
1882
+ finish: null,
1883
+ cleared: false
1639
1884
  };
1640
1885
  this.buckets.set(this.scope, bucket);
1641
1886
  }
@@ -1764,6 +2009,14 @@ var DeliveryQueue = class DeliveryQueue {
1764
2009
  this.bucket().finish = coverage;
1765
2010
  this.wake();
1766
2011
  }
2012
+ /** The reviewer cleared the review. A boolean, not a queue: clearing twice
2013
+ * before a poll still owes exactly one heads-up. */
2014
+ enqueueCleared() {
2015
+ this.bucket().cleared = true;
2016
+ const queued = this.waiter === null;
2017
+ this.wake();
2018
+ if (queued) this.notify();
2019
+ }
1767
2020
  drop(threadId) {
1768
2021
  for (const bucket of this.buckets.values()) bucket.threads.delete(threadId);
1769
2022
  this.deliveredAt.delete(threadId);
@@ -1823,7 +2076,7 @@ var DeliveryQueue = class DeliveryQueue {
1823
2076
  }
1824
2077
  hasPending() {
1825
2078
  const bucket = this.buckets.get(this.scope);
1826
- return bucket !== void 0 && (bucket.threads.size > 0 || bucket.finish !== null);
2079
+ return bucket !== void 0 && (bucket.threads.size > 0 || bucket.finish !== null || bucket.cleared);
1827
2080
  }
1828
2081
  wake() {
1829
2082
  if (this.waiter === null) return;
@@ -1845,11 +2098,19 @@ var DeliveryQueue = class DeliveryQueue {
1845
2098
  kind: "threads",
1846
2099
  threadIds: [...bucket.threads]
1847
2100
  };
2101
+ if (bucket.cleared) return { kind: "cleared" };
1848
2102
  return null;
1849
2103
  }
1850
2104
  /** The poll response for this snapshot was fully written — NOW it counts as
1851
2105
  * delivered. Clears exactly what the snapshot covered. */
1852
2106
  confirm(snapshot, deliveredThreadIds) {
2107
+ if (snapshot.kind === "cleared") {
2108
+ const bucket = this.buckets.get(this.scope);
2109
+ if (bucket) bucket.cleared = false;
2110
+ this.armGrace();
2111
+ this.notify();
2112
+ return;
2113
+ }
1853
2114
  for (const bucket of this.buckets.values()) if (snapshot.kind === "finish") {
1854
2115
  if (bucket.finish === snapshot.coverage) bucket.finish = null;
1855
2116
  for (const id of snapshot.absorbedThreadIds) bucket.threads.delete(id);
@@ -1868,10 +2129,19 @@ var DeliveryQueue = class DeliveryQueue {
1868
2129
  this.notify();
1869
2130
  }
1870
2131
  /** The agent replied on a thread. Returns how long since that thread's feedback
1871
- * was delivered, or null when the reply wasn't an answer to a delivery. */
1872
- agentReplied(threadId) {
2132
+ * was delivered, or null when the reply wasn't an answer to a delivery.
2133
+ * `interim` (a `--more` reply) keeps the thread on the clock: a follow-up is
2134
+ * still owed, so the spinner stays and a batch that closes without the
2135
+ * follow-up counts the thread unanswered. */
2136
+ agentReplied(threadId, interim = false) {
1873
2137
  const at = this.deliveredAt.get(threadId);
1874
- this.deliveredAt.delete(threadId);
2138
+ if (!interim) this.deliveredAt.delete(threadId);
2139
+ else if (at === void 0 && this.batch) {
2140
+ this.deliveredAt.set(threadId, Date.now());
2141
+ if (!this.batch.threadIds.includes(threadId)) this.batch.threadIds.push(threadId);
2142
+ this.awaitingReply = true;
2143
+ this.clearGrace();
2144
+ }
1875
2145
  if (this.batch) this.batch.sawReply = true;
1876
2146
  if (this.deliveredAt.size > 0) {
1877
2147
  if (this.awaitingReply) {
@@ -1981,6 +2251,15 @@ var IdleMonitor = class {
1981
2251
  * committed. Stamp `landed` — the UI's cue to offer a fresh start. A stash
1982
2252
  * empties the diff without moving HEAD, so it never stamps; a branch switch
1983
2253
  * is rescoped to its own review before this runs.
2254
+ * - The commit can also go unwitnessed: it lands and the next round starts
2255
+ * while no server watches, so no recompute ever sees the empty diff. The
2256
+ * evidence survives anyway — HEAD advanced past `seenHead` AND every hunk
2257
+ * the review pointed at (thread anchors, the last finish) is gone from the
2258
+ * current changeset. Both together stamp even over a non-empty diff; either
2259
+ * alone does not (an unrelated commit leaves the reviewed hunks in place, an
2260
+ * agent rewriting every hunk between recomputes leaves HEAD at `seenHead`).
2261
+ * A review that never anchored to a hunk has no such evidence and waits for
2262
+ * the witnessed path.
1984
2263
  * - A landed commit that left HEAD's history (reset, or an amend rewriting it)
1985
2264
  * drops the marker — and an amend re-stamps in the same pass, because
1986
2265
  * `seenHead` is still the pre-landing base and IS an ancestor of the
@@ -1993,7 +2272,7 @@ var IdleMonitor = class {
1993
2272
  * empty diff hangs over a non-empty review it stays frozen at the base — the
1994
2273
  * fixed point that makes "amended" and "reset back" distinguishable above.
1995
2274
  */
1996
- function maintainLanded(review, hasFiles, git) {
2275
+ function maintainLanded(review, hasFiles, currentHunkIds, git) {
1997
2276
  const head = git.head();
1998
2277
  if (head === null) return null;
1999
2278
  const state = review.get();
@@ -2005,7 +2284,7 @@ function maintainLanded(review, hasFiles, git) {
2005
2284
  outcome = "cleared";
2006
2285
  }
2007
2286
  }
2008
- if (!review.get().landed && !hasFiles && !reviewEmpty && state.seenHead !== void 0 && state.seenHead !== head && git.isAncestor(state.seenHead, head) === true) {
2287
+ if (!review.get().landed && (!hasFiles || reviewedHunksAllLanded(state, currentHunkIds)) && !reviewEmpty && state.seenHead !== void 0 && state.seenHead !== head && git.isAncestor(state.seenHead, head) === true) {
2009
2288
  review.markLanded({
2010
2289
  sha: head,
2011
2290
  subject: git.subject(head),
@@ -2016,6 +2295,18 @@ function maintainLanded(review, hasFiles, git) {
2016
2295
  if (hasFiles || reviewEmpty) review.noteHead(head);
2017
2296
  return outcome;
2018
2297
  }
2298
+ /** The unwitnessed-landing evidence: the review pointed at hunks, and none of
2299
+ * them survive in the current changeset. Vacuous truth deliberately fails —
2300
+ * a review of only file- and changeset-anchored threads names no hunks, so it
2301
+ * cannot testify that they landed. */
2302
+ function reviewedHunksAllLanded(state, currentHunkIds) {
2303
+ const referenced = /* @__PURE__ */ new Set();
2304
+ for (const thread of state.threads) if (thread.anchor.kind === "hunk") referenced.add(thread.anchor.hunkId);
2305
+ for (const id of state.lastFinish?.hunkIds ?? []) referenced.add(id);
2306
+ if (referenced.size === 0) return false;
2307
+ for (const id of referenced) if (currentHunkIds.has(id)) return false;
2308
+ return true;
2309
+ }
2019
2310
  //#endregion
2020
2311
  //#region src/server/review.ts
2021
2312
  const STATES = [
@@ -2068,14 +2359,15 @@ var ReviewStore = class {
2068
2359
  this.listeners.add(listener);
2069
2360
  return () => this.listeners.delete(listener);
2070
2361
  }
2071
- createThread(anchor, text, codeContext, intent, author = "reviewer") {
2362
+ createThread(anchor, text, capture, intent, author = "reviewer") {
2072
2363
  const now = (/* @__PURE__ */ new Date()).toISOString();
2073
2364
  return this.insert({
2074
2365
  id: randomUUID(),
2075
2366
  anchor,
2076
2367
  state: "open",
2077
2368
  ...intent ? { intent } : {},
2078
- codeContext,
2369
+ codeContext: capture?.codeContext ?? null,
2370
+ ...capture?.anchored ? { anchored: capture.anchored } : {},
2079
2371
  codeChanged: false,
2080
2372
  messages: [{
2081
2373
  id: randomUUID(),
@@ -2129,7 +2421,7 @@ var ReviewStore = class {
2129
2421
  * the reviewer raced in since — appended at the end, it would render as an
2130
2422
  * answer to a comment it never saw, and the raced comment would stop reading
2131
2423
  * as "waiting on the agent" even though it still is. */
2132
- addMessage(threadId, author, text, withheld = false, seenThroughMs) {
2424
+ addMessage(threadId, author, text, withheld = false, seenThroughMs, followUp = false) {
2133
2425
  return this.update(threadId, ({ unanswered: _answered, ...thread }) => {
2134
2426
  const message = {
2135
2427
  id: randomUUID(),
@@ -2137,9 +2429,11 @@ var ReviewStore = class {
2137
2429
  text,
2138
2430
  at: (/* @__PURE__ */ new Date()).toISOString()
2139
2431
  };
2140
- const raced = author === "agent" && seenThroughMs !== void 0 ? thread.messages.findIndex((m) => Date.parse(m.at) > seenThroughMs) : -1;
2432
+ const raced = author === "agent" && seenThroughMs !== void 0 ? thread.messages.findIndex((m) => m.author === "reviewer" && Date.parse(m.at) > seenThroughMs) : -1;
2433
+ const { awaitingFollowUp: _promised, ...settled } = thread;
2141
2434
  return {
2142
- ...thread,
2435
+ ...author === "agent" ? settled : thread,
2436
+ ...author === "agent" && followUp ? { awaitingFollowUp: true } : {},
2143
2437
  ...withheld && author === "reviewer" ? { withheld: true } : {},
2144
2438
  messages: raced === -1 ? [...thread.messages, message] : [
2145
2439
  ...thread.messages.slice(0, raced),
@@ -2188,7 +2482,7 @@ var ReviewStore = class {
2188
2482
  const threads = this.state.threads.map((thread) => {
2189
2483
  if (!wanted.has(thread.id) || !applies(thread)) return thread;
2190
2484
  changed = true;
2191
- const { unanswered: _was, ...rest } = thread;
2485
+ const { unanswered: _was, awaitingFollowUp: _promised, ...rest } = thread;
2192
2486
  return thread.unanswered ? rest : {
2193
2487
  ...rest,
2194
2488
  unanswered: true
@@ -2202,8 +2496,9 @@ var ReviewStore = class {
2202
2496
  this.commit();
2203
2497
  }
2204
2498
  setState(threadId, state) {
2205
- return this.update(threadId, (thread) => ({
2499
+ return this.update(threadId, ({ awaitingFollowUp: promised, ...thread }) => ({
2206
2500
  ...thread,
2501
+ ...promised && state !== "resolved" ? { awaitingFollowUp: true } : {},
2207
2502
  state,
2208
2503
  ...state === "sent" && !thread.sentAt ? { sentAt: (/* @__PURE__ */ new Date()).toISOString() } : {},
2209
2504
  ...state === "resolved" ? { codeContext: null } : {}
@@ -2454,7 +2749,6 @@ function normalizeCoverage(value) {
2454
2749
  skippedFiles: fileList(c.skippedFiles) ?? [],
2455
2750
  ...changedFiles !== void 0 ? { changedFiles } : {},
2456
2751
  ...commentedUnread !== void 0 ? { commentedUnread } : {},
2457
- ...REVIEW_VERDICTS.includes(c.verdict) ? { verdict: c.verdict } : {},
2458
2752
  ...typeof c.note === "string" ? { note: c.note } : {}
2459
2753
  };
2460
2754
  }
@@ -2489,12 +2783,14 @@ function parseAnchor(value) {
2489
2783
  if (typeof anchor.hunkId !== "string" || typeof anchor.path !== "string") return null;
2490
2784
  if (anchor.side !== "old" && anchor.side !== "new") return null;
2491
2785
  if (typeof anchor.line !== "number") return null;
2786
+ const endLine = typeof anchor.endLine === "number" && Number.isInteger(anchor.endLine) && anchor.endLine > anchor.line ? anchor.endLine : void 0;
2492
2787
  return {
2493
2788
  kind: "hunk",
2494
2789
  hunkId: anchor.hunkId,
2495
2790
  path: anchor.path,
2496
2791
  side: anchor.side,
2497
- line: anchor.line
2792
+ line: anchor.line,
2793
+ ...endLine !== void 0 ? { endLine } : {}
2498
2794
  };
2499
2795
  }
2500
2796
  if (anchor.kind === "file") return typeof anchor.path === "string" ? {
@@ -2503,12 +2799,25 @@ function parseAnchor(value) {
2503
2799
  } : null;
2504
2800
  return anchor.kind === "changeset" ? { kind: "changeset" } : null;
2505
2801
  }
2802
+ /** Dropped when malformed — the thread is still fine without it. */
2803
+ function normalizeAnchored(value) {
2804
+ if (typeof value !== "object" || value === null) return null;
2805
+ const a = value;
2806
+ if (typeof a.start !== "number" || typeof a.end !== "number" || typeof a.text !== "string") return null;
2807
+ if (!Number.isInteger(a.start) || !Number.isInteger(a.end) || a.start < 0 || a.end < a.start) return null;
2808
+ return {
2809
+ start: a.start,
2810
+ end: a.end,
2811
+ text: a.text
2812
+ };
2813
+ }
2506
2814
  function normalizeThread(value, now) {
2507
2815
  if (typeof value !== "object" || value === null) return null;
2508
2816
  const t = value;
2509
2817
  if (typeof t.id !== "string" || !STATES.includes(t.state)) return null;
2510
2818
  const anchor = parseAnchor(t.anchor);
2511
2819
  if (!anchor) return null;
2820
+ const anchored = normalizeAnchored(t.anchored);
2512
2821
  if (!Array.isArray(t.messages)) return null;
2513
2822
  const messages = [];
2514
2823
  for (const m of t.messages) {
@@ -2530,8 +2839,10 @@ function normalizeThread(value, now) {
2530
2839
  state: t.state,
2531
2840
  ...THREAD_INTENTS.includes(t.intent) ? { intent: t.intent } : {},
2532
2841
  codeContext: typeof t.codeContext === "string" ? t.codeContext : null,
2842
+ ...anchored ? { anchored } : {},
2533
2843
  codeChanged: t.codeChanged === true,
2534
2844
  ...t.unanswered === true ? { unanswered: true } : {},
2845
+ ...t.awaitingFollowUp === true && t.unanswered !== true ? { awaitingFollowUp: true } : {},
2535
2846
  ...t.closingNote === true ? { closingNote: true } : {},
2536
2847
  ...typeof t.sentAt === "string" ? { sentAt: t.sentAt } : {},
2537
2848
  messages,
@@ -3007,7 +3318,8 @@ function createApp(ctx, store, review, queue) {
3007
3318
  repo: resolve(ctx.root),
3008
3319
  app: "diffo",
3009
3320
  version: VERSION,
3010
- pid: process.pid
3321
+ pid: process.pid,
3322
+ ...SRC_STAMP === null ? {} : { srcStamp: SRC_STAMP }
3011
3323
  }));
3012
3324
  app.post("/api/shutdown", (c) => {
3013
3325
  setImmediate(() => ctx.onShutdownRequest?.());
@@ -3016,6 +3328,22 @@ function createApp(ctx, store, review, queue) {
3016
3328
  note: "shutting down"
3017
3329
  });
3018
3330
  });
3331
+ const THEME_VALUES = /* @__PURE__ */ new Set([
3332
+ "system",
3333
+ "light",
3334
+ "dark"
3335
+ ]);
3336
+ app.get("/api/settings", (c) => {
3337
+ const theme = ctx.uiSettings?.get("theme") ?? null;
3338
+ return c.json({ theme: theme !== null && THEME_VALUES.has(theme) ? theme : null });
3339
+ });
3340
+ app.put("/api/settings", async (c) => {
3341
+ if (!ctx.uiSettings) return c.json({ error: "settings unavailable" }, 503);
3342
+ const theme = (await c.req.json().catch(() => null))?.theme;
3343
+ if (typeof theme !== "string" || !THEME_VALUES.has(theme)) return c.json({ error: "need {theme: \"system\" | \"light\" | \"dark\"}" }, 400);
3344
+ ctx.uiSettings.set("theme", theme);
3345
+ return c.json({ ok: true });
3346
+ });
3019
3347
  const repoInfo = () => store?.get().repo ?? {
3020
3348
  path: resolve(ctx.root),
3021
3349
  name: "",
@@ -3065,14 +3393,14 @@ function createApp(ctx, store, review, queue) {
3065
3393
  const file = typeof body?.file === "string" && body.file.trim() !== "" ? body.file.trim() : null;
3066
3394
  const line = typeof body?.line === "number" && Number.isInteger(body.line) && body.line >= 1 ? body.line : null;
3067
3395
  const anchor = agentAnchor(file, line);
3068
- const codeContext = anchor.kind === "hunk" && store ? snapshotHunk(store.get(), anchor.hunkId) : null;
3069
- return c.json(review.createThread(anchor, text, codeContext, void 0, "agent"));
3396
+ const capture = store ? captureAnchor(store.get(), anchor) : null;
3397
+ return c.json(review.createThread(anchor, text, capture, void 0, "agent"));
3070
3398
  }
3071
3399
  const anchor = parseAnchor(body?.anchor);
3072
3400
  if (!anchor) return c.json({ error: "need {anchor, text}" }, 400);
3073
3401
  const intent = THREAD_INTENTS.includes(body?.intent) ? body.intent : void 0;
3074
- const codeContext = anchor.kind === "hunk" && store ? snapshotHunk(store.get(), anchor.hunkId) : null;
3075
- return c.json(review.createThread(anchor, text, codeContext, intent));
3402
+ const capture = store ? captureAnchor(store.get(), anchor) : null;
3403
+ return c.json(review.createThread(anchor, text, capture, intent));
3076
3404
  });
3077
3405
  app.post("/api/review/threads/:id/messages", async (c) => {
3078
3406
  if (!review) return c.json({ error: "review unavailable" }, 503);
@@ -3083,9 +3411,10 @@ function createApp(ctx, store, review, queue) {
3083
3411
  const id = c.req.param("id");
3084
3412
  if (!review.get().threads.some((t) => t.id === id)) return c.json({ error: "no such thread" }, 404);
3085
3413
  queue?.noteSession(parseSessionPid(c.req.header("x-diffo-session-pid")));
3086
- const waitedMs = queue?.agentReplied(id) ?? null;
3414
+ const more = body?.more === true;
3415
+ const waitedMs = queue?.agentReplied(id, more) ?? null;
3087
3416
  const seenThroughMs = waitedMs === null ? void 0 : Date.now() - waitedMs;
3088
- const thread = review.addMessage(id, "agent", text, false, seenThroughMs);
3417
+ const thread = review.addMessage(id, "agent", text, false, seenThroughMs, more);
3089
3418
  if (!thread) return c.json({ error: "no such thread" }, 404);
3090
3419
  if (waitedMs !== null) review.annotateAgentReplies([thread.id], waitedMs);
3091
3420
  return c.json({
@@ -3117,8 +3446,11 @@ function createApp(ctx, store, review, queue) {
3117
3446
  });
3118
3447
  app.delete("/api/review/threads", (c) => {
3119
3448
  if (!review) return c.json({ error: "review unavailable" }, 503);
3449
+ const before = review.get();
3450
+ const hadRound = before.threads.length > 0 || before.lastFinish !== void 0;
3120
3451
  const removed = review.reset();
3121
3452
  for (const id of removed) queue?.drop(id);
3453
+ if (hadRound && (store?.get().files.length ?? 0) > 0) queue?.enqueueCleared();
3122
3454
  return c.json({ removed: removed.length });
3123
3455
  });
3124
3456
  app.delete("/api/review/landed", (c) => {
@@ -3157,7 +3489,6 @@ function createApp(ctx, store, review, queue) {
3157
3489
  const n = Number(value);
3158
3490
  return Number.isFinite(n) ? n : void 0;
3159
3491
  };
3160
- const verdict = REVIEW_VERDICTS.includes(src.verdict) ? src.verdict : void 0;
3161
3492
  const note = typeof src.note === "string" && src.note.trim() !== "" ? src.note.trim().slice(0, 4e3) : void 0;
3162
3493
  const fileList = (value) => Array.isArray(value) ? value.filter((f) => typeof f === "string") : void 0;
3163
3494
  const changedFiles = fileList(src.changedFiles);
@@ -3170,7 +3501,6 @@ function createApp(ctx, store, review, queue) {
3170
3501
  skippedFiles: fileList(src.skippedFiles) ?? [],
3171
3502
  ...changedFiles !== void 0 ? { changedFiles } : {},
3172
3503
  ...commentedUnread !== void 0 ? { commentedUnread } : {},
3173
- ...verdict !== void 0 ? { verdict } : {},
3174
3504
  ...note !== void 0 ? { note } : {}
3175
3505
  };
3176
3506
  };
@@ -3263,20 +3593,37 @@ function createApp(ctx, store, review, queue) {
3263
3593
  });
3264
3594
  const pollPayload = (snapshot) => {
3265
3595
  const threads = review?.get().threads ?? [];
3596
+ const protocol = queue?.needsFullProtocol() === false ? "compact" : void 0;
3597
+ if (snapshot.kind === "cleared") return {
3598
+ status: "feedback",
3599
+ kind: "cleared",
3600
+ threadIds: [],
3601
+ prompt: buildClearedPrompt({
3602
+ repo: repoInfo(),
3603
+ changeset: store?.get() ?? null
3604
+ })
3605
+ };
3266
3606
  if (snapshot.kind === "finish") {
3267
3607
  const batch = activeThreads(threads);
3268
3608
  return {
3269
3609
  status: "feedback",
3270
3610
  kind: "finish",
3271
- threadIds: batch.filter((t) => t.state === "sent").map((t) => t.id),
3611
+ threadIds: batch.filter((t) => t.state === "sent" && !answeredByAgent(t)).map((t) => t.id),
3272
3612
  prompt: buildFinishPrompt(batch, {
3273
3613
  repo: repoInfo(),
3274
- changeset: store?.get() ?? null
3614
+ changeset: store?.get() ?? null,
3615
+ protocol
3275
3616
  }, snapshot.coverage)
3276
3617
  };
3277
3618
  }
3278
3619
  const delivered = threads.filter((t) => snapshot.threadIds.includes(t.id));
3279
- const prompt = delivered.length === 1 ? buildThreadPrompt(delivered[0], promptCtx(delivered[0].id)) : buildCoalescedPrompt(delivered, promptCtx(...snapshot.threadIds));
3620
+ const prompt = delivered.length === 1 ? buildThreadPrompt(delivered[0], {
3621
+ ...promptCtx(delivered[0].id),
3622
+ protocol
3623
+ }) : buildCoalescedPrompt(delivered, {
3624
+ ...promptCtx(...snapshot.threadIds),
3625
+ protocol
3626
+ });
3280
3627
  return {
3281
3628
  status: "feedback",
3282
3629
  kind: "threads",
@@ -3354,6 +3701,7 @@ function createApp(ctx, store, review, queue) {
3354
3701
  await s.write(JSON.stringify(payload));
3355
3702
  if (aborted) return;
3356
3703
  queue.confirm(snapshot, payload.threadIds);
3704
+ if (payload.threadIds.length > 0) queue.markProtocolSent();
3357
3705
  review.markDelivered(payload.threadIds);
3358
3706
  review.clearUnanswered(payload.threadIds);
3359
3707
  if (snapshot.kind === "finish") review.markFinishCollected();
@@ -3512,6 +3860,8 @@ function markDevIndex(html, isDev) {
3512
3860
  return html.replace(/<title>[^<]*<\/title>/, "<title>diffo-dev</title>").replace(/<\/head>/, " <meta name=\"diffo-env\" content=\"development\" />\n </head>");
3513
3861
  }
3514
3862
  function rehydrateQueue(review, queue) {
3863
+ const promised = review.get().threads.filter((t) => t.awaitingFollowUp === true).map((t) => t.id);
3864
+ if (promised.length > 0) review.markUnanswered(promised);
3515
3865
  const state = review.get();
3516
3866
  queue.enqueueThreads(undeliveredThreadIds(state.threads));
3517
3867
  const finish = state.lastFinish;
@@ -3549,8 +3899,8 @@ function startServer(options) {
3549
3899
  isAncestor: (ancestor, descendant) => isAncestor(options.root, ancestor, descendant),
3550
3900
  subject: (sha) => getCommitSubject(options.root, sha)
3551
3901
  };
3552
- const checkLanded = (hasFiles) => {
3553
- if (maintainLanded(review, hasFiles, landedGit) === "stamped") {
3902
+ const checkLanded = (hasFiles, hunkIds) => {
3903
+ if (maintainLanded(review, hasFiles, hunkIds, landedGit) === "stamped") {
3554
3904
  const landed = review.get().landed;
3555
3905
  if (landed) log(`changeset landed in ${landed.sha.slice(0, 7)} — offering a fresh review`);
3556
3906
  }
@@ -3561,11 +3911,13 @@ function startServer(options) {
3561
3911
  review.rescope(changeset.repo.branch);
3562
3912
  queue.rescope(changeset.repo.branch);
3563
3913
  if (review.scope.branch !== before) log(`branch ${before || "detached"} → ${changeset.repo.branch || "detached"}`);
3564
- review.reconcile(new Set(changeset.files.flatMap((f) => f.hunks.map((h) => h.id))));
3565
- checkLanded(changeset.files.length > 0);
3914
+ const hunkIds = new Set(changeset.files.flatMap((f) => f.hunks.map((h) => h.id)));
3915
+ review.reconcile(hunkIds);
3916
+ checkLanded(changeset.files.length > 0, hunkIds);
3566
3917
  });
3567
- review.reconcile(new Set(store.get().files.flatMap((f) => f.hunks.map((h) => h.id))));
3568
- checkLanded(store.get().files.length > 0);
3918
+ const startupHunkIds = new Set(store.get().files.flatMap((f) => f.hunks.map((h) => h.id)));
3919
+ review.reconcile(startupHunkIds);
3920
+ checkLanded(store.get().files.length > 0, startupHunkIds);
3569
3921
  const idleTimeoutMs = resolveIdleTimeoutMs(process.env);
3570
3922
  let idle;
3571
3923
  if (idleTimeoutMs !== null) {
@@ -3581,7 +3933,11 @@ function startServer(options) {
3581
3933
  const app = createApp({
3582
3934
  ...options,
3583
3935
  idle,
3584
- onShutdownRequest: options.onShutdownRequest ?? (() => process.exit(0))
3936
+ onShutdownRequest: options.onShutdownRequest ?? (() => process.exit(0)),
3937
+ uiSettings: {
3938
+ get: (key) => db.getUiSetting(key),
3939
+ set: (key, value) => db.setUiSetting(key, value)
3940
+ }
3585
3941
  }, store, review, queue);
3586
3942
  const server = serve({
3587
3943
  fetch: app.fetch,
@@ -3635,9 +3991,17 @@ function reviewUrl(port) {
3635
3991
  //#endregion
3636
3992
  //#region src/serverLifecycle.ts
3637
3993
  const MAX_LOG_BYTES = 2097152;
3638
- function assessRunningServer(health, repoPath, version) {
3994
+ /**
3995
+ * `srcStamp` is the dev-build refinement of the version check: a dev server's
3996
+ * version never changes across source edits, so an expected stamp (non-null)
3997
+ * makes a server started from other source a 'replace' too. Callers that must
3998
+ * not churn a running review (poll, reply) simply pass none.
3999
+ */
4000
+ function assessRunningServer(health, repoPath, version, srcStamp = null) {
3639
4001
  if (health?.ok !== true || health.repo !== repoPath) return "foreign";
3640
- return health.app === "diffo" && health.version === version ? "reuse" : "replace";
4002
+ if (health.app !== "diffo" || health.version !== version) return "replace";
4003
+ if (srcStamp !== null && health.srcStamp !== srcStamp) return "replace";
4004
+ return "reuse";
3641
4005
  }
3642
4006
  /**
3643
4007
  * argv for the daemon: the same runtime flags executing this CLI, the same entry
@@ -3711,17 +4075,17 @@ async function retireServer(port, pid, reportedPid = null) {
3711
4075
  * their own copy and write the whole blob back, so the slower watcher erases the
3712
4076
  * reviewer's threads, and clearing the row would hand the claim to a newcomer.
3713
4077
  */
3714
- async function settleExistingServer(db, repoPath, version, onStatus = () => {}) {
4078
+ async function settleExistingServer(db, repoPath, version, onStatus = () => {}, srcStamp = null) {
3715
4079
  const record = db.getServer(repoPath);
3716
4080
  if (!record) return null;
3717
4081
  const health = await fetchHealth(record.port);
3718
- const verdict = assessRunningServer(health, repoPath, version);
4082
+ const verdict = assessRunningServer(health, repoPath, version, srcStamp);
3719
4083
  if (verdict === "reuse") return {
3720
4084
  port: record.port,
3721
4085
  pid: health?.pid ?? record.pid ?? null
3722
4086
  };
3723
4087
  if (verdict === "replace") {
3724
- onStatus(`replacing the diffo server from another build (${health?.version ?? "pre-handshake"} → ${version})`);
4088
+ onStatus(health?.version === version ? `replacing the dev server — the checkout's source changed since it started` : `replacing the diffo server from another build (${health?.version ?? "pre-handshake"} → ${version})`);
3725
4089
  if (!await retireServer(record.port, record.pid ?? null, health?.pid ?? null)) throw new Error(`a diffo server from another build is stuck on port ${record.port}` + (record.pid ? ` (pid ${record.pid})` : "") + ` and would not step aside — stop it, then re-run`);
3726
4090
  }
3727
4091
  db.removeServer(repoPath, record.port);
@@ -3781,7 +4145,7 @@ async function ensureServer(opts, deps = {}) {
3781
4145
  const status = opts.onStatus ?? (() => {});
3782
4146
  const db = new DiffoDb(opts.dbPath);
3783
4147
  try {
3784
- const existing = await settleExistingServer(db, opts.repoPath, opts.version, status);
4148
+ const existing = await settleExistingServer(db, opts.repoPath, opts.version, status, opts.srcStamp ?? null);
3785
4149
  if (existing) return existing.port;
3786
4150
  status("no diffo server for this repo — starting one");
3787
4151
  mkdirSync(dirname(opts.logPath), { recursive: true });
@@ -3792,7 +4156,7 @@ async function ensureServer(opts, deps = {}) {
3792
4156
  if (!await waitUntil(async () => {
3793
4157
  const row = db.getServer(opts.repoPath);
3794
4158
  if (!row) return false;
3795
- if (assessRunningServer(await fetchHealth(row.port, 500), opts.repoPath, opts.version) !== "reuse") return false;
4159
+ if (assessRunningServer(await fetchHealth(row.port, 500), opts.repoPath, opts.version, opts.srcStamp ?? null) !== "reuse") return false;
3796
4160
  port = row.port;
3797
4161
  return true;
3798
4162
  }, opts.spawnTimeoutMs ?? 8e3) || port === null) {
@@ -4201,10 +4565,15 @@ if (command.kind === "setup") {
4201
4565
  const root = findRepoRoot(process.cwd());
4202
4566
  if (!root) fail("not inside a git repository");
4203
4567
  const repoPath = resolve(root);
4568
+ /**
4569
+ * Only the open path settles with the source stamp: opening a review is the
4570
+ * moment "run the current source" is the promise, while poll/reply/comment must
4571
+ * never restart the server under a review in progress.
4572
+ */
4204
4573
  async function discoverServer() {
4205
4574
  const db = new DiffoDb();
4206
4575
  try {
4207
- return await settleExistingServer(db, repoPath, VERSION, (line) => process.stderr.write(`diffo: ${line}\n`));
4576
+ return await settleExistingServer(db, repoPath, VERSION, (line) => process.stderr.write(`diffo: ${line}\n`), SRC_STAMP);
4208
4577
  } catch (err) {
4209
4578
  fail(err.message);
4210
4579
  } finally {
@@ -4218,7 +4587,7 @@ function sessionHeaders() {
4218
4587
  ...pid === null ? {} : { "x-diffo-session-pid": String(pid) }
4219
4588
  };
4220
4589
  }
4221
- async function requireServer(port, base) {
4590
+ async function requireServer(port, base, srcStamp = null) {
4222
4591
  try {
4223
4592
  return await ensureServer({
4224
4593
  repoPath,
@@ -4228,6 +4597,7 @@ async function requireServer(port, base) {
4228
4597
  execArgv: process.execArgv,
4229
4598
  port,
4230
4599
  base,
4600
+ srcStamp,
4231
4601
  logPath: process.env.DIFFO_SERVER_LOG || defaultLogPath(repoPath),
4232
4602
  onStatus: (line) => process.stderr.write(`diffo: ${line}\n`)
4233
4603
  });
@@ -4343,7 +4713,7 @@ if (command.kind === "status") {
4343
4713
  db.close();
4344
4714
  }
4345
4715
  const health = record ? await fetchHealth(record.port) : null;
4346
- const verdict = record ? assessRunningServer(health, repoPath, VERSION) : "foreign";
4716
+ const verdict = record ? assessRunningServer(health, repoPath, VERSION, SRC_STAMP) : "foreign";
4347
4717
  if (!record || verdict === "foreign") {
4348
4718
  if (command.json) console.log(JSON.stringify({ running: false }));
4349
4719
  else console.log(record ? "no diffo server is watching this repo (its last registration is stale)" : "no diffo server is watching this repo");
@@ -4362,7 +4732,7 @@ if (command.kind === "status") {
4362
4732
  process.exit(0);
4363
4733
  }
4364
4734
  await printChangesetSummary(record.port);
4365
- console.log(`server: port ${record.port}${pid ? ` · pid ${pid}` : ""} · v${health?.version ?? "pre-handshake"}` + (verdict === "replace" ? ` (this CLI is v${VERSION} — the next \`diffo\` replaces it)` : ""));
4735
+ console.log(`server: port ${record.port}${pid ? ` · pid ${pid}` : ""} · v${health?.version ?? "pre-handshake"}` + (verdict === "replace" ? health?.version === VERSION ? ` (the checkout's source changed — the next \`diffo\` replaces it)` : ` (this CLI is v${VERSION} — the next \`diffo\` replaces it)` : ""));
4366
4736
  console.log(`→ ${url}`);
4367
4737
  process.exit(0);
4368
4738
  }
@@ -4390,7 +4760,7 @@ if (command.kind === "stop") {
4390
4760
  }
4391
4761
  if (command.kind === "poll") {
4392
4762
  const port = await requireServer();
4393
- process.stderr.write("diffo: waiting for the reviewer — keep this process attended: a tracked\nbackground task or the foreground, never detached.\nIf it is killed or times out, just re-run it: what the reviewer sent is\nheld in the review, so it outlives this poll and the server both.\n");
4763
+ 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");
4394
4764
  try {
4395
4765
  const res = await fetch(apiUrl(port, "/api/agent/poll"), { headers: sessionHeaders() });
4396
4766
  if (!res.ok) fail(`poll failed (${res.status})`);
@@ -4413,7 +4783,8 @@ if (command.kind === "reply") {
4413
4783
  if (!message) fail("reply needs a message — pass --message \"<text>\" or pipe it on stdin");
4414
4784
  const { status, body } = await postJson(await requireServer(), `/api/review/threads/${encodeURIComponent(command.threadId)}/messages`, {
4415
4785
  author: "agent",
4416
- text: message
4786
+ text: message,
4787
+ ...command.more ? { more: true } : {}
4417
4788
  });
4418
4789
  if (status === 404) fail(`no thread with id '${command.threadId}'`);
4419
4790
  if (status !== 200) fail(`reply failed (${status})`);
@@ -4422,7 +4793,7 @@ if (command.kind === "reply") {
4422
4793
  ok: true,
4423
4794
  threadId: thread.id,
4424
4795
  state: thread.state,
4425
- next_step: ACK_NEXT_STEP.reply
4796
+ next_step: command.more ? ACK_NEXT_STEP.replyMore : ACK_NEXT_STEP.reply
4426
4797
  }));
4427
4798
  process.exit(0);
4428
4799
  }
@@ -4472,6 +4843,32 @@ async function printAgentNextStep(port) {
4472
4843
  if (nudge) console.log(`first: ${nudge}`);
4473
4844
  console.log("next: run `diffo poll` to receive the reviewer's feedback (`diffo help agent` prints the whole loop)");
4474
4845
  }
4846
+ /**
4847
+ * Dev builds serve the client from the checkout's `dist/client`, which nothing
4848
+ * rebuilds on its own — so opening a review after a source edit showed last
4849
+ * build's UI over this build's server. Rebuild it here, before the server is
4850
+ * settled: the server reads client files from disk per request, so even a
4851
+ * reused server serves the fresh bundle. `.dev-stamp` (written after a
4852
+ * successful build, into a directory vite has just emptied) makes the no-change
4853
+ * open free. The foreground path is exempt — that is `pnpm dev:server`'s tsx
4854
+ * watch loop, where the client is vite's dev server, not this bundle.
4855
+ */
4856
+ function rebuildDevClient(foreground) {
4857
+ if (SRC_STAMP === null || foreground) return;
4858
+ const stampFile = join(CHECKOUT_ROOT, "dist", "client", ".dev-stamp");
4859
+ try {
4860
+ if (readFileSync(stampFile, "utf-8").trim() === SRC_STAMP) return;
4861
+ } catch {}
4862
+ process.stderr.write(`diffo: dev checkout changed — rebuilding the client bundle\n`);
4863
+ const vite = join(CHECKOUT_ROOT, "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite");
4864
+ const built = spawnSync(vite, ["build"], {
4865
+ cwd: CHECKOUT_ROOT,
4866
+ encoding: "utf-8"
4867
+ });
4868
+ if (built.status !== 0) fail(`the client rebuild failed — fix the build, then re-run\n${(built.stderr || built.stdout || String(built.error ?? "")).trim().slice(-2e3)}`);
4869
+ writeFileSync(stampFile, `${SRC_STAMP}\n`);
4870
+ }
4871
+ rebuildDevClient(command.foreground);
4475
4872
  const existing = await discoverServer();
4476
4873
  const takeOverPort = existing !== null && command.foreground && command.port !== void 0 && command.port !== existing.port ? command.port : null;
4477
4874
  if (existing !== null && takeOverPort === null) {
@@ -4496,7 +4893,7 @@ if (existing !== null && takeOverPort !== null) {
4496
4893
  db.close();
4497
4894
  }
4498
4895
  if (!command.foreground) {
4499
- const daemonPort = await requireServer(command.port, command.spec.kind === "branch" ? command.spec.base : void 0);
4896
+ const daemonPort = await requireServer(command.port, command.spec.kind === "branch" ? command.spec.base : void 0, SRC_STAMP);
4500
4897
  const url = reviewUrl(daemonPort);
4501
4898
  await printChangesetSummary(daemonPort);
4502
4899
  console.log(`→ ${url}`);