@diffohq/diffo 0.1.0 → 0.3.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 (143) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +270 -51
  3. package/dist/client/assets/{abnfDiagram-VCTEODGH-DNZRM2Rn.js → abnfDiagram-O67JEVCF-DdDBSVZ4.js} +1 -1
  4. package/dist/client/assets/{arc-s7daVIwl.js → arc-BgNb6rn9.js} +1 -1
  5. package/dist/client/assets/architecture-7GRP2DOG-CYv-P09J.js +1 -0
  6. package/dist/client/assets/architectureDiagram-NJMV4G6O-HNLU6Z_V.js +36 -0
  7. package/dist/client/assets/{blockDiagram-NRAW4CY4-DqGD_dcu.js → blockDiagram-BEXU5L5S-DO434Dqm.js} +16 -5
  8. package/dist/client/assets/c4Diagram-YGBWAQC7-CkhXDWcc.js +38 -0
  9. package/dist/client/assets/channel-eN698CVO.js +1 -0
  10. package/dist/client/assets/chunk-3FUC2YCW-DTfKImVL.js +2 -0
  11. package/dist/client/assets/{chunk-OBVCFTLP-DOl2OrIN.js → chunk-5DYCD2WN-9Pj_Xuzv.js} +1 -1
  12. package/dist/client/assets/{chunk-PWAF6VOD-BHpo38Be.js → chunk-742MDFTN-DRQNNbBo.js} +1 -1
  13. package/dist/client/assets/chunk-7INBJB4K-BqnHF3P0.js +62 -0
  14. package/dist/client/assets/chunk-7M6MHVWA-NTwtPxj_.js +213 -0
  15. package/dist/client/assets/chunk-7PRAP22T-reFtaiUY.js +1 -0
  16. package/dist/client/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  17. package/dist/client/assets/{chunk-FOHPRMQF-DHwB1DNv.js → chunk-FOHPRMQF-DzxwRta7.js} +9 -9
  18. package/dist/client/assets/{chunk-SVP7TREG-CSTzyRmo.js → chunk-GTNCS2PH-BbSQqqTd.js} +1 -1
  19. package/dist/client/assets/{chunk-F27PBJKO-DRqkoKNg.js → chunk-GWA4HPMP-B3OAiKhC.js} +1 -1
  20. package/dist/client/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  21. package/dist/client/assets/chunk-MBY4JIJT-CNIWhzdY.js +72 -0
  22. package/dist/client/assets/chunk-NETBCI7D-Cu2GMg5A.js +1 -0
  23. package/dist/client/assets/chunk-O7XYJQB3-C616frX3.js +126 -0
  24. package/dist/client/assets/chunk-UA2S7LBM-_7jUrSu1.js +1 -0
  25. package/dist/client/assets/{chunk-POPQ4Y6H-Dw_D6UWF.js → chunk-WEXAMYUT--WmuTjt8.js} +1 -1
  26. package/dist/client/assets/{chunk-XXDRQBXY-BHwLEQfa.js → chunk-XXDRQBXY-CH68Fn6G.js} +1 -1
  27. package/dist/client/assets/chunk-Z7XXMR3K-CMGWkbdd.js +10 -0
  28. package/dist/client/assets/{chunk-75Z2AOVW-HRLiZN7c.js → chunk-ZIGJFQKS-Bupo8UEA.js} +2 -2
  29. package/dist/client/assets/classDiagram-v2-NBCMYWYE-BlYlsN2U.js +217 -0
  30. package/dist/client/assets/{core-B9wYK4yO.js → core-BuHBGQx-.js} +2 -2
  31. package/dist/client/assets/cose-bilkent-JH36ORCC-BtFdPq63.js +1 -0
  32. package/dist/client/assets/cynefin-OW5HDTMX-DQ0j2-fq.js +1 -0
  33. package/dist/client/assets/cynefinDiagram-VND7K2PF-DnctQE5u.js +62 -0
  34. package/dist/client/assets/cytoscape.esm-Yq6u8L66.js +321 -0
  35. package/dist/client/assets/dagre-6A5THRUB-u940AeXp.js +4 -0
  36. package/dist/client/assets/diagram-22UHCM2B-B1jzZKtP.js +200 -0
  37. package/dist/client/assets/{diagram-VX7I27RA-BWGYUOwn.js → diagram-3UASUU5V-BY_6wxmf.js} +2 -2
  38. package/dist/client/assets/{diagram-VSXAHHWV-DRQH2hZ-.js → diagram-ATOU4E4O-CTsYtnJJ.js} +3 -3
  39. package/dist/client/assets/{diagram-S7CK7UJ4-BwGXFSb-.js → diagram-CDSNMT55-DuEJO9EQ.js} +3 -3
  40. package/dist/client/assets/{diagram-Z3DM3KII-C2ETOHnp.js → diagram-MLGK6HIB-CZ10U2Mk.js} +1 -1
  41. package/dist/client/assets/diagram-MPIPVDR6-CkO6cJlF.js +41 -0
  42. package/dist/client/assets/dist-DFkgQY1o.js +142 -0
  43. package/dist/client/assets/{dist-C42IZvNn.js → dist-DkqXbsqN.js} +1 -1
  44. package/dist/client/assets/{ebnfDiagram-PWID7BFC-CkcgAzr3.js → ebnfDiagram-ZINNZB2B-BJtnMhOp.js} +1 -1
  45. package/dist/client/assets/elk-276RUBZZ-CShSerV4.js +27 -0
  46. package/dist/client/assets/{engine-oniguruma-D9HVl69n.js → engine-oniguruma-DDtqknzG.js} +1 -1
  47. package/dist/client/assets/{erDiagram-SSCWMZ5O-Do6h2U1E.js → erDiagram-OPXOYQCR-2VvQxgaY.js} +15 -15
  48. package/dist/client/assets/eventmodeling-NTZA5JFV-Ur4Uzv5y.js +1 -0
  49. package/dist/client/assets/flowDiagram-KWPJA3E3-BQS1w4s1.js +1 -0
  50. package/dist/client/assets/{ganttDiagram-EL5Y4UJY-cC8gUuqf.js → ganttDiagram-FUAMR5RP-hQJ3ga2L.js} +2 -2
  51. package/dist/client/assets/gitGraph-4MIJSDKK-CVt38-tW.js +1 -0
  52. package/dist/client/assets/{gitGraphDiagram-WWUBYQGX-Oj6POMRs.js → gitGraphDiagram-X574FWY7-X6_FAtp_.js} +4 -4
  53. package/dist/client/assets/index-t7j89PJp.js +91 -0
  54. package/dist/client/assets/info-A6RAGUB7-CsIS2T8y.js +1 -0
  55. package/dist/client/assets/infoDiagram-VRGFBTTK-CNesth4F.js +2 -0
  56. package/dist/client/assets/{ishikawaDiagram-5VMMS53U-BB3HsPau.js → ishikawaDiagram-OU5B5YK6-C3uowhy1.js} +2 -2
  57. package/dist/client/assets/{journeyDiagram-EYS64GPL-Cj8ezyBN.js → journeyDiagram-ZHPQQLJL-Bti7zdNB.js} +4 -4
  58. package/dist/client/assets/{kanban-definition-3QL26DDD-D3dbth5Z.js → kanban-definition-PNTS6WVX-Co13mJCW.js} +10 -10
  59. package/dist/client/assets/{katex-CXMH3UgJ.js → katex-ZlcWpGUi.js} +2 -2
  60. package/dist/client/assets/{line-tg3RVOrp.js → line-CodMwzVg.js} +1 -1
  61. package/dist/client/assets/linear-D9CKVpWI.js +1 -0
  62. package/dist/client/assets/{mermaid-parser.core-DKC3l_TU.js → mermaid-parser.core-OY76TKUN.js} +3 -3
  63. package/dist/client/assets/mermaid.core-DWE2Tn2R.js +44 -0
  64. package/dist/client/assets/{mindmap-definition-FBJOCRG2-CSUjR6w9.js → mindmap-definition-NLK3R4M7-DJ3MEvZg.js} +25 -25
  65. package/dist/client/assets/packet-AYTQ26CC-CW1j_hFQ.js +1 -0
  66. package/dist/client/assets/{pegDiagram-XKGWAZYB-BhLsv72R.js → pegDiagram-GJSIUBJH-hOvLvBiF.js} +1 -1
  67. package/dist/client/assets/pie-WAS4IAKB-D1KPtuKA.js +1 -0
  68. package/dist/client/assets/{pieDiagram-E7YTZNPT-Ci9YzLDy.js → pieDiagram-5QR66LMP-CJN1M2lG.js} +2 -2
  69. package/dist/client/assets/{quadrantDiagram-AXDQQJYC-D7D5FwMe.js → quadrantDiagram-O4NWA36T-DOysLQ6R.js} +3 -3
  70. package/dist/client/assets/radar-RG4KPBEZ-B7XCBnIS.js +1 -0
  71. package/dist/client/assets/railroad-74A4TZTK-0nmMIDEw.js +1 -0
  72. package/dist/client/assets/railroad-abnf-HS5TGJTU-DJU_abQP.js +1 -0
  73. package/dist/client/assets/railroad-ebnf-LZEXJU2U-_TwNbNw6.js +1 -0
  74. package/dist/client/assets/railroad-peg-WCYAUIDC-CAYsrf-p.js +1 -0
  75. package/dist/client/assets/{railroadDiagram-O6MQD6OU-ByO5lwcN.js → railroadDiagram-XR7U4H2S-BXbeU-WV.js} +1 -1
  76. package/dist/client/assets/{requirementDiagram-EFPCY7ZU-UAUf-JbW.js → requirementDiagram-PLB6GJNP-D3G2HYv-.js} +11 -11
  77. package/dist/client/assets/sankeyDiagram-IPEJSGJF-CFSa-Gk4.js +40 -0
  78. package/dist/client/assets/sequenceDiagram-PO4LG4MO-BvmuQTlz.js +169 -0
  79. package/dist/client/assets/{src-DQRzrNpT.js → src-CplSIRuf.js} +1 -1
  80. package/dist/client/assets/stateDiagram-v2-GCMORJYK-C_ceCfHy.js +291 -0
  81. package/dist/client/assets/swimlanes-2SLR337P-D-n44lEC.js +1 -0
  82. package/dist/client/assets/swimlanesDiagram-TC7HE7FX-DrlgJMk7.js +8 -0
  83. package/dist/client/assets/timeline-definition-EJHVYXUP-DKYNs1Uj.js +120 -0
  84. package/dist/client/assets/treeView-Q6P3EWNA-COW2UiIS.js +1 -0
  85. package/dist/client/assets/treemap-WGGIJYW6-DJOuJZmF.js +1 -0
  86. package/dist/client/assets/usecaseDiagram-POWQR4AR-BTmpbeJZ.js +328 -0
  87. package/dist/client/assets/vennDiagram-UO4OBE2U-C37AKEaJ.js +34 -0
  88. package/dist/client/assets/wardley-WFR3VGLG-CpVTtNOC.js +1 -0
  89. package/dist/client/assets/{wardleyDiagram-VM6X3IG4-D9KaADBZ.js → wardleyDiagram-VNRHLVJA-ja-hiiVC.js} +3 -3
  90. package/dist/client/assets/{xychartDiagram-S5SC5T6Z-CnrJ3a0c.js → xychartDiagram-PMCCYNJV-BsDoacSf.js} +3 -3
  91. package/dist/client/index.html +1 -1
  92. package/package.json +5 -4
  93. package/plugin.json +1 -1
  94. package/skills/diffo/SKILL.md +61 -124
  95. package/dist/client/assets/architecture-7GRP2DOG-DzM3CzOq.js +0 -1
  96. package/dist/client/assets/architectureDiagram-5GKGNRK7-DsxTOcye.js +0 -36
  97. package/dist/client/assets/c4Diagram-UCG6FXSJ-CMuJod-h.js +0 -38
  98. package/dist/client/assets/channel-Cftoq4wT.js +0 -1
  99. package/dist/client/assets/chunk-DU6HZSFF-D6gjp2l1.js +0 -125
  100. package/dist/client/assets/chunk-G27WJ6UU-QUFgr4za.js +0 -231
  101. package/dist/client/assets/chunk-GMAD6QVW-DG-OmEbV.js +0 -72
  102. package/dist/client/assets/chunk-JQ64N6SF-CPdpoyCu.js +0 -1
  103. package/dist/client/assets/chunk-LCL6LL3I-DoHJ4n3V.js +0 -206
  104. package/dist/client/assets/chunk-P2QGCYS3-CBWu7ViW.js +0 -1
  105. package/dist/client/assets/chunk-R7TYR2AO-DgC6z76h.js +0 -10
  106. package/dist/client/assets/chunk-RHFEMEQ7-BabrfzF-.js +0 -168
  107. package/dist/client/assets/chunk-TEH6E4GO-M22Cr-Sd.js +0 -62
  108. package/dist/client/assets/classDiagram-DTDB5LWJ-uMtyQjAg.js +0 -1
  109. package/dist/client/assets/classDiagram-v2-JRS7N3AN-uMtyQjAg.js +0 -1
  110. package/dist/client/assets/cose-bilkent-JH36ORCC-BL0lTP0k.js +0 -1
  111. package/dist/client/assets/cynefin-OW5HDTMX-1zOzoEu-.js +0 -1
  112. package/dist/client/assets/cynefinDiagram-5FMLGOSQ-DST_IHb1.js +0 -62
  113. package/dist/client/assets/cytoscape.esm-C2x7inlt.js +0 -321
  114. package/dist/client/assets/dagre-3AP2YEHR-CoEaLaFu.js +0 -4
  115. package/dist/client/assets/dagre-PrKaheQc.js +0 -1
  116. package/dist/client/assets/diagram-UQ7AKVKN-wJMwiRQW.js +0 -41
  117. package/dist/client/assets/eventmodeling-NTZA5JFV-LK0PWCmm.js +0 -1
  118. package/dist/client/assets/flowDiagram-A5DVABFB-W_CNjdk0.js +0 -1
  119. package/dist/client/assets/gitGraph-4MIJSDKK-Con-0IRc.js +0 -1
  120. package/dist/client/assets/index-BPuQYkRJ.js +0 -90
  121. package/dist/client/assets/info-A6RAGUB7-Cl45HDP4.js +0 -1
  122. package/dist/client/assets/infoDiagram-RXCK75RN-DIVe-qvk.js +0 -2
  123. package/dist/client/assets/linear-CzAWMsuG.js +0 -1
  124. package/dist/client/assets/mermaid.core-B0ktnMCY.js +0 -44
  125. package/dist/client/assets/packet-AYTQ26CC-NTsh29Fp.js +0 -1
  126. package/dist/client/assets/pie-WAS4IAKB-DENes_Cx.js +0 -1
  127. package/dist/client/assets/radar-RG4KPBEZ-C3OVgpW0.js +0 -1
  128. package/dist/client/assets/railroad-74A4TZTK-DHWDkPlt.js +0 -1
  129. package/dist/client/assets/railroad-abnf-HS5TGJTU-XG9NyE2s.js +0 -1
  130. package/dist/client/assets/railroad-ebnf-LZEXJU2U-BePIURFQ.js +0 -1
  131. package/dist/client/assets/railroad-peg-WCYAUIDC-THfR3kY_.js +0 -1
  132. package/dist/client/assets/sankeyDiagram-P5KCCOFB-D3dgv1AD.js +0 -40
  133. package/dist/client/assets/sequenceDiagram-WJ2MYXX4-DbGvVPa7.js +0 -162
  134. package/dist/client/assets/stateDiagram-HBIQ2CUA-CJznFEjd.js +0 -1
  135. package/dist/client/assets/stateDiagram-v2-4QOOHH4V-o6PmCsJ0.js +0 -1
  136. package/dist/client/assets/swimlanes-XN3QIQJK-Bp4PtrPm.js +0 -1
  137. package/dist/client/assets/swimlanesDiagram-VK2B7HYN-W1d9PL3X.js +0 -8
  138. package/dist/client/assets/timeline-definition-24CTP7MA-jqaUS70q.js +0 -120
  139. package/dist/client/assets/treeView-Q6P3EWNA-4uMQTFyy.js +0 -1
  140. package/dist/client/assets/treemap-WGGIJYW6-BEvgQaxX.js +0 -1
  141. package/dist/client/assets/vennDiagram-4TSXK5OY-BzSNLT-7.js +0 -34
  142. package/dist/client/assets/wardley-WFR3VGLG-0Vpm3lxj.js +0 -1
  143. /package/dist/client/assets/{sizeCapture-X5ZJPWSS-B0uUizjq.js → sizeCapture-INFHLROL-B0uUizjq.js} +0 -0
package/dist/cli.mjs CHANGED
@@ -94,6 +94,18 @@ 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
+ /**
98
+ * Whatever the agent sent, made fit to be a tab's name: controls stripped,
99
+ * whitespace collapsed to one line, capped. Null for anything that isn't a
100
+ * usable title — the caller then leaves the existing name alone rather than
101
+ * blanking it.
102
+ */
103
+ function normalizeTitle(raw) {
104
+ if (typeof raw !== "string") return null;
105
+ const line = raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
106
+ if (line === "") return null;
107
+ return line.length > 40 ? `${line.slice(0, 39).trimEnd()}…` : line;
108
+ }
97
109
  const EMPTY_REVIEW = {
98
110
  version: 1,
99
111
  threads: []
@@ -193,6 +205,7 @@ function buildCliCommands(cli) {
193
205
  return {
194
206
  open: cli,
195
207
  poll: `${cli} poll`,
208
+ firstPoll: `${cli} poll --title "<the change, in 2-3 words>"`,
196
209
  reply: `${cli} reply <threadId> --message "<your reply>"`,
197
210
  comment: `${cli} comment [<file>] [--line <line>] --message "<comment>"`,
198
211
  guide: `${cli} comment --message "<what the change does>"`,
@@ -217,14 +230,37 @@ const INSTALL_SKILL = {
217
230
  * restates how to hold the poll — attended, never detached. Mirrors the skill.
218
231
  */
219
232
  const POLL_STANCE = "a tracked background task if your harness has one, the foreground if not — never a detached process";
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`;
233
+ /**
234
+ * The tab-title doctrine — the few words an agent hands its first poll, which
235
+ * become the browser tab's name. Stated once and interpolated into every
236
+ * surface that teaches it (the skill, `help agent`, the open-time next step),
237
+ * the same way GUIDE and POLL_STANCE keep their rules from drifting apart.
238
+ *
239
+ * It exists because a reviewer keeps several reviews open and every tab reads
240
+ * "Diffo": the agent is the only party that knows which is which at the moment
241
+ * it starts listening.
242
+ */
243
+ const TAB_TITLE = {
244
+ /** What to write — and how little room there is to write it in. */
245
+ what: "two or three words naming what the change IS, not what you did to it — about 20 characters, because that is all a browser tab shows",
246
+ /** Why it is worth a flag at all — the reviewer's problem, stated once. */
247
+ why: "it becomes the name of the reviewer's browser tab, and a reviewer with several reviews open sees every one of them titled \"Diffo\"",
248
+ /** How to order the words, given where they are read. */
249
+ shape: "the tab cuts off everything past that, so the word telling this review apart from another goes first, not last",
250
+ /** When to send it — and when not to bother. */
251
+ when: "on your first poll of a review; on a later one only if the changeset has become something the old title no longer names",
252
+ /** Two to copy the register from — both fit a tab whole. */
253
+ examples: "\"tab titles\", \"flaky upload retries\""
254
+ };
255
+ const JOIN_PROMPT = `join the diffo review: run \`${CLI_COMMANDS.firstPoll}\` (${POLL_STANCE}; the title is ${TAB_TITLE.what} — ${TAB_TITLE.why}) and follow the JSON payload it prints — each payload carries its own instructions`;
221
256
  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}).`;
257
+ if (kind === "cleared") return `Post the guide if this changeset warrants one, then run \`${CLI_COMMANDS.firstPoll}\` again to keep listening (${POLL_STANCE}) — this is a fresh round, so give it a fresh title.`;
223
258
  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.`;
224
259
  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.`;
225
260
  }
226
261
  const ACK_NEXT_STEP = {
227
262
  reply: `When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
263
+ 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.",
228
264
  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}\`.`,
229
265
  end: "Detached. Do not reopen or re-poll this review unless the user asks — deliver anything remaining directly in the conversation."
230
266
  };
@@ -250,9 +286,15 @@ const GUIDE = {
250
286
  * The skill teaches the same step, but this line is what an agent WITHOUT the
251
287
  * skill sees — payloads and command output must stand alone (see POLL_STANCE).
252
288
  */
289
+ /**
290
+ * The URL goes out before the guide, never after: composing a guide is the
291
+ * slowest step of an open (a diagram takes real thought), and a reviewer who
292
+ * is waiting for a link should not wait on it. The guide lands live at the top
293
+ * of their review while they are still opening the page.
294
+ */
253
295
  function guideNudge(hasGuide) {
254
296
  if (hasGuide) return null;
255
- return `orient the reviewer before sharing the URL, if this changeset needs it (${GUIDE.when}): post a guide — one comment on the whole changeset: ${GUIDE.what}: \`${CLI_COMMANDS.guide}\` (no file, so it anchors to the changeset). ${GUIDE.stance}.`;
297
+ 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 (${GUIDE.when}): post a guide — one comment on the whole changeset: ${GUIDE.what}: \`${CLI_COMMANDS.guide}\` (no file, so it anchors to the changeset; it appears live at the top of their review). ${GUIDE.stance}.`;
256
298
  }
257
299
  /**
258
300
  * Printed to stderr when a poll takes the review over and the review already
@@ -271,6 +313,11 @@ const INTENT_CONTRACT = {
271
313
  fix: "- `issue` threads want a code change. Address each one, or push back in the thread with your reasoning."
272
314
  };
273
315
  const UNLABELED_CONTRACT = "- Unlabeled threads: judge from the text — a question wants an answer, not an edit.";
316
+ /** The agent spoke last: the thread is answered until the reviewer says more.
317
+ * A reply that promised a follow-up (`--more`) is not an answer yet. */
318
+ function answeredByAgent(thread) {
319
+ return thread.messages.at(-1)?.author === "agent" && thread.awaitingFollowUp !== true;
320
+ }
274
321
  /** The closing note is the reviewer summing up, so it earns an answer even when it
275
322
  * asks nothing — a note with no reply is the review's one dead end. */
276
323
  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.";
@@ -281,9 +328,34 @@ function intentContract(threads) {
281
328
  if (threads.some((t) => t.closingNote)) lines.push(CLOSING_CONTRACT);
282
329
  return lines;
283
330
  }
284
- function replyProtocol(threads) {
331
+ /**
332
+ * The short form, for a session that already received the full protocol this
333
+ * attachment (tracked per session pid by the DeliveryQueue). The full text is
334
+ * ~580 tokens and re-shipping it with every delivery was the loop's largest
335
+ * recurring cost; the compact form keeps only the per-batch contract — intent
336
+ * rules, the reply command, the re-poll — plus the pointer that reprints the rest.
337
+ */
338
+ function compactProtocol(threads) {
339
+ return `## How to respond
340
+
341
+ Same protocol as your earlier deliveries (\`${CLI} help agent\` reprints it in full):
342
+
343
+ 1. Act on each thread.
344
+ ${intentContract(threads).map((line) => ` ${line}`).join("\n")}
345
+ 2. Reply per thread as soon as it is handled — what changed and where (\`file:line\`), verify it the cheapest honest way first:
346
+
347
+ ${CLI_COMMANDS.reply}
348
+
349
+ 3. When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening.
350
+
351
+ Change only what these threads ask about — the reviewer is mid-read. Re-read
352
+ the current file before editing; the code may have moved. Resolving a thread
353
+ is the reviewer's call, never yours.`;
354
+ }
355
+ function replyProtocol(threads, mode = "full") {
356
+ if (mode === "compact") return compactProtocol(threads);
285
357
  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" : "";
286
- 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."] : [];
358
+ 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."] : [];
287
359
  return `## How to respond
288
360
 
289
361
  Diffo is running locally; talk to it through its CLI.
@@ -303,6 +375,10 @@ ${[...intentContract(threads).map((line) => ` ${line}`), ...answeredNote].join
303
375
  is fixed, verify it the cheapest honest way (run the relevant test, re-read
304
376
  the change) and mention what you checked.
305
377
  Reply as soon as a thread is handled; don't save replies for the end.
378
+ A reply that only promises a follow-up ("I'll investigate and report
379
+ back") is not an answer — post it with \`--more\` so the reviewer keeps
380
+ seeing you at work on that thread, then post the real answer as a plain
381
+ reply (no \`--more\`) before your next poll.
306
382
  Replies and comment threads render GitHub-flavored markdown, and a
307
383
  \`\`\`mermaid fence renders as a diagram — use one when a flow, sequence,
308
384
  or state picture explains the change better than prose. Keep it small
@@ -418,6 +494,7 @@ function snapshotBlock(thread) {
418
494
  }
419
495
  const lines = codeContext.split("\n");
420
496
  const where = thread.anchor.kind === "changeset" ? "the file" : `\`${thread.anchor.path}\``;
497
+ if (thread.deliveredThrough) return [`(diff snapshot delivered to you earlier — read the current ${where} instead)`];
421
498
  if (!anchored) return [
422
499
  "The commented change:",
423
500
  "```diff",
@@ -457,7 +534,7 @@ function buildThreadPrompt(thread, ctx) {
457
534
  threadBlock(thread, 0),
458
535
  "",
459
536
  ...siblings ? [siblings, ""] : [],
460
- replyProtocol([thread]),
537
+ replyProtocol([thread], ctx.protocol),
461
538
  ""
462
539
  ].join("\n");
463
540
  }
@@ -469,7 +546,7 @@ function buildCoalescedPrompt(threads, ctx) {
469
546
  ...ctx.changeset ? [specLine(ctx.changeset), ""] : [],
470
547
  ...threads.map((t, i) => `${threadBlock(t, i)}\n`),
471
548
  ...siblings ? [siblings, ""] : [],
472
- replyProtocol(threads),
549
+ replyProtocol(threads, ctx.protocol),
473
550
  ""
474
551
  ].join("\n");
475
552
  }
@@ -494,7 +571,9 @@ function buildFinishPrompt(threads, ctx, coverage) {
494
571
  const repo = ctx.repo;
495
572
  const sent = threads.filter((t) => t.state === "sent");
496
573
  const closing = sent.find((t) => t.closingNote);
497
- const actionable = closing ? [closing, ...sent.filter((t) => t !== closing)] : sent;
574
+ const ordered = closing ? [closing, ...sent.filter((t) => t !== closing)] : sent;
575
+ const answered = ordered.filter((t) => t !== closing && answeredByAgent(t));
576
+ const actionable = ordered.filter((t) => !answered.includes(t));
498
577
  const changed = coverage.changedFiles ?? [];
499
578
  const commented = coverage.commentedUnread ?? [];
500
579
  const filtered = coverage.filteredOut ?? [];
@@ -523,11 +602,12 @@ function buildFinishPrompt(threads, ctx, coverage) {
523
602
  "",
524
603
  ...owedLines
525
604
  ];
605
+ 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)}`), "");
526
606
  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.`, "");
527
607
  if (actionable.length === 0) {
528
608
  const fullRead = coverage.totalHunks > 0 && coverage.viewedHunks >= coverage.totalHunks && changed.length === 0 && commented.length === 0 && filtered.length === 0 && coverage.skippedFiles.length === 0;
529
609
  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.", "");
530
- } else parts.push(`${actionable.length} review thread${actionable.length === 1 ? "" : "s"} to act on:`, "", ...actionable.map((t, i) => `${threadBlock(t, i)}\n`), replyProtocol(actionable), "");
610
+ } 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), "");
531
611
  return parts.join("\n");
532
612
  }
533
613
  //#endregion
@@ -549,6 +629,8 @@ For the reviewer:
549
629
  For the agent (the AI that wrote the change):
550
630
  poll Wait for the reviewer's feedback (blocking long-poll;
551
631
  prints one JSON payload; safe to re-run any time)
632
+ (--title "<what the change is>" on the first poll names
633
+ the reviewer's browser tab)
552
634
  reply <threadId> Post a reply to a review thread
553
635
  (--message "<text>", or pipe the text on stdin)
554
636
  comment [<file>] Start a comment thread as the agent — on a line (--line),
@@ -588,23 +670,36 @@ The loop:
588
670
 
589
671
  1. Open: run \`diffo --no-open\` from inside the repo. It returns straight
590
672
  away, leaving a background server watching the working tree. Never open a
591
- browser at the reviewer — end your message with the printed URL instead,
592
- and keep ending every message with it while you stay attached.
593
- 2. Guide (only when the changeset needs it — ${GUIDE.when}): before sharing
594
- the URL, post ONE comment on the whole changeset (\`diffo comment -m "…"\`,
595
- no file): ${GUIDE.what}.
596
- ${GUIDE.stance}. If the changeset later shifts under the guide,
597
- ${GUIDE.update}.
598
- 3. Listen: run \`diffo poll\` — it blocks until the reviewer acts, then prints
599
- one JSON payload naming the threads to act on. Run it attended:
600
- ${POLL_STANCE}.
673
+ browser at the reviewer — share the printed URL instead, the moment it
674
+ prints: a message line right after this command, before the guide, the
675
+ poll, or anything else. Then end your message with it too, and keep
676
+ ending every message with it while you stay attached.
677
+ Attached without ever seeing the URL (a takeover, a fresh session)?
678
+ \`diffo status\` prints it.
679
+ 2. Guide — post one only when the changeset needs orientation:
680
+ ${GUIDE.when}.
681
+ Right after sharing the URL, while the reviewer opens the page, post ONE
682
+ comment on the whole changeset (\`diffo comment -m "…"\`, no file),
683
+ containing: ${GUIDE.what}.
684
+ ${GUIDE.stance}.
685
+ It lands live at the top of their review — never hold the URL back for it.
686
+ If the changeset later shifts under the guide, ${GUIDE.update}.
687
+ 3. Listen: run \`${CLI_COMMANDS.firstPoll}\` — it blocks until the reviewer
688
+ acts, then prints one JSON payload naming the threads to act on. Run it
689
+ attended: ${POLL_STANCE}.
690
+ The title is ${TAB_TITLE.what}: ${TAB_TITLE.why}. Write it the way it is
691
+ read — ${TAB_TITLE.shape} (${TAB_TITLE.examples}). Send it ${TAB_TITLE.when};
692
+ every other poll is a plain \`diffo poll\`.
601
693
  Killed or timed out? Re-run it; feedback is held in the review, not the
602
694
  poll.
603
695
  4. Act: \`[issue]\` threads want a code change; \`[question]\` threads want an
604
696
  answer in the reply and no edit. Your edits reach the reviewer live.
605
697
  5. Reply: \`diffo reply <threadId> --message "<text>"\` (pipe long replies on
606
698
  stdin) — concise, addressed to the reviewer. Markdown renders; a
607
- \`\`\`mermaid fence draws a diagram.
699
+ \`\`\`mermaid fence draws a diagram. A reply that only promises a
700
+ follow-up ("I'll investigate and report back") goes out with \`--more\` —
701
+ the reviewer keeps seeing you at work — and the real answer follows as a
702
+ plain reply before the next poll.
608
703
  6. Comment (sparingly): \`diffo comment [<file>] [--line <n>] -m "<text>"\`
609
704
  starts a thread in your voice — a concern, or context that helps the read.
610
705
  7. Poll again only when the whole batch is handled — a new poll tells the
@@ -615,11 +710,13 @@ Rules:
615
710
  - Change only what the threads ask about — the reviewer is mid-read, and an
616
711
  unrelated edit moves the diff under them.
617
712
  - One attached agent at a time: the newest poll carries the review; don't
618
- re-poll to take it back from another session — tell the user instead.
713
+ re-poll to take it back from another session — tell the user instead. When
714
+ a poll says it took the review over, or returns status "superseded", say so
715
+ to the user: their feedback moved with it.
619
716
  - Resolving a thread is the reviewer's call, never yours.`,
620
717
  poll: `diffo poll — wait for the reviewer's feedback
621
718
 
622
- Usage: diffo poll
719
+ Usage: diffo poll [--title "<what the change is>"]
623
720
 
624
721
  Blocks (streaming whitespace heartbeats) until the reviewer acts, then prints
625
722
  one JSON payload naming the review threads to act on, and exits. Run it
@@ -628,10 +725,15 @@ payload that reaches a process nobody is listening to never reaches you.
628
725
  Safe to re-run any time: feedback is held in the review itself, so
629
726
  nothing is lost when a poll is killed or times out — the next poll gets it.
630
727
 
728
+ --title is ${TAB_TITLE.what}: ${TAB_TITLE.why}. Write it the way it is read —
729
+ ${TAB_TITLE.shape} (${TAB_TITLE.examples}). Send it ${TAB_TITLE.when}; the
730
+ newest title wins, and a poll without one leaves the name it finds alone.
731
+
631
732
  Output: one JSON object, e.g.
632
733
  {"status":"feedback","threadIds":["t-3"],"prompt":"…what to do…"}
633
734
 
634
- Example:
735
+ Examples:
736
+ diffo poll --title "tab titles from the agent"
635
737
  diffo poll`,
636
738
  reply: `diffo reply — post a reply to a review thread
637
739
 
@@ -640,6 +742,10 @@ Usage: diffo reply <threadId> --message "<text>"
640
742
 
641
743
  Thread ids arrive in poll payloads. Each run posts one message, so don't
642
744
  re-run a reply that succeeded.
745
+ --more marks the reply as interim: you are still working, and a follow-up
746
+ reply on this thread is promised. The reviewer keeps seeing the thread as
747
+ "with the agent" until your next plain reply; going quiet instead marks it
748
+ unanswered.
643
749
  Messages render GitHub-flavored markdown; a \`\`\`mermaid fence renders as a
644
750
  diagram in the review.
645
751
 
@@ -878,6 +984,8 @@ function parseVerb(verb, rest) {
878
984
  short: "m"
879
985
  },
880
986
  line: { type: "string" },
987
+ title: { type: "string" },
988
+ more: { type: "boolean" },
881
989
  json: { type: "boolean" },
882
990
  help: {
883
991
  type: "boolean",
@@ -898,6 +1006,14 @@ function parseVerb(verb, rest) {
898
1006
  kind: "error",
899
1007
  message: `'${verb}' takes no --json`
900
1008
  };
1009
+ if (values.more && verb !== "reply") return {
1010
+ kind: "error",
1011
+ message: `'${verb}' takes no --more`
1012
+ };
1013
+ if (values.title !== void 0 && verb !== "poll") return {
1014
+ kind: "error",
1015
+ message: `'${verb}' takes no --title`
1016
+ };
901
1017
  if (verb === "poll" || verb === "end" || verb === "setup" || verb === "status" || verb === "stop") {
902
1018
  if (positionals.length > 0) return {
903
1019
  kind: "error",
@@ -911,6 +1027,17 @@ function parseVerb(verb, rest) {
911
1027
  kind: "status",
912
1028
  json: values.json === true
913
1029
  };
1030
+ if (verb === "poll") {
1031
+ const title = normalizeTitle(values.title);
1032
+ if (values.title !== void 0 && title === null) return {
1033
+ kind: "error",
1034
+ message: "--title needs a few words naming the change"
1035
+ };
1036
+ return {
1037
+ kind: "poll",
1038
+ title
1039
+ };
1040
+ }
914
1041
  return { kind: verb };
915
1042
  }
916
1043
  if (verb === "reply") {
@@ -930,7 +1057,8 @@ function parseVerb(verb, rest) {
930
1057
  return {
931
1058
  kind: "reply",
932
1059
  threadId,
933
- message: values.message ?? null
1060
+ message: values.message ?? null,
1061
+ more: values.more === true
934
1062
  };
935
1063
  }
936
1064
  const file = positionals[0] ?? null;
@@ -1531,7 +1659,8 @@ var DiffoDb = class {
1531
1659
  const known = /* @__PURE__ */ new Set([
1532
1660
  "reviews",
1533
1661
  "servers",
1534
- "repo_ports"
1662
+ "repo_ports",
1663
+ "ui_settings"
1535
1664
  ]);
1536
1665
  const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
1537
1666
  for (const { name } of tables) if (!known.has(name)) this.db.exec(`DROP TABLE IF EXISTS "${name.replaceAll("\"", "\"\"")}"`);
@@ -1556,6 +1685,10 @@ var DiffoDb = class {
1556
1685
  repo_path TEXT PRIMARY KEY,
1557
1686
  port INTEGER NOT NULL
1558
1687
  );
1688
+ CREATE TABLE IF NOT EXISTS ui_settings (
1689
+ key TEXT PRIMARY KEY,
1690
+ value TEXT NOT NULL
1691
+ );
1559
1692
  PRAGMA user_version = ${SCHEMA_VERSION};
1560
1693
  `);
1561
1694
  this.pruneVanishedRepos();
@@ -1631,6 +1764,16 @@ var DiffoDb = class {
1631
1764
  this.db.prepare(`INSERT INTO repo_ports (repo_path, port) VALUES (?, ?)
1632
1765
  ON CONFLICT(repo_path) DO UPDATE SET port = excluded.port`).run(repoPath, port);
1633
1766
  }
1767
+ /** Reviewer preferences that must outlive one server's origin — every repo is
1768
+ * served from its own port, so localStorage alone can't hold a choice like
1769
+ * the theme. Keyed blobs, no scope: these are per-human, not per-repo. */
1770
+ getUiSetting(key) {
1771
+ return this.db.prepare("SELECT value FROM ui_settings WHERE key = ?").get(key)?.value ?? null;
1772
+ }
1773
+ setUiSetting(key, value) {
1774
+ this.db.prepare(`INSERT INTO ui_settings (key, value) VALUES (?, ?)
1775
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, value);
1776
+ }
1634
1777
  getServer(repoPath) {
1635
1778
  const row = this.db.prepare("SELECT port, pid, started_at FROM servers WHERE repo_path = ?").get(repoPath);
1636
1779
  return row ? {
@@ -1710,6 +1853,11 @@ var DeliveryQueue = class DeliveryQueue {
1710
1853
  reason: "no-agent"
1711
1854
  };
1712
1855
  owner = null;
1856
+ /** The session pid that last received the full reply protocol. Later
1857
+ * deliveries to the same session carry the compact form — the full text is
1858
+ * the loop's largest recurring token cost. A takeover (new pid) or an
1859
+ * anonymous session (null pid) always gets the full form again. */
1860
+ protocolPid = null;
1713
1861
  batch = null;
1714
1862
  batchWatch = null;
1715
1863
  waiterWatch = null;
@@ -1770,6 +1918,12 @@ var DeliveryQueue = class DeliveryQueue {
1770
1918
  ownerPid() {
1771
1919
  return this.ownerConnected() ? this.owner : null;
1772
1920
  }
1921
+ needsFullProtocol() {
1922
+ return this.owner === null || this.owner !== this.protocolPid;
1923
+ }
1924
+ markProtocolSent() {
1925
+ this.protocolPid = this.owner;
1926
+ }
1773
1927
  /**
1774
1928
  * A poll attaches for its session; the newest poll always carries the review.
1775
1929
  * Returns the pid of a still-live session it took the review from, so both
@@ -2045,10 +2199,19 @@ var DeliveryQueue = class DeliveryQueue {
2045
2199
  this.notify();
2046
2200
  }
2047
2201
  /** The agent replied on a thread. Returns how long since that thread's feedback
2048
- * was delivered, or null when the reply wasn't an answer to a delivery. */
2049
- agentReplied(threadId) {
2202
+ * was delivered, or null when the reply wasn't an answer to a delivery.
2203
+ * `interim` (a `--more` reply) keeps the thread on the clock: a follow-up is
2204
+ * still owed, so the spinner stays and a batch that closes without the
2205
+ * follow-up counts the thread unanswered. */
2206
+ agentReplied(threadId, interim = false) {
2050
2207
  const at = this.deliveredAt.get(threadId);
2051
- this.deliveredAt.delete(threadId);
2208
+ if (!interim) this.deliveredAt.delete(threadId);
2209
+ else if (at === void 0 && this.batch) {
2210
+ this.deliveredAt.set(threadId, Date.now());
2211
+ if (!this.batch.threadIds.includes(threadId)) this.batch.threadIds.push(threadId);
2212
+ this.awaitingReply = true;
2213
+ this.clearGrace();
2214
+ }
2052
2215
  if (this.batch) this.batch.sawReply = true;
2053
2216
  if (this.deliveredAt.size > 0) {
2054
2217
  if (this.awaitingReply) {
@@ -2328,7 +2491,7 @@ var ReviewStore = class {
2328
2491
  * the reviewer raced in since — appended at the end, it would render as an
2329
2492
  * answer to a comment it never saw, and the raced comment would stop reading
2330
2493
  * as "waiting on the agent" even though it still is. */
2331
- addMessage(threadId, author, text, withheld = false, seenThroughMs) {
2494
+ addMessage(threadId, author, text, withheld = false, seenThroughMs, followUp = false) {
2332
2495
  return this.update(threadId, ({ unanswered: _answered, ...thread }) => {
2333
2496
  const message = {
2334
2497
  id: randomUUID(),
@@ -2336,9 +2499,11 @@ var ReviewStore = class {
2336
2499
  text,
2337
2500
  at: (/* @__PURE__ */ new Date()).toISOString()
2338
2501
  };
2339
- const raced = author === "agent" && seenThroughMs !== void 0 ? thread.messages.findIndex((m) => Date.parse(m.at) > seenThroughMs) : -1;
2502
+ const raced = author === "agent" && seenThroughMs !== void 0 ? thread.messages.findIndex((m) => m.author === "reviewer" && Date.parse(m.at) > seenThroughMs) : -1;
2503
+ const { awaitingFollowUp: _promised, ...settled } = thread;
2340
2504
  return {
2341
- ...thread,
2505
+ ...author === "agent" ? settled : thread,
2506
+ ...author === "agent" && followUp ? { awaitingFollowUp: true } : {},
2342
2507
  ...withheld && author === "reviewer" ? { withheld: true } : {},
2343
2508
  messages: raced === -1 ? [...thread.messages, message] : [
2344
2509
  ...thread.messages.slice(0, raced),
@@ -2387,7 +2552,7 @@ var ReviewStore = class {
2387
2552
  const threads = this.state.threads.map((thread) => {
2388
2553
  if (!wanted.has(thread.id) || !applies(thread)) return thread;
2389
2554
  changed = true;
2390
- const { unanswered: _was, ...rest } = thread;
2555
+ const { unanswered: _was, awaitingFollowUp: _promised, ...rest } = thread;
2391
2556
  return thread.unanswered ? rest : {
2392
2557
  ...rest,
2393
2558
  unanswered: true
@@ -2401,8 +2566,9 @@ var ReviewStore = class {
2401
2566
  this.commit();
2402
2567
  }
2403
2568
  setState(threadId, state) {
2404
- return this.update(threadId, (thread) => ({
2569
+ return this.update(threadId, ({ awaitingFollowUp: promised, ...thread }) => ({
2405
2570
  ...thread,
2571
+ ...promised && state !== "resolved" ? { awaitingFollowUp: true } : {},
2406
2572
  state,
2407
2573
  ...state === "sent" && !thread.sentAt ? { sentAt: (/* @__PURE__ */ new Date()).toISOString() } : {},
2408
2574
  ...state === "resolved" ? { codeContext: null } : {}
@@ -2419,16 +2585,18 @@ var ReviewStore = class {
2419
2585
  return true;
2420
2586
  }
2421
2587
  /**
2422
- * Start the review over: threads, the last-finish record, and the landed
2423
- * marker all go. Not just the threads — a kept `lastFinish` would carry hunk
2424
- * ids from the dead changeset into the next review's "since last review"
2425
- * lens, reporting everything as new. `seenHead` survives: it describes the
2588
+ * Start the review over: threads, the last-finish record, the landed marker,
2589
+ * and the tab title all go. Not just the threads — a kept `lastFinish` would
2590
+ * carry hunk ids from the dead changeset into the next review's "since last
2591
+ * review" lens, reporting everything as new, and a kept title would name the
2592
+ * work that just ended. The cleared agent is woken (see the DELETE route), so
2593
+ * its next poll names the new round. `seenHead` survives: it describes the
2426
2594
  * repo, not the review being discarded.
2427
2595
  */
2428
2596
  reset() {
2429
2597
  const ids = this.state.threads.map((t) => t.id);
2430
- if (ids.length === 0 && !this.state.lastFinish && !this.state.landed) return [];
2431
- const { lastFinish: _finish, landed: _landed, ...rest } = this.state;
2598
+ if (ids.length === 0 && !this.state.lastFinish && !this.state.landed && !this.state.title) return [];
2599
+ const { lastFinish: _finish, landed: _landed, title: _title, ...rest } = this.state;
2432
2600
  this.state = {
2433
2601
  ...rest,
2434
2602
  threads: []
@@ -2436,6 +2604,16 @@ var ReviewStore = class {
2436
2604
  this.commit();
2437
2605
  return ids;
2438
2606
  }
2607
+ /** The agent's name for this changeset, carried by its poll. The newest one
2608
+ * wins: a change that grows a second subject renames its own tab. */
2609
+ setTitle(title) {
2610
+ if (this.state.title === title) return;
2611
+ this.state = {
2612
+ ...this.state,
2613
+ title
2614
+ };
2615
+ this.commit();
2616
+ }
2439
2617
  /** The base the work under review sits on — see `ReviewState.seenHead` for
2440
2618
  * when the caller must NOT move it. */
2441
2619
  noteHead(sha) {
@@ -2606,9 +2784,11 @@ function parseReview(raw) {
2606
2784
  const migrated = Array.isArray(parsed.suggestions) ? parsed.suggestions.map((s) => migrateLegacySuggestion(s, now)).filter((t) => t !== null) : [];
2607
2785
  const lastFinish = normalizeLastFinish(parsed.lastFinish, now);
2608
2786
  const landed = normalizeLanded(parsed.landed, now);
2787
+ const title = normalizeTitle(parsed.title);
2609
2788
  return {
2610
2789
  version: 1,
2611
2790
  threads: [...valid, ...migrated],
2791
+ ...title ? { title } : {},
2612
2792
  ...lastFinish ? { lastFinish } : {},
2613
2793
  ...typeof parsed.seenHead === "string" && parsed.seenHead !== "" ? { seenHead: parsed.seenHead } : {},
2614
2794
  ...landed ? { landed } : {}
@@ -2746,6 +2926,7 @@ function normalizeThread(value, now) {
2746
2926
  ...anchored ? { anchored } : {},
2747
2927
  codeChanged: t.codeChanged === true,
2748
2928
  ...t.unanswered === true ? { unanswered: true } : {},
2929
+ ...t.awaitingFollowUp === true && t.unanswered !== true ? { awaitingFollowUp: true } : {},
2749
2930
  ...t.closingNote === true ? { closingNote: true } : {},
2750
2931
  ...typeof t.sentAt === "string" ? { sentAt: t.sentAt } : {},
2751
2932
  messages,
@@ -3231,6 +3412,22 @@ function createApp(ctx, store, review, queue) {
3231
3412
  note: "shutting down"
3232
3413
  });
3233
3414
  });
3415
+ const THEME_VALUES = /* @__PURE__ */ new Set([
3416
+ "system",
3417
+ "light",
3418
+ "dark"
3419
+ ]);
3420
+ app.get("/api/settings", (c) => {
3421
+ const theme = ctx.uiSettings?.get("theme") ?? null;
3422
+ return c.json({ theme: theme !== null && THEME_VALUES.has(theme) ? theme : null });
3423
+ });
3424
+ app.put("/api/settings", async (c) => {
3425
+ if (!ctx.uiSettings) return c.json({ error: "settings unavailable" }, 503);
3426
+ const theme = (await c.req.json().catch(() => null))?.theme;
3427
+ if (typeof theme !== "string" || !THEME_VALUES.has(theme)) return c.json({ error: "need {theme: \"system\" | \"light\" | \"dark\"}" }, 400);
3428
+ ctx.uiSettings.set("theme", theme);
3429
+ return c.json({ ok: true });
3430
+ });
3234
3431
  const repoInfo = () => store?.get().repo ?? {
3235
3432
  path: resolve(ctx.root),
3236
3433
  name: "",
@@ -3298,9 +3495,10 @@ function createApp(ctx, store, review, queue) {
3298
3495
  const id = c.req.param("id");
3299
3496
  if (!review.get().threads.some((t) => t.id === id)) return c.json({ error: "no such thread" }, 404);
3300
3497
  queue?.noteSession(parseSessionPid(c.req.header("x-diffo-session-pid")));
3301
- const waitedMs = queue?.agentReplied(id) ?? null;
3498
+ const more = body?.more === true;
3499
+ const waitedMs = queue?.agentReplied(id, more) ?? null;
3302
3500
  const seenThroughMs = waitedMs === null ? void 0 : Date.now() - waitedMs;
3303
- const thread = review.addMessage(id, "agent", text, false, seenThroughMs);
3501
+ const thread = review.addMessage(id, "agent", text, false, seenThroughMs, more);
3304
3502
  if (!thread) return c.json({ error: "no such thread" }, 404);
3305
3503
  if (waitedMs !== null) review.annotateAgentReplies([thread.id], waitedMs);
3306
3504
  return c.json({
@@ -3311,6 +3509,7 @@ function createApp(ctx, store, review, queue) {
3311
3509
  const deliver = body?.deliver !== false;
3312
3510
  let thread = review.addMessage(c.req.param("id"), "reviewer", text, !deliver);
3313
3511
  if (!thread) return c.json({ error: "no such thread" }, 404);
3512
+ if (deliver && thread.state === "open" && startedByAgent(thread)) thread = review.send(thread.id) ?? thread;
3314
3513
  let delivered = false;
3315
3514
  if (deliver && (thread.state === "sent" || thread.state === "addressed")) {
3316
3515
  delivered = deliverThreads([thread.id]);
@@ -3360,8 +3559,9 @@ function createApp(ctx, store, review, queue) {
3360
3559
  const handedOver = thread.state === "sent" || thread.state === "addressed" && thread.withheld === true;
3361
3560
  const delivered = handedOver ? deliverThreads([thread.id]) : false;
3362
3561
  if (handedOver) review.clearWithheld([thread.id]);
3562
+ const current = review.get().threads.find((t) => t.id === thread.id) ?? thread;
3363
3563
  return c.json({
3364
- thread,
3564
+ thread: current,
3365
3565
  prompt,
3366
3566
  delivered,
3367
3567
  presence
@@ -3479,6 +3679,7 @@ function createApp(ctx, store, review, queue) {
3479
3679
  });
3480
3680
  const pollPayload = (snapshot) => {
3481
3681
  const threads = review?.get().threads ?? [];
3682
+ const protocol = queue?.needsFullProtocol() === false ? "compact" : void 0;
3482
3683
  if (snapshot.kind === "cleared") return {
3483
3684
  status: "feedback",
3484
3685
  kind: "cleared",
@@ -3493,15 +3694,22 @@ function createApp(ctx, store, review, queue) {
3493
3694
  return {
3494
3695
  status: "feedback",
3495
3696
  kind: "finish",
3496
- threadIds: batch.filter((t) => t.state === "sent" && t.messages.at(-1)?.author === "reviewer").map((t) => t.id),
3697
+ threadIds: batch.filter((t) => t.state === "sent" && !answeredByAgent(t)).map((t) => t.id),
3497
3698
  prompt: buildFinishPrompt(batch, {
3498
3699
  repo: repoInfo(),
3499
- changeset: store?.get() ?? null
3700
+ changeset: store?.get() ?? null,
3701
+ protocol
3500
3702
  }, snapshot.coverage)
3501
3703
  };
3502
3704
  }
3503
3705
  const delivered = threads.filter((t) => snapshot.threadIds.includes(t.id));
3504
- const prompt = delivered.length === 1 ? buildThreadPrompt(delivered[0], promptCtx(delivered[0].id)) : buildCoalescedPrompt(delivered, promptCtx(...snapshot.threadIds));
3706
+ const prompt = delivered.length === 1 ? buildThreadPrompt(delivered[0], {
3707
+ ...promptCtx(delivered[0].id),
3708
+ protocol
3709
+ }) : buildCoalescedPrompt(delivered, {
3710
+ ...promptCtx(...snapshot.threadIds),
3711
+ protocol
3712
+ });
3505
3713
  return {
3506
3714
  status: "feedback",
3507
3715
  kind: "threads",
@@ -3514,6 +3722,8 @@ function createApp(ctx, store, review, queue) {
3514
3722
  if (!c.req.header("x-diffo-agent") && !c.req.header("x-diffo-session-pid")) return c.json({ error: "agent polls must send the x-diffo-agent header — use `diffo poll`" }, 403);
3515
3723
  const tookOverFrom = queue.claimSession(parseSessionPid(c.req.header("x-diffo-session-pid")));
3516
3724
  if (tookOverFrom !== null) c.header("x-diffo-took-over-from", String(tookOverFrom));
3725
+ const title = normalizeTitle(c.req.query("title"));
3726
+ if (title) review.setTitle(title);
3517
3727
  const heartbeatMs = ctx.pollHeartbeatMs ?? POLL_HEARTBEAT_MS;
3518
3728
  return stream(c, async (s) => {
3519
3729
  let aborted = false;
@@ -3579,6 +3789,7 @@ function createApp(ctx, store, review, queue) {
3579
3789
  await s.write(JSON.stringify(payload));
3580
3790
  if (aborted) return;
3581
3791
  queue.confirm(snapshot, payload.threadIds);
3792
+ if (payload.threadIds.length > 0) queue.markProtocolSent();
3582
3793
  review.markDelivered(payload.threadIds);
3583
3794
  review.clearUnanswered(payload.threadIds);
3584
3795
  if (snapshot.kind === "finish") review.markFinishCollected();
@@ -3737,6 +3948,8 @@ function markDevIndex(html, isDev) {
3737
3948
  return html.replace(/<title>[^<]*<\/title>/, "<title>diffo-dev</title>").replace(/<\/head>/, " <meta name=\"diffo-env\" content=\"development\" />\n </head>");
3738
3949
  }
3739
3950
  function rehydrateQueue(review, queue) {
3951
+ const promised = review.get().threads.filter((t) => t.awaitingFollowUp === true).map((t) => t.id);
3952
+ if (promised.length > 0) review.markUnanswered(promised);
3740
3953
  const state = review.get();
3741
3954
  queue.enqueueThreads(undeliveredThreadIds(state.threads));
3742
3955
  const finish = state.lastFinish;
@@ -3808,7 +4021,11 @@ function startServer(options) {
3808
4021
  const app = createApp({
3809
4022
  ...options,
3810
4023
  idle,
3811
- onShutdownRequest: options.onShutdownRequest ?? (() => process.exit(0))
4024
+ onShutdownRequest: options.onShutdownRequest ?? (() => process.exit(0)),
4025
+ uiSettings: {
4026
+ get: (key) => db.getUiSetting(key),
4027
+ set: (key, value) => db.setUiSetting(key, value)
4028
+ }
3812
4029
  }, store, review, queue);
3813
4030
  const server = serve({
3814
4031
  fetch: app.fetch,
@@ -4631,9 +4848,10 @@ if (command.kind === "stop") {
4631
4848
  }
4632
4849
  if (command.kind === "poll") {
4633
4850
  const port = await requireServer();
4634
- 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");
4851
+ 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");
4635
4852
  try {
4636
- const res = await fetch(apiUrl(port, "/api/agent/poll"), { headers: sessionHeaders() });
4853
+ const path = command.title === null ? "/api/agent/poll" : `/api/agent/poll?title=${encodeURIComponent(command.title)}`;
4854
+ const res = await fetch(apiUrl(port, path), { headers: sessionHeaders() });
4637
4855
  if (!res.ok) fail(`poll failed (${res.status})`);
4638
4856
  const tookOverFrom = res.headers.get("x-diffo-took-over-from");
4639
4857
  if (tookOverFrom) {
@@ -4654,7 +4872,8 @@ if (command.kind === "reply") {
4654
4872
  if (!message) fail("reply needs a message — pass --message \"<text>\" or pipe it on stdin");
4655
4873
  const { status, body } = await postJson(await requireServer(), `/api/review/threads/${encodeURIComponent(command.threadId)}/messages`, {
4656
4874
  author: "agent",
4657
- text: message
4875
+ text: message,
4876
+ ...command.more ? { more: true } : {}
4658
4877
  });
4659
4878
  if (status === 404) fail(`no thread with id '${command.threadId}'`);
4660
4879
  if (status !== 200) fail(`reply failed (${status})`);
@@ -4663,7 +4882,7 @@ if (command.kind === "reply") {
4663
4882
  ok: true,
4664
4883
  threadId: thread.id,
4665
4884
  state: thread.state,
4666
- next_step: ACK_NEXT_STEP.reply
4885
+ next_step: command.more ? ACK_NEXT_STEP.replyMore : ACK_NEXT_STEP.reply
4667
4886
  }));
4668
4887
  process.exit(0);
4669
4888
  }
@@ -4711,7 +4930,7 @@ async function printAgentNextStep(port) {
4711
4930
  const review = await fetchReviewState(port);
4712
4931
  const nudge = review ? guideNudge(findGuideThread(review) !== void 0) : null;
4713
4932
  if (nudge) console.log(`first: ${nudge}`);
4714
- console.log("next: run `diffo poll` to receive the reviewer's feedback (`diffo help agent` prints the whole loop)");
4933
+ console.log(`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)`);
4715
4934
  }
4716
4935
  /**
4717
4936
  * Dev builds serve the client from the checkout's `dist/client`, which nothing