@diffohq/diffo 0.0.1

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 (152) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +5 -0
  3. package/README.md +300 -0
  4. package/dist/cli.mjs +4553 -0
  5. package/dist/client/assets/abnfDiagram-VCTEODGH--nG4YFZf.js +1 -0
  6. package/dist/client/assets/arc-DUDGa5yC.js +1 -0
  7. package/dist/client/assets/architecture-7GRP2DOG-BS3omA-i.js +1 -0
  8. package/dist/client/assets/architectureDiagram-5GKGNRK7-DvSKG4lO.js +36 -0
  9. package/dist/client/assets/array-BifhSqXX.js +1 -0
  10. package/dist/client/assets/blockDiagram-NRAW4CY4-t9YA0Zds.js +129 -0
  11. package/dist/client/assets/c-DWGmR78Y.js +1 -0
  12. package/dist/client/assets/c-SSSlmE39.js +1 -0
  13. package/dist/client/assets/c4Diagram-UCG6FXSJ-D0NJEiQf.js +38 -0
  14. package/dist/client/assets/channel-C2N97jf-.js +1 -0
  15. package/dist/client/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  16. package/dist/client/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  17. package/dist/client/assets/chunk-75Z2AOVW-Bai5ank7.js +2 -0
  18. package/dist/client/assets/chunk-DU6HZSFF-D2kJr6Sd.js +125 -0
  19. package/dist/client/assets/chunk-F27PBJKO-Bfqt90G-.js +1 -0
  20. package/dist/client/assets/chunk-FOHPRMQF-DHwB1DNv.js +161 -0
  21. package/dist/client/assets/chunk-G27WJ6UU-BaH77Llj.js +231 -0
  22. package/dist/client/assets/chunk-GMAD6QVW-DEk8GHWm.js +72 -0
  23. package/dist/client/assets/chunk-JQ64N6SF-DSC3eBmu.js +1 -0
  24. package/dist/client/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  25. package/dist/client/assets/chunk-LCL6LL3I-CbXTQnS7.js +206 -0
  26. package/dist/client/assets/chunk-OBVCFTLP-DTR5L5DZ.js +1 -0
  27. package/dist/client/assets/chunk-P2QGCYS3-B4CbyKz8.js +1 -0
  28. package/dist/client/assets/chunk-POPQ4Y6H-bUwnKEA6.js +1 -0
  29. package/dist/client/assets/chunk-PWAF6VOD-zdMmz14g.js +1 -0
  30. package/dist/client/assets/chunk-R7TYR2AO-Bij6COIm.js +10 -0
  31. package/dist/client/assets/chunk-RHFEMEQ7-B2_qXJHf.js +168 -0
  32. package/dist/client/assets/chunk-SVP7TREG-2A6DuC2c.js +88 -0
  33. package/dist/client/assets/chunk-TEH6E4GO-ByR-u0fj.js +62 -0
  34. package/dist/client/assets/chunk-XXDRQBXY-D_O-C3As.js +1 -0
  35. package/dist/client/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  36. package/dist/client/assets/classDiagram-DTDB5LWJ-DkiwiYZN.js +1 -0
  37. package/dist/client/assets/classDiagram-v2-JRS7N3AN-DkiwiYZN.js +1 -0
  38. package/dist/client/assets/core-Bnn4HFdG.js +12 -0
  39. package/dist/client/assets/cose-bilkent-JH36ORCC-Z148C14w.js +1 -0
  40. package/dist/client/assets/cpp-CJIxL8VH.js +1 -0
  41. package/dist/client/assets/cpp-Cts1PyIS.js +1 -0
  42. package/dist/client/assets/csharp-oqKa8noW.js +1 -0
  43. package/dist/client/assets/css-CdtkT07m.js +1 -0
  44. package/dist/client/assets/css-LbU1hoFO.js +1 -0
  45. package/dist/client/assets/cynefin-OW5HDTMX-_nvdHJ_6.js +1 -0
  46. package/dist/client/assets/cynefinDiagram-5FMLGOSQ-DgjK3dBx.js +62 -0
  47. package/dist/client/assets/cytoscape.esm-C2x7inlt.js +321 -0
  48. package/dist/client/assets/dagre-3AP2YEHR-ZpUDL3As.js +4 -0
  49. package/dist/client/assets/dagre-PrKaheQc.js +1 -0
  50. package/dist/client/assets/defaultLocale-BFoDCU3G.js +1 -0
  51. package/dist/client/assets/diagram-S7CK7UJ4-CkZIS9iX.js +30 -0
  52. package/dist/client/assets/diagram-UQ7AKVKN-CQ18pzfD.js +41 -0
  53. package/dist/client/assets/diagram-VSXAHHWV-BHksZhNT.js +3 -0
  54. package/dist/client/assets/diagram-VX7I27RA-JaMo95UB.js +24 -0
  55. package/dist/client/assets/diagram-Z3DM3KII-BtbUi7Zp.js +24 -0
  56. package/dist/client/assets/dist-frF0Fo17.js +1 -0
  57. package/dist/client/assets/docker-IyjqRm3v.js +1 -0
  58. package/dist/client/assets/ebnfDiagram-PWID7BFC-CrkTnETC.js +1 -0
  59. package/dist/client/assets/engine-oniguruma-BDMkTjCm.js +1 -0
  60. package/dist/client/assets/erDiagram-SSCWMZ5O-BU_DeMcX.js +99 -0
  61. package/dist/client/assets/eventmodeling-NTZA5JFV-C6iBQfJc.js +1 -0
  62. package/dist/client/assets/flowDiagram-A5DVABFB-Dq3MklmQ.js +1 -0
  63. package/dist/client/assets/ganttDiagram-EL5Y4UJY-DCoVu2Ob.js +292 -0
  64. package/dist/client/assets/gitGraph-4MIJSDKK-D_vOLjJd.js +1 -0
  65. package/dist/client/assets/gitGraphDiagram-WWUBYQGX-CbJeUVdZ.js +106 -0
  66. package/dist/client/assets/github-dark-C-LZuMrd.js +1 -0
  67. package/dist/client/assets/github-light-EUqPIrTm.js +1 -0
  68. package/dist/client/assets/go-rLFTqkRN.js +1 -0
  69. package/dist/client/assets/graphlib-DS17s2tU.js +1 -0
  70. package/dist/client/assets/html-BcACZWSP.js +1 -0
  71. package/dist/client/assets/html-DDNsd9lV.js +1 -0
  72. package/dist/client/assets/index-DUY5AD4r.js +90 -0
  73. package/dist/client/assets/index-pWZ6lr0e.css +1 -0
  74. package/dist/client/assets/info-A6RAGUB7-CRcr_2Kl.js +1 -0
  75. package/dist/client/assets/infoDiagram-RXCK75RN-B4UffpYj.js +2 -0
  76. package/dist/client/assets/init-C-OQMol4.js +1 -0
  77. package/dist/client/assets/ishikawaDiagram-5VMMS53U-DO6STczE.js +70 -0
  78. package/dist/client/assets/java-BtIQNnN0.js +1 -0
  79. package/dist/client/assets/java-DxlWrXwe.js +1 -0
  80. package/dist/client/assets/javascript-BzbQS41l.js +1 -0
  81. package/dist/client/assets/javascript-Yelw-5ZN.js +1 -0
  82. package/dist/client/assets/journeyDiagram-EYS64GPL-CJqu4rjo.js +139 -0
  83. package/dist/client/assets/json-B88Oqtu5.js +1 -0
  84. package/dist/client/assets/json-CaljABEy.js +1 -0
  85. package/dist/client/assets/jsx-D3KKoC5L.js +1 -0
  86. package/dist/client/assets/jsx-hEbJ82HF.js +1 -0
  87. package/dist/client/assets/kanban-definition-3QL26DDD-ugjVwpzB.js +89 -0
  88. package/dist/client/assets/katex-CXMH3UgJ.js +257 -0
  89. package/dist/client/assets/kotlin-DhhofPvG.js +1 -0
  90. package/dist/client/assets/line-C42aEa5T.js +1 -0
  91. package/dist/client/assets/linear-CAfNWogY.js +1 -0
  92. package/dist/client/assets/markdown-BYOwaDjH.js +1 -0
  93. package/dist/client/assets/mermaid-parser.core-DIpynd7a.js +7 -0
  94. package/dist/client/assets/mermaid.core-b7ipG6MC.js +44 -0
  95. package/dist/client/assets/mindmap-definition-FBJOCRG2-Btow11vP.js +96 -0
  96. package/dist/client/assets/ordinal-BDEzSJ7C.js +1 -0
  97. package/dist/client/assets/packet-AYTQ26CC-DmMiSJ_n.js +1 -0
  98. package/dist/client/assets/path-fybaL0A-.js +1 -0
  99. package/dist/client/assets/pegDiagram-XKGWAZYB-ZE6QXs-N.js +1 -0
  100. package/dist/client/assets/php-CQT4wx5i.js +1 -0
  101. package/dist/client/assets/pie-WAS4IAKB-CWO1GXQk.js +1 -0
  102. package/dist/client/assets/pieDiagram-E7YTZNPT-CkYClGbv.js +39 -0
  103. package/dist/client/assets/python-gzcpVVnB.js +1 -0
  104. package/dist/client/assets/quadrantDiagram-AXDQQJYC-CLah-4Vu.js +7 -0
  105. package/dist/client/assets/radar-RG4KPBEZ-VI14cwZd.js +1 -0
  106. package/dist/client/assets/railroad-74A4TZTK-s1iFKSLC.js +1 -0
  107. package/dist/client/assets/railroad-abnf-HS5TGJTU-DCYBjW-8.js +1 -0
  108. package/dist/client/assets/railroad-ebnf-LZEXJU2U-Cpg28CUR.js +1 -0
  109. package/dist/client/assets/railroad-peg-WCYAUIDC-D3OHUWgZ.js +1 -0
  110. package/dist/client/assets/railroadDiagram-O6MQD6OU-DSEEuZLE.js +1 -0
  111. package/dist/client/assets/requirementDiagram-EFPCY7ZU-DKPeLCXy.js +84 -0
  112. package/dist/client/assets/rough.esm-Dy-Kn_BL.js +1 -0
  113. package/dist/client/assets/ruby-D_UtGFHI.js +1 -0
  114. package/dist/client/assets/rust-Cfkwpbl8.js +1 -0
  115. package/dist/client/assets/sankeyDiagram-P5KCCOFB-CoRGTY0Z.js +40 -0
  116. package/dist/client/assets/scss-DHtrkMkT.js +1 -0
  117. package/dist/client/assets/sequenceDiagram-WJ2MYXX4-vOT2DQAD.js +162 -0
  118. package/dist/client/assets/shellscript-BiGZg9Om.js +1 -0
  119. package/dist/client/assets/shellscript-CqHdAlCp.js +1 -0
  120. package/dist/client/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  121. package/dist/client/assets/sql-0Qcw9ht3.js +1 -0
  122. package/dist/client/assets/sql-CP0rg-5b.js +1 -0
  123. package/dist/client/assets/src-COpTHlse.js +1 -0
  124. package/dist/client/assets/stateDiagram-HBIQ2CUA-B1Q5gx1k.js +1 -0
  125. package/dist/client/assets/stateDiagram-v2-4QOOHH4V-Bq1OmWqW.js +1 -0
  126. package/dist/client/assets/svelte-BUVJxsYv.js +1 -0
  127. package/dist/client/assets/swift-CyEgAFGc.js +1 -0
  128. package/dist/client/assets/swimlanes-XN3QIQJK-BHsSTPP_.js +1 -0
  129. package/dist/client/assets/swimlanesDiagram-VK2B7HYN-DACxnx54.js +8 -0
  130. package/dist/client/assets/timeline-definition-24CTP7MA-gMAK8Huz.js +120 -0
  131. package/dist/client/assets/toml-CcmNWLt0.js +1 -0
  132. package/dist/client/assets/treeView-Q6P3EWNA-D_ac7x_f.js +1 -0
  133. package/dist/client/assets/treemap-WGGIJYW6-CmKDF5Bk.js +1 -0
  134. package/dist/client/assets/tsx-5f3ulCja.js +1 -0
  135. package/dist/client/assets/tsx-BOvsFOI6.js +1 -0
  136. package/dist/client/assets/typescript-Utq2Cl8c.js +1 -0
  137. package/dist/client/assets/typescript-j_1H8WHN.js +1 -0
  138. package/dist/client/assets/vennDiagram-4TSXK5OY-BwoJOmDU.js +34 -0
  139. package/dist/client/assets/vue-CSBlZ1UP.js +1 -0
  140. package/dist/client/assets/wardley-WFR3VGLG-DBwnOUBJ.js +1 -0
  141. package/dist/client/assets/wardleyDiagram-VM6X3IG4-DSpTGNPm.js +78 -0
  142. package/dist/client/assets/wasm-BnjxR4X6.js +1 -0
  143. package/dist/client/assets/xml-CoDIzWF0.js +1 -0
  144. package/dist/client/assets/xml-WY0zc4PG.js +1 -0
  145. package/dist/client/assets/xychartDiagram-S5SC5T6Z-I-7pacDu.js +7 -0
  146. package/dist/client/assets/yaml-CxbubiML.js +1 -0
  147. package/dist/client/assets/yaml-DijMHRDA.js +1 -0
  148. package/dist/client/favicon.svg +27 -0
  149. package/dist/client/index.html +16 -0
  150. package/package.json +81 -0
  151. package/plugin.json +14 -0
  152. package/skills/diffo/SKILL.md +163 -0
package/dist/cli.mjs ADDED
@@ -0,0 +1,4553 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
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";
5
+ import { createServer } from "node:net";
6
+ import { homedir } from "node:os";
7
+ import { basename, dirname, extname, join, resolve, sep } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { parseArgs, promisify } from "node:util";
10
+ import { readFile } from "node:fs/promises";
11
+ import { serve } from "@hono/node-server";
12
+ import { Hono } from "hono";
13
+ import { stream, streamSSE } from "hono/streaming";
14
+ import { createHash, randomUUID } from "node:crypto";
15
+ //#region src/agentSession.ts
16
+ /** A row whose leading token is a shell is never the session. Tested on the FIRST
17
+ * token only: under Claude Code the transient `zsh -c` carries
18
+ * `~/.claude/shell-snapshots/…` in its arguments, which would match HARNESS. */
19
+ const SHELL = /^-?(?:\/[^ ]*\/)?(?:sh|bash|zsh|fish|dash|ksh|csh|tcsh)(?:\s|$)/;
20
+ const HARNESS = /claude|cursor|codex|copilot|windsurf|zed|aider|goose|cline|amp/i;
21
+ function pickSessionAncestor(ancestors) {
22
+ for (const row of ancestors) {
23
+ const command = row.command.trim();
24
+ if (SHELL.test(command)) continue;
25
+ if (HARNESS.test(command)) return row.pid;
26
+ }
27
+ return null;
28
+ }
29
+ function readRow(pid) {
30
+ try {
31
+ const ppid = Number.parseInt(execFileSync("ps", [
32
+ "-o",
33
+ "ppid=",
34
+ "-p",
35
+ String(pid)
36
+ ], { encoding: "utf-8" }).trim(), 10);
37
+ const command = execFileSync("ps", [
38
+ "-o",
39
+ "command=",
40
+ "-p",
41
+ String(pid)
42
+ ], { encoding: "utf-8" }).trim();
43
+ if (!Number.isFinite(ppid) || command === "") return null;
44
+ return {
45
+ pid,
46
+ ppid,
47
+ command
48
+ };
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ function readAncestors(startPid, rowFor = readRow, maxDepth = 12) {
54
+ const rows = [];
55
+ let pid = startPid;
56
+ for (let depth = 0; depth < maxDepth && pid > 1; depth++) {
57
+ const row = rowFor(pid);
58
+ if (!row) break;
59
+ rows.push(row);
60
+ if (row.ppid === pid) break;
61
+ pid = row.ppid;
62
+ }
63
+ return rows;
64
+ }
65
+ function detectSessionPid() {
66
+ if (process.platform === "win32") return null;
67
+ try {
68
+ return pickSessionAncestor(readAncestors(process.ppid));
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ //#endregion
74
+ //#region src/shared/review.ts
75
+ const THREAD_INTENTS = ["fix", "question"];
76
+ /** The thread was started by the agent (its opening message is the agent's).
77
+ * Drives rendering — avatar, head label — and never changes. */
78
+ function startedByAgent(thread) {
79
+ return thread.messages[0]?.author === "agent";
80
+ }
81
+ /** Agent-started with nothing of the reviewer's in it. Only these are excluded
82
+ * from the reviewer's counts, Send, and the finish flush — the moment the
83
+ * reviewer writes a reply into an agent thread, it carries their feedback and
84
+ * moves exactly like a thread they opened themselves. */
85
+ function untouchedAgentVoice(thread) {
86
+ return startedByAgent(thread) && !thread.messages.some((m) => m.author === "reviewer");
87
+ }
88
+ /**
89
+ * Feedback the reviewer is still owed an answer on — the delivery queue's contents,
90
+ * derived rather than remembered, so a restart cannot drop an Ask nobody collected.
91
+ * The cuts: handed over at all; not already ruled unanswered by a closed batch; not
92
+ * deliberately withheld; and the reviewer spoke last.
93
+ */
94
+ function undeliveredThreadIds(threads) {
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
+ }
97
+ const REVIEW_VERDICTS = [
98
+ "comment",
99
+ "approve",
100
+ "request-changes"
101
+ ];
102
+ const EMPTY_REVIEW = {
103
+ version: 1,
104
+ threads: []
105
+ };
106
+ /**
107
+ * Threads this changeset still owns, and the ones it has left behind. A thread is
108
+ * *active* while the diff still has somewhere to put it — its hunk, or failing that
109
+ * its file — and a changeset-level note lives as long as the changeset has files.
110
+ *
111
+ * Past threads are hidden, never deleted: a stash or a branch switch empties the diff
112
+ * for a minute and must not destroy a thread.
113
+ */
114
+ function threadsInChangeset(files, threads) {
115
+ const hunkIds = new Set(files.flatMap((f) => f.hunks.map((h) => h.id)));
116
+ const paths = new Set(files.flatMap((f) => f.oldPath ? [f.path, f.oldPath] : [f.path]));
117
+ const lives = (thread) => {
118
+ const anchor = thread.anchor;
119
+ if (anchor.kind === "changeset") return files.length > 0;
120
+ if (anchor.kind === "file") return paths.has(anchor.path);
121
+ return hunkIds.has(anchor.hunkId) || paths.has(anchor.path);
122
+ };
123
+ const active = [];
124
+ const past = [];
125
+ for (const thread of threads) (lives(thread) ? active : past).push(thread);
126
+ return {
127
+ active,
128
+ past
129
+ };
130
+ }
131
+ function describeAnchor(anchor) {
132
+ if (anchor.kind === "changeset") return "the whole changeset";
133
+ if (anchor.kind === "file") return anchor.path;
134
+ return `${anchor.path}:${anchor.line} (${anchor.side} side)`;
135
+ }
136
+ //#endregion
137
+ //#region src/server/prompt.ts
138
+ function snapshotHunk(changeset, hunkId) {
139
+ for (const file of changeset.files) {
140
+ 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");
148
+ }
149
+ return null;
150
+ }
151
+ const NPX = `npx -y @diffohq/diffo`;
152
+ /** This checkout's root — `src/server/prompt.ts` → up two. */
153
+ const CHECKOUT_ROOT = fileURLToPath(new URL("../..", import.meta.url)).replace(/\/$/, "");
154
+ const IS_DEV = process.env.ENV === "development";
155
+ /** Running the CLI straight from a checkout. Absolute on both halves — the agent
156
+ * runs it from inside the repo under review, hence the tsx *binary* rather than
157
+ * `node --import tsx`, which resolves its loader against the cwd. The
158
+ * `ENV=development` prefix travels with the invocation so the server a dev skill
159
+ * starts speaks the same dev CLI back. */
160
+ function devCliFor(checkoutRoot) {
161
+ return `ENV=development ${checkoutRoot}/node_modules/.bin/tsx ${checkoutRoot}/src/cli.ts`;
162
+ }
163
+ const CLI = IS_DEV ? devCliFor(CHECKOUT_ROOT) : NPX;
164
+ function buildCliCommands(cli) {
165
+ return {
166
+ open: cli,
167
+ poll: `${cli} poll`,
168
+ reply: `${cli} reply <threadId> --message "<your reply>"`,
169
+ comment: `${cli} comment [<file>] [--line <line>] --message "<comment>"`,
170
+ guide: `${cli} comment --message "<what the change does>"`,
171
+ end: `${cli} end`,
172
+ setup: `${cli} setup`
173
+ };
174
+ }
175
+ const CLI_COMMANDS = buildCliCommands(CLI);
176
+ /** Drift-tested against package.json's `repository` in prompt.test.ts. */
177
+ const SKILL_REPO = "DiffoHQ/diffo";
178
+ function buildInstallSkill(isDev, checkoutRoot, global = true) {
179
+ if (isDev) return `cd ${checkoutRoot} && pnpm dev:skill${global ? " --global" : ""}`;
180
+ return `npx skills add ${SKILL_REPO} --skill diffo${global ? " -g" : ""}`;
181
+ }
182
+ const INSTALL_SKILL = {
183
+ global: buildInstallSkill(IS_DEV, CHECKOUT_ROOT, true),
184
+ project: buildInstallSkill(IS_DEV, CHECKOUT_ROOT, false)
185
+ };
186
+ /**
187
+ * Payloads must stand alone: agents join without the skill (the invite modal's
188
+ * paste, a fresh session, a compacted context), so every instruction to poll
189
+ * restates how to hold the poll — attended, never detached. Mirrors the skill.
190
+ */
191
+ const POLL_STANCE = "a tracked background task if your harness has one, the foreground if not — never a detached process";
192
+ 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
+ function nextStepFor(kind, actionable) {
194
+ 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
+ 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
+ }
197
+ const ACK_NEXT_STEP = {
198
+ reply: `When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
199
+ 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
+ end: "Detached. Do not reopen or re-poll this review unless the user asks — deliver anything remaining directly in the conversation."
201
+ };
202
+ /**
203
+ * The guide doctrine — the one agent comment that orients a cold reader.
204
+ * Stated once and interpolated into every surface that teaches it (the skill,
205
+ * `help agent`, the open-time nudge below), the same way POLL_STANCE keeps the
206
+ * poll rules aligned: the surfaces phrase it at different lengths, but these
207
+ * invariants cannot drift apart.
208
+ */
209
+ const GUIDE = {
210
+ /** When one is warranted — and that silence is a valid outcome. */
211
+ when: "multi-file, structural, or subtle — skip when the diff explains itself",
212
+ /** What it contains. */
213
+ what: "one sentence on what the change does, plus a small ```mermaid diagram if a picture explains the shape better than words",
214
+ /** The line it must not cross. */
215
+ stance: "Orient reading, never pre-review: no verdicts, nothing is \"fine\"",
216
+ /** Staleness: the guide is a thread, so updates land under it. */
217
+ update: "reply to your own guide thread with a short update"
218
+ };
219
+ /**
220
+ * Printed by `diffo` (open) to a piped stdout when the review has no guide yet.
221
+ * The skill teaches the same step, but this line is what an agent WITHOUT the
222
+ * skill sees — payloads and command output must stand alone (see POLL_STANCE).
223
+ */
224
+ function guideNudge(hasGuide) {
225
+ if (hasGuide) return null;
226
+ 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}.`;
227
+ }
228
+ /**
229
+ * Printed to stderr when a poll takes the review over and the review already
230
+ * carries a guide: the new agent inherits it as orientation and updates it in
231
+ * place — a second guide would give the reviewer two.
232
+ */
233
+ function guideInherit(threadId) {
234
+ return `diffo: this review already carries the previous agent's guide (thread ${threadId}).\nRead it, and if your changes reshape the changeset update it by replying to it\n(\`${CLI} reply ${threadId} -m "…"\`) rather than posting a second guide.\n`;
235
+ }
236
+ const INTENT_LABEL = {
237
+ question: "question",
238
+ fix: "issue"
239
+ };
240
+ const INTENT_CONTRACT = {
241
+ question: "- `question` threads want an answer, not an edit. Reply in the thread; change no code for them unless the reviewer asks.",
242
+ fix: "- `issue` threads want a code change. Address each one, or push back in the thread with your reasoning."
243
+ };
244
+ const UNLABELED_CONTRACT = "- Unlabeled threads: judge from the text — a question wants an answer, not an edit.";
245
+ /** The closing note is the reviewer summing up, so it earns an answer even when it
246
+ * asks nothing — a note with no reply is the review's one dead end. */
247
+ 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.";
248
+ function intentContract(threads) {
249
+ const present = new Set(threads.filter((t) => !t.closingNote).map((t) => t.intent));
250
+ const lines = THREAD_INTENTS.filter((i) => present.has(i)).map((i) => INTENT_CONTRACT[i]);
251
+ if (present.has(void 0)) lines.push(UNLABELED_CONTRACT);
252
+ if (threads.some((t) => t.closingNote)) lines.push(CLOSING_CONTRACT);
253
+ return lines;
254
+ }
255
+ function replyProtocol(threads) {
256
+ 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."] : [];
258
+ return `## How to respond
259
+
260
+ Diffo is running locally; talk to it through its CLI.
261
+ ${batchNote}
262
+ For each thread above:
263
+
264
+ 1. Act on it.
265
+ ${[...intentContract(threads).map((line) => ` ${line}`), ...answeredNote].join("\n")}
266
+ 2. Reply to the thread (concise, addressed to the reviewer, no preamble):
267
+
268
+ ${CLI_COMMANDS.reply}
269
+
270
+ (a long reply can be piped instead: pipe it to \`${CLI} reply <threadId>\`)
271
+
272
+ A fix reply says what changed and where (\`file:line\`) — don't paste the
273
+ diff; the reviewer's browser shows your edits live. Before saying something
274
+ is fixed, verify it the cheapest honest way (run the relevant test, re-read
275
+ the change) and mention what you checked.
276
+ Reply as soon as a thread is handled; don't save replies for the end.
277
+ Replies and comment threads render GitHub-flavored markdown, and a
278
+ \`\`\`mermaid fence renders as a diagram — use one when a flow, sequence,
279
+ or state picture explains the change better than prose. Keep it small
280
+ (roughly ten nodes); it renders inside a narrow thread card.
281
+ 3. If you notice something worth a comment of its own — a potential issue,
282
+ or context that helps the reviewer read — start a thread:
283
+
284
+ ${CLI_COMMANDS.comment}
285
+
286
+ Anchor it to a line (--line), a file, or the whole changeset (no file).
287
+ It appears in the review in your voice and never counts as the reviewer's
288
+ feedback: they reply to take it up, or resolve it. Spend these sparingly —
289
+ an agent that annotates everything gets skimmed.
290
+ 4. When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to wait
291
+ for the reviewer's next feedback — ${POLL_STANCE}.
292
+
293
+ Rules:
294
+
295
+ - Change only what these threads ask about — the reviewer is mid-read, and an
296
+ unrelated edit moves the diff under them. If a correct fix has to touch other
297
+ code, say so in the thread (or in a comment thread of yours) before doing it.
298
+ - Each "commented change" above was frozen when the comment was written —
299
+ re-read the current file before editing; the code may have moved since.
300
+ - Your code edits are detected automatically and the reviewer's diff updates
301
+ live. Resolving a thread is the reviewer's call, never yours.`;
302
+ }
303
+ const FRAME_FILE_CAP = 40;
304
+ function fileLine(file) {
305
+ const letter = {
306
+ added: "A",
307
+ modified: "M",
308
+ deleted: "D",
309
+ renamed: "R"
310
+ }[file.status];
311
+ return file.status === "renamed" && file.oldPath ? `- R ${file.oldPath} → ${file.path}` : `- ${letter} ${file.path}`;
312
+ }
313
+ function specLine(changeset) {
314
+ const spec = changeset.spec.kind === "working-tree" ? "the working tree against HEAD" : `the working tree against merge-base(\`${changeset.spec.base}\`, HEAD)`;
315
+ const { files, additions, deletions } = changeset.stats;
316
+ return `The changeset under review: ${spec} — ${files} file${files === 1 ? "" : "s"}, +${additions} −${deletions}.`;
317
+ }
318
+ function changesetFrame(changeset) {
319
+ const listed = changeset.files.slice(0, FRAME_FILE_CAP).map(fileLine);
320
+ if (changeset.files.length > FRAME_FILE_CAP) listed.push(`- … and ${changeset.files.length - FRAME_FILE_CAP} more`);
321
+ return [
322
+ "## The changeset under review",
323
+ "",
324
+ specLine(changeset),
325
+ "",
326
+ ...listed
327
+ ].join("\n");
328
+ }
329
+ const SIBLING_CAP = 10;
330
+ function siblingLines(siblings) {
331
+ if (siblings.length === 0) return null;
332
+ const lines = siblings.slice(0, SIBLING_CAP).map((t) => {
333
+ const first = t.messages[0]?.text.split("\n")[0]?.slice(0, 100) ?? "";
334
+ return `- ${describeAnchor(t.anchor)} — "${first}"`;
335
+ });
336
+ if (siblings.length > SIBLING_CAP) lines.push(`- … and ${siblings.length - SIBLING_CAP} more`);
337
+ return [
338
+ "## Other review threads (context only — do not act on them here)",
339
+ "",
340
+ ...lines
341
+ ].join("\n");
342
+ }
343
+ const SNAPSHOT_LINE_CAP = 25;
344
+ function threadBlock(thread, index) {
345
+ 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
+ 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");
358
+ }
359
+ function buildThreadPrompt(thread, ctx) {
360
+ const siblings = siblingLines(ctx.siblings ?? []);
361
+ return [
362
+ `A reviewer is reading your changes in \`${ctx.repo.name}\` (branch \`${ctx.repo.branch}\`) and sent you this review thread.`,
363
+ "",
364
+ ...ctx.changeset ? [specLine(ctx.changeset), ""] : [],
365
+ threadBlock(thread, 0),
366
+ "",
367
+ ...siblings ? [siblings, ""] : [],
368
+ replyProtocol([thread]),
369
+ ""
370
+ ].join("\n");
371
+ }
372
+ function buildCoalescedPrompt(threads, ctx) {
373
+ const siblings = siblingLines(ctx.siblings ?? []);
374
+ return [
375
+ `The reviewer sent new messages on ${threads.length} review threads in \`${ctx.repo.name}\` (branch \`${ctx.repo.branch}\`).`,
376
+ "",
377
+ ...ctx.changeset ? [specLine(ctx.changeset), ""] : [],
378
+ ...threads.map((t, i) => `${threadBlock(t, i)}\n`),
379
+ ...siblings ? [siblings, ""] : [],
380
+ replyProtocol(threads),
381
+ ""
382
+ ].join("\n");
383
+ }
384
+ function buildFinishPrompt(threads, ctx, coverage) {
385
+ const repo = ctx.repo;
386
+ const sent = threads.filter((t) => t.state === "sent");
387
+ const closing = sent.find((t) => t.closingNote);
388
+ const actionable = closing ? [closing, ...sent.filter((t) => t !== closing)] : sent;
389
+ const changed = coverage.changedFiles ?? [];
390
+ const commented = coverage.commentedUnread ?? [];
391
+ const filtered = coverage.filteredOut ?? [];
392
+ const skipped = [
393
+ changed.length > 0 ? ` Changed after the reviewer read them (their read marks were revoked): ${changed.join(", ")}.` : "",
394
+ commented.length > 0 ? ` Commented on but not marked read: ${commented.join(", ")}.` : "",
395
+ filtered.length > 0 ? ` Deliberately out of scope — hidden by a filter and never read: ${filtered.join(", ")}.` : "",
396
+ coverage.skippedFiles.length > 0 ? ` Not marked read: ${coverage.skippedFiles.join(", ")}.` : ""
397
+ ].join("");
398
+ 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
+ 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
+ const owed = actionable.filter((t) => t.unanswered);
402
+ const owedLines = owed.length > 0 ? [
403
+ `${owed.length} of the threads below you were already given once, and never`,
404
+ "replied to. They are marked. Answer those first — a reply, or a",
405
+ "reason you are not acting; going quiet again is the one thing that",
406
+ "leaves the reviewer with nothing to do.",
407
+ ""
408
+ ] : [];
409
+ const parts = [
410
+ `A reviewer finished reading your changes in \`${repo.name}\` (branch \`${repo.branch}\`).`,
411
+ "",
412
+ ...verdictLine ? [verdictLine, ""] : [],
413
+ ...noteLines.length > 0 ? [...noteLines, ""] : [],
414
+ ...ctx.changeset ? [changesetFrame(ctx.changeset), ""] : [],
415
+ `Coverage: ${files}${coverage.viewedHunks}/${coverage.totalHunks} hunks read.${skipped}`,
416
+ "",
417
+ ...owedLines
418
+ ];
419
+ 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), "");
422
+ return parts.join("\n");
423
+ }
424
+ //#endregion
425
+ //#region src/cliArgs.ts
426
+ const HELP_TEXT = `diffo — review a changeset the way you'd read a book
427
+
428
+ Usage: diffo [options] open (or resume) the review for this repo
429
+ diffo <command> [...] manage the server, or talk to the review
430
+ diffo help [command] show help for a command
431
+
432
+ For the reviewer:
433
+ status Show this repo's review server, if one is running
434
+ (--json for a machine-readable answer)
435
+ stop Stop this repo's review server
436
+ setup Register diffo with the coding agents on this machine
437
+ (Claude Code, Cursor, VS Code, Copilot, and the shared
438
+ ~/.agents skills dir read by Codex, Gemini, Amp, Goose, …)
439
+
440
+ For the agent (the AI that wrote the change):
441
+ poll Wait for the reviewer's feedback (blocking long-poll;
442
+ prints one JSON payload; safe to re-run any time)
443
+ reply <threadId> Post a reply to a review thread
444
+ (--message "<text>", or pipe the text on stdin)
445
+ comment [<file>] Start a comment thread as the agent — on a line (--line),
446
+ a file, or (with no file) the whole changeset; the
447
+ reviewer replies to take it up, or resolves it
448
+ end Detach from the review politely
449
+ help agent The agent's whole protocol on one page
450
+
451
+ Options (for opening the review):
452
+ --base <branch> Review everything since forking from <branch>
453
+ (default: working tree vs HEAD)
454
+ -p, --port <port> Port to serve on (default: the port this repo last used,
455
+ else the first free one)
456
+ --no-open Don't open the browser automatically
457
+ --foreground Run the server in this terminal instead of leaving a
458
+ background one behind (Ctrl-C stops it)
459
+ -v, --version Show version
460
+ -h, --help Show this help
461
+
462
+ Examples:
463
+ diffo open the review for this repo in the browser
464
+ diffo --base main review everything since forking from main
465
+ diffo status is a server running here, and where?
466
+ diffo reply t-3 -m "fixed" answer a review thread (agent side)
467
+
468
+ Feedback lives in the review, not in any process: diffo, poll, status, stop,
469
+ and setup are all safe to re-run after an interruption.`;
470
+ const VERB_HELP = {
471
+ agent: `diffo help agent — the agent's whole protocol on one page
472
+
473
+ You are the agent that wrote the change; a human reads it live in their
474
+ browser. Diffo carries their feedback to you and your replies back inline.
475
+ Poll payloads carry their own instructions — this page is the map that
476
+ survives when context does not.
477
+
478
+ The loop:
479
+
480
+ 1. Open: run \`diffo --no-open\` from inside the repo. It returns straight
481
+ away, leaving a background server watching the working tree. Never open a
482
+ browser at the reviewer — end your message with the printed URL instead,
483
+ 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}.
489
+ 3. Listen: run \`diffo poll\` — it blocks until the reviewer acts, then prints
490
+ one JSON payload naming the threads to act on. Run it attended:
491
+ ${POLL_STANCE}.
492
+ Killed or timed out? Re-run it; feedback is held in the review, not the
493
+ poll.
494
+ 4. Act: \`[issue]\` threads want a code change; \`[question]\` threads want an
495
+ answer in the reply and no edit. Your edits reach the reviewer live.
496
+ 5. Reply: \`diffo reply <threadId> --message "<text>"\` (pipe long replies on
497
+ stdin) — concise, addressed to the reviewer. Markdown renders; a
498
+ \`\`\`mermaid fence draws a diagram.
499
+ 6. Comment (sparingly): \`diffo comment [<file>] [--line <n>] -m "<text>"\`
500
+ starts a thread in your voice — a concern, or context that helps the read.
501
+ 7. Poll again only when the whole batch is handled — a new poll tells the
502
+ reviewer you are done with the previous one.
503
+ 8. Detach: run \`diffo end\` when the review is over or the user moves on.
504
+
505
+ Rules:
506
+ - Change only what the threads ask about — the reviewer is mid-read, and an
507
+ unrelated edit moves the diff under them.
508
+ - 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.
510
+ - Resolving a thread is the reviewer's call, never yours.`,
511
+ poll: `diffo poll — wait for the reviewer's feedback
512
+
513
+ Usage: diffo poll
514
+
515
+ Blocks (streaming whitespace heartbeats) until the reviewer acts, then prints
516
+ one JSON payload naming the review threads to act on, and exits. Run it
517
+ attended — a tracked background task or the foreground, never detached: a
518
+ payload that reaches a process nobody is listening to never reaches you.
519
+ Safe to re-run any time: feedback is held in the review itself, so
520
+ nothing is lost when a poll is killed or times out — the next poll gets it.
521
+
522
+ Output: one JSON object, e.g.
523
+ {"status":"feedback","threadIds":["t-3"],"prompt":"…what to do…"}
524
+
525
+ Example:
526
+ diffo poll`,
527
+ reply: `diffo reply — post a reply to a review thread
528
+
529
+ Usage: diffo reply <threadId> --message "<text>"
530
+ … | diffo reply <threadId> (long replies: pipe on stdin)
531
+
532
+ Thread ids arrive in poll payloads. Each run posts one message, so don't
533
+ re-run a reply that succeeded.
534
+ Messages render GitHub-flavored markdown; a \`\`\`mermaid fence renders as a
535
+ diagram in the review.
536
+
537
+ Output: {"ok":true,"threadId":"t-3","state":"…","next_step":"…"}
538
+
539
+ Example:
540
+ diffo reply t-3 --message "fixed — the guard now covers the empty case"`,
541
+ comment: `diffo comment — start a comment thread as the agent
542
+
543
+ Usage: diffo comment [<file>] [--line <n>] --message "<text>"
544
+ … | diffo comment [<file>] [--line <n>] (long comments: pipe on stdin)
545
+
546
+ Anchors to a line (--line), a file, or — with no file — the whole changeset.
547
+ A potential issue, or context that helps the reviewer read: either way it is
548
+ one thread, labeled as yours, and it never counts as the reviewer's feedback
549
+ until they reply into it — then it is theirs to send — or they resolve it.
550
+ Spend these sparingly — an agent that annotates everything gets skimmed.
551
+ Messages render GitHub-flavored markdown; a \`\`\`mermaid fence renders as a
552
+ diagram in the review.
553
+
554
+ Output: {"ok":true,"threadId":"t-1","next_step":"…"}
555
+
556
+ Example:
557
+ diffo comment src/auth.ts --line 42 --message "this branch is unreachable"`,
558
+ end: `diffo end — detach from the review politely
559
+
560
+ Usage: diffo end
561
+
562
+ Ends YOUR attachment only: if another session is the attached agent it does
563
+ nothing, and says so. Safe to re-run.
564
+
565
+ Output: {"ok":true,"next_step":"…"}
566
+
567
+ Example:
568
+ diffo end`,
569
+ status: `diffo status — show this repo's review server, if one is running
570
+
571
+ Usage: diffo status [--json]
572
+
573
+ Prints the changeset summary, the server (port, pid, version), and the review
574
+ URL. Exits 0 when a server is running, 1 when none is. Safe to re-run.
575
+
576
+ --json prints one JSON object instead, e.g.
577
+ {"running":true,"port":4949,"pid":123,"version":"0.0.1","url":"http://localhost:4949"}
578
+ {"running":false}
579
+
580
+ Example:
581
+ diffo status --json`,
582
+ stop: `diffo stop — stop this repo's review server
583
+
584
+ Usage: diffo stop
585
+
586
+ Asks the server to shut down cleanly (falling back to a signal if it lingers)
587
+ and clears its registration. Safe to re-run: stopping nothing is a success,
588
+ and the review itself survives — the next \`diffo\` picks it back up.
589
+
590
+ Example:
591
+ diffo stop`,
592
+ setup: `diffo setup — register diffo with the coding agents on this machine
593
+
594
+ Usage: diffo setup
595
+
596
+ Detects Claude Code, Cursor, VS Code, and Copilot CLI, and registers diffo
597
+ with each so they know when and how to open a review. Also installs into
598
+ ~/.agents/skills — the cross-tool skills directory read by Codex, Gemini CLI,
599
+ Amp, Goose, OpenCode, and others. Safe to re-run: already-registered clients
600
+ are left as they are, and opening a review keeps installed skills fresh
601
+ automatically after upgrades.
602
+
603
+ Example:
604
+ diffo setup`
605
+ };
606
+ function helpFor(topic) {
607
+ return topic && VERB_HELP[topic] || HELP_TEXT;
608
+ }
609
+ const VERBS = /* @__PURE__ */ new Set([
610
+ "poll",
611
+ "reply",
612
+ "comment",
613
+ "end",
614
+ "setup",
615
+ "status",
616
+ "stop"
617
+ ]);
618
+ function editDistance(a, b) {
619
+ const dp = Array.from({ length: b.length + 1 }, (_, i) => i);
620
+ for (let i = 1; i <= a.length; i++) {
621
+ let diagonal = dp[0];
622
+ dp[0] = i;
623
+ for (let j = 1; j <= b.length; j++) {
624
+ const above = dp[j];
625
+ dp[j] = Math.min(above + 1, dp[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
626
+ diagonal = above;
627
+ }
628
+ }
629
+ return dp[b.length];
630
+ }
631
+ /** The closest command within two edits — one typo away, not a different word. */
632
+ function nearestVerb(input) {
633
+ let best = null;
634
+ let bestDistance = 3;
635
+ for (const verb of [...VERBS, "help"]) {
636
+ const distance = editDistance(input.toLowerCase(), verb);
637
+ if (distance < bestDistance) {
638
+ bestDistance = distance;
639
+ best = verb;
640
+ }
641
+ }
642
+ return best;
643
+ }
644
+ function unknownCommand(input) {
645
+ const suggestion = nearestVerb(input);
646
+ return {
647
+ kind: "error",
648
+ message: `unknown command '${input}'${suggestion ? ` — did you mean '${suggestion}'?` : ""}`
649
+ };
650
+ }
651
+ function parseHelp(rest) {
652
+ const topic = rest[0];
653
+ if (rest.length > 1) return {
654
+ kind: "error",
655
+ message: "help takes at most one command"
656
+ };
657
+ if (topic === void 0 || topic === "help") return { kind: "help" };
658
+ if (topic !== "agent" && !VERBS.has(topic)) return unknownCommand(topic);
659
+ return {
660
+ kind: "help",
661
+ topic
662
+ };
663
+ }
664
+ /** `parseInt` would take '80.5' and '80abc' as 80; a number is all digits or nothing. */
665
+ function parseWholeNumber(raw) {
666
+ return /^\d+$/.test(raw) ? Number.parseInt(raw, 10) : null;
667
+ }
668
+ /**
669
+ * Mirrors the characters git itself forbids in a ref name, plus a leading dash —
670
+ * which would be handed to git in argument position (e.g.
671
+ * `merge-base --upload-pack=… HEAD`). Catching these here turns a cryptic git
672
+ * failure into a plain diffo error.
673
+ */
674
+ function isInvalidBranchName(name) {
675
+ for (const ch of name) {
676
+ const code = ch.codePointAt(0) ?? 0;
677
+ if (code < 32 || code === 127) return true;
678
+ }
679
+ return /[\s~^:?*[\\]|\.\.|@\{|^[-.]|[/.]$|\.lock$/.test(name);
680
+ }
681
+ /** `parseArgs` throws on an unknown flag; turn that into a `CliCommand` error. */
682
+ function tryParse(run) {
683
+ try {
684
+ return {
685
+ ok: true,
686
+ value: run()
687
+ };
688
+ } catch (err) {
689
+ return {
690
+ ok: false,
691
+ message: err.message.split(".")[0] ?? "invalid arguments"
692
+ };
693
+ }
694
+ }
695
+ function parseCliArgs(argv) {
696
+ if (argv[0] !== void 0 && !argv[0].startsWith("-")) {
697
+ if (argv[0] === "help") return parseHelp(argv.slice(1));
698
+ if (VERBS.has(argv[0])) return parseVerb(argv[0], argv.slice(1));
699
+ return unknownCommand(argv[0]);
700
+ }
701
+ const parsed = tryParse(() => parseArgs({
702
+ args: argv,
703
+ options: {
704
+ port: {
705
+ type: "string",
706
+ short: "p"
707
+ },
708
+ base: { type: "string" },
709
+ "no-open": {
710
+ type: "boolean",
711
+ default: false
712
+ },
713
+ foreground: {
714
+ type: "boolean",
715
+ default: false
716
+ },
717
+ help: {
718
+ type: "boolean",
719
+ short: "h"
720
+ },
721
+ version: {
722
+ type: "boolean",
723
+ short: "v"
724
+ }
725
+ }
726
+ }));
727
+ if (!parsed.ok) return {
728
+ kind: "error",
729
+ message: parsed.message
730
+ };
731
+ const { values } = parsed.value;
732
+ if (values.help) return { kind: "help" };
733
+ if (values.version) return { kind: "version" };
734
+ let port;
735
+ if (values.port !== void 0) {
736
+ const parsedPort = parseWholeNumber(values.port);
737
+ if (parsedPort === null || parsedPort < 1 || parsedPort > 65535) return {
738
+ kind: "error",
739
+ message: `'${values.port}' is not a valid port`
740
+ };
741
+ port = parsedPort;
742
+ }
743
+ if (values.base !== void 0 && values.base.trim() === "") return {
744
+ kind: "error",
745
+ message: "--base needs a branch name"
746
+ };
747
+ if (values.base !== void 0 && isInvalidBranchName(values.base)) return {
748
+ kind: "error",
749
+ message: `'${values.base}' is not a valid branch name`
750
+ };
751
+ return {
752
+ kind: "run",
753
+ spec: values.base ? {
754
+ kind: "branch",
755
+ base: values.base
756
+ } : { kind: "working-tree" },
757
+ port,
758
+ open: !values["no-open"],
759
+ foreground: values.foreground === true
760
+ };
761
+ }
762
+ function parseVerb(verb, rest) {
763
+ const parsed = tryParse(() => parseArgs({
764
+ args: rest,
765
+ allowPositionals: true,
766
+ options: {
767
+ message: {
768
+ type: "string",
769
+ short: "m"
770
+ },
771
+ line: { type: "string" },
772
+ json: { type: "boolean" },
773
+ help: {
774
+ type: "boolean",
775
+ short: "h"
776
+ }
777
+ }
778
+ }));
779
+ if (!parsed.ok) return {
780
+ kind: "error",
781
+ message: parsed.message
782
+ };
783
+ const { values, positionals } = parsed.value;
784
+ if (values.help) return {
785
+ kind: "help",
786
+ topic: verb
787
+ };
788
+ if (values.json && verb !== "status") return {
789
+ kind: "error",
790
+ message: `'${verb}' takes no --json`
791
+ };
792
+ if (verb === "poll" || verb === "end" || verb === "setup" || verb === "status" || verb === "stop") {
793
+ if (positionals.length > 0) return {
794
+ kind: "error",
795
+ message: `'${verb}' takes no arguments`
796
+ };
797
+ if (values.message !== void 0 || values.line !== void 0) return {
798
+ kind: "error",
799
+ message: `'${verb}' takes no options`
800
+ };
801
+ if (verb === "status") return {
802
+ kind: "status",
803
+ json: values.json === true
804
+ };
805
+ return { kind: verb };
806
+ }
807
+ if (verb === "reply") {
808
+ const threadId = positionals[0];
809
+ if (!threadId) return {
810
+ kind: "error",
811
+ message: "reply needs a thread id"
812
+ };
813
+ if (positionals.length > 1) return {
814
+ kind: "error",
815
+ message: "pass the reply with --message or on stdin, not as an argument"
816
+ };
817
+ if (values.line !== void 0) return {
818
+ kind: "error",
819
+ message: "reply takes no --line"
820
+ };
821
+ return {
822
+ kind: "reply",
823
+ threadId,
824
+ message: values.message ?? null
825
+ };
826
+ }
827
+ const file = positionals[0] ?? null;
828
+ if (positionals.length > 1) return {
829
+ kind: "error",
830
+ message: "pass the comment with --message or on stdin, not as an argument"
831
+ };
832
+ let line = null;
833
+ if (values.line !== void 0) {
834
+ line = parseWholeNumber(values.line);
835
+ if (line === null || line < 1) return {
836
+ kind: "error",
837
+ message: `'${values.line}' is not a valid line number`
838
+ };
839
+ }
840
+ if (line !== null && file === null) return {
841
+ kind: "error",
842
+ message: "a --line needs a file to anchor to"
843
+ };
844
+ return {
845
+ kind: "comment",
846
+ file,
847
+ line,
848
+ message: values.message ?? null
849
+ };
850
+ }
851
+ //#endregion
852
+ //#region src/server/git.ts
853
+ const MAX_BUFFER = 52428800;
854
+ const SNIFF_BYTES = 8192;
855
+ /** Largest untracked file whose content the diff will inline. Deliberately not
856
+ * `MAX_BUFFER`: that bounds a subprocess's stdout, and the two limits move for
857
+ * different reasons. */
858
+ const MAX_INLINE_BYTES = 2097152;
859
+ const DIFF_FLAGS = [
860
+ "--no-ext-diff",
861
+ "--no-color",
862
+ "-M"
863
+ ];
864
+ function git(root, args) {
865
+ return execFileSync("git", args, {
866
+ cwd: root,
867
+ encoding: "utf-8",
868
+ stdio: [
869
+ "ignore",
870
+ "pipe",
871
+ "pipe"
872
+ ],
873
+ maxBuffer: MAX_BUFFER
874
+ });
875
+ }
876
+ const execFileAsync = promisify(execFile);
877
+ /**
878
+ * The same call, off the event loop. Every `git` here is the process waiting on a
879
+ * child, which `execFileSync` spends blocking the loop — one recompute on a
880
+ * 17,639-directory repo measured ~700ms of answering nothing.
881
+ *
882
+ * The sync twins stay: startup builds the first changeset before anything is
883
+ * served, where blocking is free and an `await` in a constructor is not.
884
+ */
885
+ async function gitAsync(root, args) {
886
+ const { stdout } = await execFileAsync("git", args, {
887
+ cwd: root,
888
+ encoding: "utf-8",
889
+ maxBuffer: MAX_BUFFER
890
+ });
891
+ return stdout;
892
+ }
893
+ function findRepoRoot(cwd) {
894
+ try {
895
+ return git(cwd, ["rev-parse", "--show-toplevel"]).trim();
896
+ } catch {
897
+ return null;
898
+ }
899
+ }
900
+ function getRepoName(root) {
901
+ return basename(root);
902
+ }
903
+ function getBranchName(root) {
904
+ try {
905
+ return git(root, [
906
+ "rev-parse",
907
+ "--abbrev-ref",
908
+ "HEAD"
909
+ ]).trim();
910
+ } catch {
911
+ return "";
912
+ }
913
+ }
914
+ /**
915
+ * Does this branch still exist here? Answers **true** when git can't say: the
916
+ * caller deletes the reviewer's threads on a false, so every ambiguous case has
917
+ * to fall on the side of keeping them.
918
+ */
919
+ function branchExists(root, branch) {
920
+ try {
921
+ git(root, [
922
+ "show-ref",
923
+ "--verify",
924
+ "--quiet",
925
+ `refs/heads/${branch}`
926
+ ]);
927
+ return true;
928
+ } catch (err) {
929
+ return err.status !== 1;
930
+ }
931
+ }
932
+ /**
933
+ * The name of the linked worktree we're in, or null in the main one. A linked
934
+ * worktree's git dir is `<main>/.git/worktrees/<name>`, while the main worktree's
935
+ * is a plain `.git`. Two worktrees of the same repo are otherwise
936
+ * indistinguishable in the header.
937
+ */
938
+ function getWorktreeName(root) {
939
+ try {
940
+ const gitDir = git(root, ["rev-parse", "--git-dir"]).trim();
941
+ const marker = `${sep}worktrees${sep}`;
942
+ if (!gitDir.includes(marker)) return null;
943
+ return basename(gitDir);
944
+ } catch {
945
+ return null;
946
+ }
947
+ }
948
+ function getHeadSha(root) {
949
+ try {
950
+ return git(root, ["rev-parse", "HEAD"]).trim();
951
+ } catch {
952
+ return null;
953
+ }
954
+ }
955
+ /**
956
+ * Is `ancestor` in `descendant`'s history? Null when git couldn't answer (a sha
957
+ * it no longer knows, a transient failure) — the callers lean opposite ways on
958
+ * ambiguity (keep a landed marker, but never stamp one), so the ambiguity has
959
+ * to survive the return.
960
+ */
961
+ function isAncestor(root, ancestor, descendant) {
962
+ try {
963
+ git(root, [
964
+ "merge-base",
965
+ "--is-ancestor",
966
+ ancestor,
967
+ descendant
968
+ ]);
969
+ return true;
970
+ } catch (err) {
971
+ return err.status === 1 ? false : null;
972
+ }
973
+ }
974
+ function getCommitSubject(root, sha) {
975
+ try {
976
+ return git(root, [
977
+ "log",
978
+ "-1",
979
+ "--format=%s",
980
+ sha
981
+ ]).trim();
982
+ } catch {
983
+ return "";
984
+ }
985
+ }
986
+ function hasHead(root) {
987
+ try {
988
+ git(root, [
989
+ "rev-parse",
990
+ "--verify",
991
+ "HEAD"
992
+ ]);
993
+ return true;
994
+ } catch {
995
+ return false;
996
+ }
997
+ }
998
+ var MissingBaseError = class extends Error {
999
+ base;
1000
+ constructor(base) {
1001
+ super(`base branch '${base}' not found`);
1002
+ this.base = base;
1003
+ }
1004
+ };
1005
+ /**
1006
+ * The base a clean working tree most likely wants reviewing against: the
1007
+ * remote's default branch when origin/HEAD names one, else main/master.
1008
+ * Null when the repo offers no candidate, when HEAD *is* the candidate, or
1009
+ * when nothing was committed since forking from it — no hint is owed then.
1010
+ */
1011
+ function suggestedBase(root) {
1012
+ try {
1013
+ const candidate = defaultBranch(root);
1014
+ if (candidate === null) return null;
1015
+ if (candidate === git(root, [
1016
+ "rev-parse",
1017
+ "--abbrev-ref",
1018
+ "HEAD"
1019
+ ]).trim()) return null;
1020
+ const mergeBase = git(root, [
1021
+ "merge-base",
1022
+ candidate,
1023
+ "HEAD"
1024
+ ]).trim();
1025
+ const commits = Number.parseInt(git(root, [
1026
+ "rev-list",
1027
+ "--count",
1028
+ `${mergeBase}..HEAD`
1029
+ ]).trim(), 10);
1030
+ return Number.isInteger(commits) && commits > 0 ? {
1031
+ base: candidate,
1032
+ commits
1033
+ } : null;
1034
+ } catch {
1035
+ return null;
1036
+ }
1037
+ }
1038
+ function defaultBranch(root) {
1039
+ const candidates = [];
1040
+ try {
1041
+ const ref = git(root, [
1042
+ "rev-parse",
1043
+ "--abbrev-ref",
1044
+ "origin/HEAD"
1045
+ ]).trim();
1046
+ const short = ref.replace(/^origin\//, "");
1047
+ if (short) candidates.push(short, ref);
1048
+ } catch {}
1049
+ candidates.push("main", "master");
1050
+ for (const name of candidates) try {
1051
+ git(root, [
1052
+ "rev-parse",
1053
+ "--verify",
1054
+ "--quiet",
1055
+ name
1056
+ ]);
1057
+ return name;
1058
+ } catch {}
1059
+ return null;
1060
+ }
1061
+ function getRawDiff(root, spec) {
1062
+ if (spec.kind === "working-tree") {
1063
+ if (!hasHead(root)) return "";
1064
+ return git(root, [
1065
+ "diff",
1066
+ ...DIFF_FLAGS,
1067
+ "HEAD"
1068
+ ]);
1069
+ }
1070
+ let mergeBase;
1071
+ try {
1072
+ mergeBase = git(root, [
1073
+ "merge-base",
1074
+ spec.base,
1075
+ "HEAD"
1076
+ ]).trim();
1077
+ } catch {
1078
+ throw new MissingBaseError(spec.base);
1079
+ }
1080
+ return git(root, [
1081
+ "diff",
1082
+ ...DIFF_FLAGS,
1083
+ mergeBase
1084
+ ]);
1085
+ }
1086
+ function getStagedPaths(root) {
1087
+ try {
1088
+ const out = git(root, [
1089
+ "diff",
1090
+ "--name-only",
1091
+ "--cached"
1092
+ ]).trim();
1093
+ return new Set(out ? out.split("\n") : []);
1094
+ } catch {
1095
+ return /* @__PURE__ */ new Set();
1096
+ }
1097
+ }
1098
+ function getUntrackedPaths(root) {
1099
+ const out = git(root, [
1100
+ "ls-files",
1101
+ "--others",
1102
+ "--exclude-standard"
1103
+ ]).trim();
1104
+ return out ? out.split("\n") : [];
1105
+ }
1106
+ function binaryStub(file) {
1107
+ return [
1108
+ `diff --git a/${file} b/${file}`,
1109
+ "new file mode 100644",
1110
+ "index 0000000..0000001",
1111
+ `Binary files /dev/null and b/${file} differ`
1112
+ ].join("\n");
1113
+ }
1114
+ /** One scratch page for the sniff below. 8KB is past `Buffer.poolSize / 2`, so a
1115
+ * fresh `allocUnsafe` per file misses the shared pool and mallocs. The sniff is
1116
+ * synchronous and never re-entered, so reuse is safe. */
1117
+ const sniffBuffer = Buffer.allocUnsafe(SNIFF_BYTES);
1118
+ /**
1119
+ * Git's own test: a NUL byte in the first 8KB means binary. Read 8KB, not the
1120
+ * file — `readFileSync` pulled the whole thing in to look at its first page, so
1121
+ * an untracked 2GB core dump cost a 2GB allocation on every recompute.
1122
+ */
1123
+ function looksBinary(absPath, size) {
1124
+ if (size === 0) return false;
1125
+ let fd = null;
1126
+ try {
1127
+ fd = openSync(absPath, "r");
1128
+ const read = readSync(fd, sniffBuffer, 0, Math.min(size, SNIFF_BYTES), 0);
1129
+ for (let i = 0; i < read; i++) if (sniffBuffer[i] === 0) return true;
1130
+ return false;
1131
+ } catch {
1132
+ return true;
1133
+ } finally {
1134
+ if (fd !== null) closeSync(fd);
1135
+ }
1136
+ }
1137
+ function getUntrackedDiff(root) {
1138
+ return patchesForUntracked(root, getUntrackedPaths(root));
1139
+ }
1140
+ function patchesForUntracked(root, files) {
1141
+ const patches = [];
1142
+ for (const file of files) {
1143
+ const absPath = join(root, file);
1144
+ let stats;
1145
+ try {
1146
+ stats = lstatSync(absPath);
1147
+ } catch {
1148
+ continue;
1149
+ }
1150
+ if (stats.isSymbolicLink()) {
1151
+ const target = readlinkSync(absPath, "utf-8");
1152
+ patches.push([
1153
+ `diff --git a/${file} b/${file}`,
1154
+ "new file mode 120000",
1155
+ "index 0000000..0000001",
1156
+ "--- /dev/null",
1157
+ `+++ b/${file}`,
1158
+ "@@ -0,0 +1 @@",
1159
+ `+${target}`,
1160
+ "\"
1161
+ ].join("\n"));
1162
+ continue;
1163
+ }
1164
+ if (!stats.isFile()) continue;
1165
+ if (stats.size > MAX_INLINE_BYTES || looksBinary(absPath, stats.size)) {
1166
+ patches.push(binaryStub(file));
1167
+ continue;
1168
+ }
1169
+ let content;
1170
+ try {
1171
+ content = readFileSync(absPath, "utf-8");
1172
+ } catch {
1173
+ continue;
1174
+ }
1175
+ const trailingNewline = content.endsWith("\n");
1176
+ const lines = trailingNewline ? content.slice(0, -1).split("\n") : content.split("\n");
1177
+ if (content === "") {
1178
+ patches.push([
1179
+ `diff --git a/${file} b/${file}`,
1180
+ "new file mode 100644",
1181
+ "index 0000000..0000001"
1182
+ ].join("\n"));
1183
+ continue;
1184
+ }
1185
+ const body = `+${lines.join("\n+")}`;
1186
+ patches.push([
1187
+ `diff --git a/${file} b/${file}`,
1188
+ "new file mode 100644",
1189
+ "index 0000000..0000001",
1190
+ "--- /dev/null",
1191
+ `+++ b/${file}`,
1192
+ `@@ -0,0 +1,${lines.length} @@`,
1193
+ trailingNewline ? body : `${body}\n\`
1194
+ ].join("\n"));
1195
+ }
1196
+ return patches.join("\n");
1197
+ }
1198
+ function getFullDiff(root, spec) {
1199
+ return [getRawDiff(root, spec), getUntrackedDiff(root)].filter(Boolean).join("\n");
1200
+ }
1201
+ async function hasHeadAsync(root) {
1202
+ try {
1203
+ await gitAsync(root, [
1204
+ "rev-parse",
1205
+ "--verify",
1206
+ "HEAD"
1207
+ ]);
1208
+ return true;
1209
+ } catch {
1210
+ return false;
1211
+ }
1212
+ }
1213
+ async function getBranchNameAsync(root) {
1214
+ try {
1215
+ return (await gitAsync(root, [
1216
+ "rev-parse",
1217
+ "--abbrev-ref",
1218
+ "HEAD"
1219
+ ])).trim();
1220
+ } catch {
1221
+ return "";
1222
+ }
1223
+ }
1224
+ async function getStagedPathsAsync(root) {
1225
+ try {
1226
+ const out = (await gitAsync(root, [
1227
+ "diff",
1228
+ "--name-only",
1229
+ "--cached"
1230
+ ])).trim();
1231
+ return new Set(out ? out.split("\n") : []);
1232
+ } catch {
1233
+ return /* @__PURE__ */ new Set();
1234
+ }
1235
+ }
1236
+ async function getRawDiffAsync(root, spec) {
1237
+ if (spec.kind === "working-tree") {
1238
+ if (!await hasHeadAsync(root)) return "";
1239
+ return gitAsync(root, [
1240
+ "diff",
1241
+ ...DIFF_FLAGS,
1242
+ "HEAD"
1243
+ ]);
1244
+ }
1245
+ let mergeBase;
1246
+ try {
1247
+ mergeBase = (await gitAsync(root, [
1248
+ "merge-base",
1249
+ spec.base,
1250
+ "HEAD"
1251
+ ])).trim();
1252
+ } catch {
1253
+ throw new MissingBaseError(spec.base);
1254
+ }
1255
+ return gitAsync(root, [
1256
+ "diff",
1257
+ ...DIFF_FLAGS,
1258
+ mergeBase
1259
+ ]);
1260
+ }
1261
+ /**
1262
+ * The untracked half. `ls-files` walks the worktree honouring gitignore — ~540ms
1263
+ * of the ~700ms on the repo that prompted this — so it goes async. The per-file
1264
+ * reads below it are bounded by `MAX_INLINE_BYTES` and stay synchronous.
1265
+ */
1266
+ async function getUntrackedDiffAsync(root) {
1267
+ const paths = (await gitAsync(root, [
1268
+ "ls-files",
1269
+ "--others",
1270
+ "--exclude-standard"
1271
+ ])).trim();
1272
+ return patchesForUntracked(root, paths ? paths.split("\n") : []);
1273
+ }
1274
+ async function getFullDiffAsync(root, spec) {
1275
+ const [raw, untracked] = await Promise.all([getRawDiffAsync(root, spec), getUntrackedDiffAsync(root)]);
1276
+ return [raw, untracked].filter(Boolean).join("\n");
1277
+ }
1278
+ function isSafeRepoPath(root, relPath) {
1279
+ return resolve(root, relPath).startsWith(resolve(root) + sep);
1280
+ }
1281
+ function getHeadFileBytes(root, relPath) {
1282
+ if (!isSafeRepoPath(root, relPath)) return null;
1283
+ const full = resolve(root, relPath);
1284
+ try {
1285
+ const stats = lstatSync(full);
1286
+ if (stats.isSymbolicLink()) return Buffer.from(readlinkSync(full, "utf-8"));
1287
+ if (!stats.isFile()) return null;
1288
+ const realRoot = realpathSync(resolve(root));
1289
+ const real = realpathSync(full);
1290
+ if (real !== realRoot && !real.startsWith(realRoot + sep)) return null;
1291
+ return readFileSync(full);
1292
+ } catch {
1293
+ return null;
1294
+ }
1295
+ }
1296
+ function getBaseFileBytes(root, ref, relPath) {
1297
+ if (!isSafeRepoPath(root, relPath)) return null;
1298
+ try {
1299
+ return execFileSync("git", ["show", `${ref}:${relPath}`], {
1300
+ cwd: root,
1301
+ stdio: [
1302
+ "ignore",
1303
+ "pipe",
1304
+ "pipe"
1305
+ ],
1306
+ maxBuffer: MAX_BUFFER
1307
+ });
1308
+ } catch {
1309
+ return null;
1310
+ }
1311
+ }
1312
+ function resolveBaseRef(root, spec) {
1313
+ if (spec.kind === "working-tree") return "HEAD";
1314
+ try {
1315
+ return git(root, [
1316
+ "merge-base",
1317
+ spec.base,
1318
+ "HEAD"
1319
+ ]).trim();
1320
+ } catch {
1321
+ throw new MissingBaseError(spec.base);
1322
+ }
1323
+ }
1324
+ //#endregion
1325
+ //#region src/server/db.ts
1326
+ const require = createRequire(import.meta.url);
1327
+ /** Signal 0 asks "does this process exist?" without touching it; EPERM is still
1328
+ * a yes (it exists, it just isn't ours). */
1329
+ function pidAlive$1(pid) {
1330
+ try {
1331
+ process.kill(pid, 0);
1332
+ return true;
1333
+ } catch (err) {
1334
+ return err.code === "EPERM";
1335
+ }
1336
+ }
1337
+ function defaultDbPath() {
1338
+ return process.env.DIFFO_DB || join(homedir(), ".diffo", "diffo.db");
1339
+ }
1340
+ /** node:sqlite still emits an ExperimentalWarning on load — swallow exactly that
1341
+ * one so every CLI run isn't noisy. */
1342
+ function loadSqlite() {
1343
+ const originalEmit = process.emitWarning.bind(process);
1344
+ process.emitWarning = ((warning, ...rest) => {
1345
+ if (String(warning instanceof Error ? warning.message : warning).includes("SQLite")) return;
1346
+ return originalEmit(warning, ...rest);
1347
+ });
1348
+ try {
1349
+ return require("node:sqlite");
1350
+ } finally {
1351
+ process.emitWarning = originalEmit;
1352
+ }
1353
+ }
1354
+ /** A bump DROPS the affected table rather than migrating it (pre-release). */
1355
+ const SCHEMA_VERSION = 2;
1356
+ var DiffoDb = class {
1357
+ db;
1358
+ path;
1359
+ constructor(path = defaultDbPath()) {
1360
+ this.path = path;
1361
+ mkdirSync(dirname(path), {
1362
+ recursive: true,
1363
+ mode: 448
1364
+ });
1365
+ const { DatabaseSync } = loadSqlite();
1366
+ this.db = new DatabaseSync(path);
1367
+ chmodSync(path, 384);
1368
+ this.db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 3000; PRAGMA journal_size_limit = 4194304;");
1369
+ const { user_version: version } = this.db.prepare("PRAGMA user_version").get();
1370
+ if (version <= SCHEMA_VERSION) {
1371
+ const known = /* @__PURE__ */ new Set([
1372
+ "reviews",
1373
+ "servers",
1374
+ "repo_ports"
1375
+ ]);
1376
+ const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
1377
+ for (const { name } of tables) if (!known.has(name)) this.db.exec(`DROP TABLE IF EXISTS "${name.replaceAll("\"", "\"\"")}"`);
1378
+ }
1379
+ if (version < SCHEMA_VERSION) this.db.exec("DROP TABLE IF EXISTS reviews");
1380
+ this.db.exec(`
1381
+ CREATE TABLE IF NOT EXISTS reviews (
1382
+ repo_path TEXT NOT NULL,
1383
+ branch TEXT NOT NULL,
1384
+ base TEXT NOT NULL,
1385
+ state_json TEXT NOT NULL,
1386
+ updated_at TEXT NOT NULL,
1387
+ PRIMARY KEY (repo_path, branch, base)
1388
+ );
1389
+ CREATE TABLE IF NOT EXISTS servers (
1390
+ repo_path TEXT PRIMARY KEY,
1391
+ port INTEGER NOT NULL,
1392
+ pid INTEGER NOT NULL,
1393
+ started_at TEXT NOT NULL
1394
+ );
1395
+ CREATE TABLE IF NOT EXISTS repo_ports (
1396
+ repo_path TEXT PRIMARY KEY,
1397
+ port INTEGER NOT NULL
1398
+ );
1399
+ PRAGMA user_version = ${SCHEMA_VERSION};
1400
+ `);
1401
+ this.pruneVanishedRepos();
1402
+ }
1403
+ /**
1404
+ * Rows about a worktree that is gone can never be reached again — the path *is*
1405
+ * the key. An unmounted volume looks the same as a deleted worktree, so its rows
1406
+ * go too; the `servers` row needs its process dead as well, or a live server's
1407
+ * claim could be dropped over a moment of unreachability.
1408
+ */
1409
+ pruneVanishedRepos() {
1410
+ for (const { repo_path } of this.distinctRepoPaths("reviews")) if (!existsSync(repo_path)) this.db.prepare("DELETE FROM reviews WHERE repo_path = ?").run(repo_path);
1411
+ for (const { repo_path } of this.distinctRepoPaths("repo_ports")) if (!existsSync(repo_path)) this.db.prepare("DELETE FROM repo_ports WHERE repo_path = ?").run(repo_path);
1412
+ const servers = this.db.prepare("SELECT repo_path, pid FROM servers").all();
1413
+ for (const { repo_path, pid } of servers) if (!existsSync(repo_path) && !pidAlive$1(pid)) this.db.prepare("DELETE FROM servers WHERE repo_path = ?").run(repo_path);
1414
+ this.pruneStaleReviews();
1415
+ }
1416
+ /**
1417
+ * Reviews the reviewer can never return to: a branch that no longer exists, or
1418
+ * one untouched for `REVIEW_TTL_DAYS`. Branch existence is asked of git rather
1419
+ * than assumed — if git can't answer the row is kept, because guessing here
1420
+ * deletes threads.
1421
+ */
1422
+ pruneStaleReviews() {
1423
+ const cutoff = (/* @__PURE__ */ new Date(Date.now() - 5184e6)).toISOString();
1424
+ const stale = this.db.prepare("DELETE FROM reviews WHERE updated_at < ?").run(cutoff);
1425
+ if (Number(stale.changes) > 0) {
1426
+ this.checkpoint();
1427
+ return;
1428
+ }
1429
+ const rows = this.db.prepare("SELECT DISTINCT repo_path, branch FROM reviews").all();
1430
+ for (const { repo_path, branch } of rows) {
1431
+ if (branch === "" || branchExists(repo_path, branch)) continue;
1432
+ this.db.prepare("DELETE FROM reviews WHERE repo_path = ? AND branch = ?").run(repo_path, branch);
1433
+ }
1434
+ }
1435
+ /** Fold the write-ahead log back into the database file and truncate it. WAL
1436
+ * only shrinks at a checkpoint, which a long-lived server never reaches. */
1437
+ checkpoint() {
1438
+ try {
1439
+ this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1440
+ } catch {}
1441
+ }
1442
+ distinctRepoPaths(table) {
1443
+ return this.db.prepare(`SELECT DISTINCT repo_path FROM ${table}`).all();
1444
+ }
1445
+ getReview(scope) {
1446
+ return this.db.prepare("SELECT state_json FROM reviews WHERE repo_path = ? AND branch = ? AND base = ?").get(scope.repoPath, scope.branch, scope.base)?.state_json ?? null;
1447
+ }
1448
+ setReview(scope, stateJson) {
1449
+ this.db.prepare(`INSERT INTO reviews (repo_path, branch, base, state_json, updated_at) VALUES (?, ?, ?, ?, ?)
1450
+ ON CONFLICT(repo_path, branch, base) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at`).run(scope.repoPath, scope.branch, scope.base, stateJson, (/* @__PURE__ */ new Date()).toISOString());
1451
+ }
1452
+ /**
1453
+ * Claim this repo for a starting server — atomically, so the row is a lock and
1454
+ * not merely a note: two servers hold separate copies of the same review and
1455
+ * `setReview` writes the whole blob, so one would erase the other's threads.
1456
+ * `claimed` comes from the insert itself, the only answer two lookalike servers
1457
+ * can't confuse.
1458
+ */
1459
+ claimServer(repoPath, port, pid) {
1460
+ const { changes } = this.db.prepare(`INSERT INTO servers (repo_path, port, pid, started_at) VALUES (?, ?, ?, ?)
1461
+ ON CONFLICT(repo_path) DO NOTHING`).run(repoPath, port, pid, (/* @__PURE__ */ new Date()).toISOString());
1462
+ return {
1463
+ claimed: Number(changes) === 1,
1464
+ holder: this.getServer(repoPath)
1465
+ };
1466
+ }
1467
+ getPreferredPort(repoPath) {
1468
+ return this.db.prepare("SELECT port FROM repo_ports WHERE repo_path = ?").get(repoPath)?.port ?? null;
1469
+ }
1470
+ setPreferredPort(repoPath, port) {
1471
+ this.db.prepare(`INSERT INTO repo_ports (repo_path, port) VALUES (?, ?)
1472
+ ON CONFLICT(repo_path) DO UPDATE SET port = excluded.port`).run(repoPath, port);
1473
+ }
1474
+ getServer(repoPath) {
1475
+ const row = this.db.prepare("SELECT port, pid, started_at FROM servers WHERE repo_path = ?").get(repoPath);
1476
+ return row ? {
1477
+ repoPath,
1478
+ port: row.port,
1479
+ pid: row.pid,
1480
+ startedAt: row.started_at
1481
+ } : null;
1482
+ }
1483
+ /** Remove a registration — but only the one being asked about. A dying server
1484
+ * passes its own port so it can't wipe the row of a newer server that already
1485
+ * replaced it. */
1486
+ removeServer(repoPath, port) {
1487
+ if (port === void 0) this.db.prepare("DELETE FROM servers WHERE repo_path = ?").run(repoPath);
1488
+ else this.db.prepare("DELETE FROM servers WHERE repo_path = ? AND port = ?").run(repoPath, port);
1489
+ }
1490
+ close() {
1491
+ this.db.close();
1492
+ }
1493
+ };
1494
+ //#endregion
1495
+ //#region src/version.ts
1496
+ /**
1497
+ * This build's version, read from our own package.json. The CLI and the server both
1498
+ * report it, and the CLI compares the two: a running server from another build gets
1499
+ * replaced, not reused.
1500
+ *
1501
+ * The walk-up exists because this module sits at a different depth in dev (src/) and
1502
+ * in the bundle (dist/); the name check keeps a stray package.json out of it.
1503
+ */
1504
+ const VERSION = (() => {
1505
+ let dir = dirname(fileURLToPath(import.meta.url));
1506
+ for (let depth = 0; depth < 3; depth++) {
1507
+ dir = resolve(dir, "..");
1508
+ try {
1509
+ const pkg = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf-8"));
1510
+ if (pkg.name === "diffo" && typeof pkg.version === "string") return pkg.version;
1511
+ } catch {}
1512
+ }
1513
+ return "0.0.0";
1514
+ })();
1515
+ //#endregion
1516
+ //#region src/server/delivery.ts
1517
+ const STALL_AFTER_MS = 3e5;
1518
+ const REPLY_GRACE_MS = 9e4;
1519
+ /** A re-poll within this window of a delivery reads as "hasn't started yet". */
1520
+ const BATCH_SETTLE_MS = 2e3;
1521
+ /** Signal 0 probes existence without touching the process; EPERM still proves it exists. */
1522
+ function pidAlive(pid) {
1523
+ try {
1524
+ process.kill(pid, 0);
1525
+ return true;
1526
+ } catch (err) {
1527
+ return err.code === "EPERM";
1528
+ }
1529
+ }
1530
+ var DeliveryQueue = class DeliveryQueue {
1531
+ stallAfterMs;
1532
+ replyGraceMs;
1533
+ liveness;
1534
+ batchSettleMs;
1535
+ buckets = /* @__PURE__ */ new Map();
1536
+ scope = "";
1537
+ waiter = null;
1538
+ awaitingReply = false;
1539
+ /** Thread id -> epoch ms it was delivered. */
1540
+ deliveredAt = /* @__PURE__ */ new Map();
1541
+ listeners = /* @__PURE__ */ new Set();
1542
+ stalled = false;
1543
+ stallTimer = null;
1544
+ betweenPolls = false;
1545
+ graceTimer = null;
1546
+ lastDetach = null;
1547
+ since = Date.now();
1548
+ lastDetail = {
1549
+ state: "waiting",
1550
+ reason: "no-agent"
1551
+ };
1552
+ owner = null;
1553
+ batch = null;
1554
+ batchWatch = null;
1555
+ waiterWatch = null;
1556
+ batchListeners = /* @__PURE__ */ new Set();
1557
+ constructor(stallAfterMs = STALL_AFTER_MS, replyGraceMs = REPLY_GRACE_MS, liveness = {}, batchSettleMs = BATCH_SETTLE_MS) {
1558
+ this.stallAfterMs = stallAfterMs;
1559
+ this.replyGraceMs = replyGraceMs;
1560
+ this.liveness = liveness;
1561
+ this.batchSettleMs = batchSettleMs;
1562
+ }
1563
+ onBatchClosed(listener) {
1564
+ this.batchListeners.add(listener);
1565
+ return () => this.batchListeners.delete(listener);
1566
+ }
1567
+ closeBatch(reason) {
1568
+ const batch = this.batch;
1569
+ if (!batch) return;
1570
+ const closed = {
1571
+ threadIds: batch.threadIds,
1572
+ unanswered: [...this.deliveredAt.keys()],
1573
+ deliveredAt: batch.deliveredAt,
1574
+ closedAt: Date.now(),
1575
+ reason
1576
+ };
1577
+ this.batch = null;
1578
+ this.deliveredAt.clear();
1579
+ this.awaitingReply = false;
1580
+ this.stalled = false;
1581
+ this.clearStall();
1582
+ this.clearBatchWatch();
1583
+ for (const listener of this.batchListeners) listener(closed);
1584
+ }
1585
+ armBatchWatch() {
1586
+ this.clearBatchWatch();
1587
+ const isAlive = this.liveness.isAlive ?? pidAlive;
1588
+ const timer = setInterval(() => {
1589
+ if (!this.batch) return this.clearBatchWatch();
1590
+ const pid = this.owner;
1591
+ if (pid === null || isAlive(pid)) return;
1592
+ this.closeBatch("gone");
1593
+ this.notify();
1594
+ }, this.liveness.checkEveryMs ?? 5e3);
1595
+ timer.unref?.();
1596
+ this.batchWatch = timer;
1597
+ }
1598
+ clearBatchWatch() {
1599
+ if (this.batchWatch !== null) clearInterval(this.batchWatch);
1600
+ this.batchWatch = null;
1601
+ }
1602
+ static clean(pid) {
1603
+ return pid !== null && Number.isInteger(pid) && pid > 1 ? pid : null;
1604
+ }
1605
+ ownerConnected() {
1606
+ if (this.owner === null) return false;
1607
+ if (this.waiter === null && this.batch === null) return false;
1608
+ return (this.liveness.isAlive ?? pidAlive)(this.owner);
1609
+ }
1610
+ ownerPid() {
1611
+ return this.ownerConnected() ? this.owner : null;
1612
+ }
1613
+ /**
1614
+ * A poll attaches for its session; the newest poll always carries the review.
1615
+ * Returns the pid of a still-live session it took the review from, so both
1616
+ * sides can say a hand-off happened. Null when the review was free.
1617
+ */
1618
+ claimSession(pid) {
1619
+ const claimant = DeliveryQueue.clean(pid);
1620
+ const previous = this.ownerConnected() ? this.owner : null;
1621
+ this.owner = claimant;
1622
+ return previous !== null && previous !== claimant ? previous : null;
1623
+ }
1624
+ /**
1625
+ * A non-poll verb (a `reply`) named its session. It may adopt an unowned
1626
+ * review, but never take one off the session that holds it.
1627
+ */
1628
+ noteSession(pid) {
1629
+ const caller = DeliveryQueue.clean(pid);
1630
+ if (caller === null) return;
1631
+ if (this.owner === null) this.owner = caller;
1632
+ }
1633
+ bucket() {
1634
+ let bucket = this.buckets.get(this.scope);
1635
+ if (!bucket) {
1636
+ bucket = {
1637
+ threads: /* @__PURE__ */ new Set(),
1638
+ finish: null
1639
+ };
1640
+ this.buckets.set(this.scope, bucket);
1641
+ }
1642
+ return bucket;
1643
+ }
1644
+ rescope(scope) {
1645
+ if (scope === this.scope) return;
1646
+ this.scope = scope;
1647
+ if (this.hasPending() && this.waiter !== null) this.wake();
1648
+ else this.notify();
1649
+ }
1650
+ presence() {
1651
+ if (this.awaitingReply) return "working";
1652
+ if (this.waiter) return "listening";
1653
+ if (this.betweenPolls) return "working";
1654
+ return "waiting";
1655
+ }
1656
+ presenceDetail() {
1657
+ return {
1658
+ state: this.presence(),
1659
+ reason: this.reason(),
1660
+ since: this.since
1661
+ };
1662
+ }
1663
+ reason() {
1664
+ if (this.awaitingReply) return this.stalled ? "stalled" : "delivered";
1665
+ if (this.waiter) return "polling";
1666
+ if (this.betweenPolls) return "replied";
1667
+ return this.lastDetach ?? "no-agent";
1668
+ }
1669
+ hasListener() {
1670
+ return this.waiter !== null;
1671
+ }
1672
+ deliveredThreadIds() {
1673
+ return [...this.deliveredAt.keys()];
1674
+ }
1675
+ currentBatch() {
1676
+ if (!this.batch) return null;
1677
+ const ids = new Set(this.batch.threadIds);
1678
+ for (const id of this.deliveredAt.keys()) ids.add(id);
1679
+ return {
1680
+ threadIds: [...ids],
1681
+ answered: [...ids].filter((id) => !this.deliveredAt.has(id))
1682
+ };
1683
+ }
1684
+ queuedThreadIds() {
1685
+ return [...this.buckets.get(this.scope)?.threads ?? []];
1686
+ }
1687
+ subscribe(listener) {
1688
+ this.listeners.add(listener);
1689
+ return () => this.listeners.delete(listener);
1690
+ }
1691
+ notify() {
1692
+ const state = this.presence();
1693
+ const reason = this.reason();
1694
+ const prev = this.lastDetail;
1695
+ if (prev.state !== state || prev.reason !== reason) {
1696
+ this.since = Date.now();
1697
+ this.lastDetail = {
1698
+ state,
1699
+ reason
1700
+ };
1701
+ }
1702
+ for (const listener of this.listeners) listener(state);
1703
+ }
1704
+ armStall() {
1705
+ this.clearStall();
1706
+ const timer = setTimeout(() => {
1707
+ this.stallTimer = null;
1708
+ if (!this.awaitingReply || this.stalled) return;
1709
+ this.stalled = true;
1710
+ this.notify();
1711
+ }, this.stallAfterMs);
1712
+ timer.unref?.();
1713
+ this.stallTimer = timer;
1714
+ }
1715
+ clearStall() {
1716
+ if (this.stallTimer !== null) clearTimeout(this.stallTimer);
1717
+ this.stallTimer = null;
1718
+ }
1719
+ armGrace() {
1720
+ this.clearGrace();
1721
+ this.betweenPolls = true;
1722
+ const drop = () => {
1723
+ this.graceTimer = null;
1724
+ if (!this.betweenPolls) return;
1725
+ this.betweenPolls = false;
1726
+ this.notify();
1727
+ };
1728
+ const pid = this.owner;
1729
+ if (pid === null) {
1730
+ const timer = setTimeout(drop, this.replyGraceMs);
1731
+ timer.unref?.();
1732
+ this.graceTimer = timer;
1733
+ return;
1734
+ }
1735
+ const isAlive = this.liveness.isAlive ?? pidAlive;
1736
+ const capMs = this.liveness.capMs ?? 6e5;
1737
+ const startedAt = Date.now();
1738
+ const timer = setInterval(() => {
1739
+ if (!this.betweenPolls) {
1740
+ this.clearGrace();
1741
+ return;
1742
+ }
1743
+ if (!isAlive(pid) || Date.now() - startedAt >= capMs) {
1744
+ clearInterval(timer);
1745
+ drop();
1746
+ }
1747
+ }, this.liveness.checkEveryMs ?? 5e3);
1748
+ timer.unref?.();
1749
+ this.graceTimer = timer;
1750
+ }
1751
+ clearGrace() {
1752
+ if (this.graceTimer !== null) clearInterval(this.graceTimer);
1753
+ this.graceTimer = null;
1754
+ this.betweenPolls = false;
1755
+ }
1756
+ enqueueThreads(threadIds) {
1757
+ const bucket = this.bucket();
1758
+ for (const id of threadIds) bucket.threads.add(id);
1759
+ const queued = this.waiter === null;
1760
+ this.wake();
1761
+ if (queued) this.notify();
1762
+ }
1763
+ enqueueFinish(coverage) {
1764
+ this.bucket().finish = coverage;
1765
+ this.wake();
1766
+ }
1767
+ drop(threadId) {
1768
+ for (const bucket of this.buckets.values()) bucket.threads.delete(threadId);
1769
+ this.deliveredAt.delete(threadId);
1770
+ if (this.batch) this.batch.threadIds = this.batch.threadIds.filter((id) => id !== threadId);
1771
+ this.notify();
1772
+ }
1773
+ /**
1774
+ * Attach a poll. Resolves 'data' when there is feedback to take (possibly
1775
+ * immediately), 'superseded' when a newer poll replaces this one, 'ended' on
1776
+ * `diffo end`, or 'gone' when the agent's session dies while this poll waits.
1777
+ */
1778
+ attach(onAbort) {
1779
+ this.releaseWaiter("superseded");
1780
+ this.clearGrace();
1781
+ if (this.batch !== null) {
1782
+ if (this.batch.sawReply || Date.now() - this.batch.deliveredAt >= this.batchSettleMs) this.closeBatch("repoll");
1783
+ }
1784
+ if (this.hasPending()) return Promise.resolve("data");
1785
+ return new Promise((resolve) => {
1786
+ const waiter = (outcome) => resolve(outcome);
1787
+ this.waiter = waiter;
1788
+ this.lastDetach = null;
1789
+ this.armWaiterWatch();
1790
+ this.notify();
1791
+ onAbort?.(() => {
1792
+ if (this.waiter === waiter) {
1793
+ this.releaseWaiter(null);
1794
+ this.lastDetach = "disconnected";
1795
+ this.notify();
1796
+ }
1797
+ });
1798
+ });
1799
+ }
1800
+ releaseWaiter(outcome) {
1801
+ const waiter = this.waiter;
1802
+ this.waiter = null;
1803
+ this.clearWaiterWatch();
1804
+ if (waiter && outcome !== null) waiter(outcome);
1805
+ }
1806
+ armWaiterWatch() {
1807
+ this.clearWaiterWatch();
1808
+ const isAlive = this.liveness.isAlive ?? pidAlive;
1809
+ const timer = setInterval(() => {
1810
+ if (this.waiter === null) return this.clearWaiterWatch();
1811
+ const pid = this.owner;
1812
+ if (pid === null || isAlive(pid)) return;
1813
+ this.releaseWaiter("gone");
1814
+ this.lastDetach = "disconnected";
1815
+ this.notify();
1816
+ }, this.liveness.checkEveryMs ?? 5e3);
1817
+ timer.unref?.();
1818
+ this.waiterWatch = timer;
1819
+ }
1820
+ clearWaiterWatch() {
1821
+ if (this.waiterWatch !== null) clearInterval(this.waiterWatch);
1822
+ this.waiterWatch = null;
1823
+ }
1824
+ hasPending() {
1825
+ const bucket = this.buckets.get(this.scope);
1826
+ return bucket !== void 0 && (bucket.threads.size > 0 || bucket.finish !== null);
1827
+ }
1828
+ wake() {
1829
+ if (this.waiter === null) return;
1830
+ this.releaseWaiter("data");
1831
+ }
1832
+ /**
1833
+ * Peek at everything pending WITHOUT removing it. A pooled Finish wins: its
1834
+ * prompt already carries every sent thread, so per-thread items are absorbed.
1835
+ */
1836
+ take() {
1837
+ const bucket = this.buckets.get(this.scope);
1838
+ if (!bucket) return null;
1839
+ if (bucket.finish !== null) return {
1840
+ kind: "finish",
1841
+ coverage: bucket.finish,
1842
+ absorbedThreadIds: [...bucket.threads]
1843
+ };
1844
+ if (bucket.threads.size > 0) return {
1845
+ kind: "threads",
1846
+ threadIds: [...bucket.threads]
1847
+ };
1848
+ return null;
1849
+ }
1850
+ /** The poll response for this snapshot was fully written — NOW it counts as
1851
+ * delivered. Clears exactly what the snapshot covered. */
1852
+ confirm(snapshot, deliveredThreadIds) {
1853
+ for (const bucket of this.buckets.values()) if (snapshot.kind === "finish") {
1854
+ if (bucket.finish === snapshot.coverage) bucket.finish = null;
1855
+ for (const id of snapshot.absorbedThreadIds) bucket.threads.delete(id);
1856
+ } else for (const id of snapshot.threadIds) bucket.threads.delete(id);
1857
+ const now = Date.now();
1858
+ for (const id of deliveredThreadIds) this.deliveredAt.set(id, now);
1859
+ this.awaitingReply = true;
1860
+ this.batch = {
1861
+ threadIds: [...deliveredThreadIds],
1862
+ deliveredAt: now,
1863
+ sawReply: false
1864
+ };
1865
+ this.armBatchWatch();
1866
+ this.stalled = false;
1867
+ this.armStall();
1868
+ this.notify();
1869
+ }
1870
+ /** 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) {
1873
+ const at = this.deliveredAt.get(threadId);
1874
+ this.deliveredAt.delete(threadId);
1875
+ if (this.batch) this.batch.sawReply = true;
1876
+ if (this.deliveredAt.size > 0) {
1877
+ if (this.awaitingReply) {
1878
+ this.stalled = false;
1879
+ this.armStall();
1880
+ this.notify();
1881
+ }
1882
+ } else if (this.awaitingReply || this.stalled || this.betweenPolls) {
1883
+ this.awaitingReply = false;
1884
+ this.stalled = false;
1885
+ this.clearStall();
1886
+ this.armGrace();
1887
+ this.notify();
1888
+ }
1889
+ return at === void 0 ? null : Date.now() - at;
1890
+ }
1891
+ end(sessionPid = null) {
1892
+ const caller = DeliveryQueue.clean(sessionPid);
1893
+ if (caller !== null && caller !== this.owner && this.ownerConnected()) return false;
1894
+ this.owner = null;
1895
+ this.releaseWaiter("ended");
1896
+ this.closeBatch("ended");
1897
+ this.awaitingReply = false;
1898
+ this.stalled = false;
1899
+ this.clearStall();
1900
+ this.clearGrace();
1901
+ this.deliveredAt.clear();
1902
+ this.lastDetach = "ended";
1903
+ this.notify();
1904
+ return true;
1905
+ }
1906
+ };
1907
+ //#endregion
1908
+ //#region src/server/idle.ts
1909
+ const IDLE_TIMEOUT_MS = 18e5;
1910
+ /** The idle budget for this process, or null when self-stop is off.
1911
+ * `DIFFO_IDLE_TIMEOUT_MS` overrides anywhere (0 or 'off' disables); otherwise only
1912
+ * a daemon (`DIFFO_DAEMON=1`, set by the spawning CLI) gets the default budget. */
1913
+ function resolveIdleTimeoutMs(env) {
1914
+ const raw = env.DIFFO_IDLE_TIMEOUT_MS?.trim();
1915
+ if (raw !== void 0 && raw !== "") {
1916
+ if (raw === "off") return null;
1917
+ const value = Number(raw);
1918
+ if (!Number.isFinite(value) || value <= 0) return null;
1919
+ return value;
1920
+ }
1921
+ return env.DIFFO_DAEMON === "1" ? IDLE_TIMEOUT_MS : null;
1922
+ }
1923
+ /**
1924
+ * Idleness is decided by live connections (browser tabs and agent polls) and
1925
+ * request recency — deliberately NOT by the delivery queue's state. Feedback the
1926
+ * agent never answered is durable (`rehydrateQueue` re-delivers it after a
1927
+ * respawn), so an abandoned batch must not hold the daemon alive forever.
1928
+ */
1929
+ var IdleMonitor = class {
1930
+ opts;
1931
+ connections = 0;
1932
+ lastActivity;
1933
+ timer = null;
1934
+ constructor(opts) {
1935
+ this.opts = opts;
1936
+ this.lastActivity = this.now();
1937
+ }
1938
+ now() {
1939
+ return this.opts.now?.() ?? Date.now();
1940
+ }
1941
+ touch() {
1942
+ this.lastActivity = this.now();
1943
+ }
1944
+ connect() {
1945
+ this.connections++;
1946
+ this.touch();
1947
+ }
1948
+ disconnect() {
1949
+ this.connections = Math.max(0, this.connections - 1);
1950
+ this.touch();
1951
+ }
1952
+ isIdle() {
1953
+ if (this.connections > 0) return false;
1954
+ return this.now() - this.lastActivity >= this.opts.timeoutMs;
1955
+ }
1956
+ start() {
1957
+ if (this.timer) return;
1958
+ const every = this.opts.checkEveryMs ?? 6e4;
1959
+ const timer = setInterval(() => {
1960
+ if (this.isIdle()) {
1961
+ this.stop();
1962
+ this.opts.onIdle();
1963
+ }
1964
+ }, every);
1965
+ timer.unref?.();
1966
+ this.timer = timer;
1967
+ }
1968
+ stop() {
1969
+ if (this.timer) clearInterval(this.timer);
1970
+ this.timer = null;
1971
+ }
1972
+ };
1973
+ //#endregion
1974
+ //#region src/server/landed.ts
1975
+ /**
1976
+ * Keep the review's landed marker true to the repo. Called on every changeset
1977
+ * recompute (and once at startup, which is what catches a commit made while no
1978
+ * server ran):
1979
+ *
1980
+ * - The diff emptied while HEAD advanced past `seenHead` → the changeset was
1981
+ * committed. Stamp `landed` — the UI's cue to offer a fresh start. A stash
1982
+ * empties the diff without moving HEAD, so it never stamps; a branch switch
1983
+ * is rescoped to its own review before this runs.
1984
+ * - A landed commit that left HEAD's history (reset, or an amend rewriting it)
1985
+ * drops the marker — and an amend re-stamps in the same pass, because
1986
+ * `seenHead` is still the pre-landing base and IS an ancestor of the
1987
+ * rewritten commit.
1988
+ * - Everything here is metadata. Threads are never touched: only the reviewer
1989
+ * clears a review, this just tells them when they can.
1990
+ *
1991
+ * `seenHead` advances only while the diff has files (tracking the base the
1992
+ * outstanding work sits on) or the review is empty (nothing to land). While an
1993
+ * empty diff hangs over a non-empty review it stays frozen at the base — the
1994
+ * fixed point that makes "amended" and "reset back" distinguishable above.
1995
+ */
1996
+ function maintainLanded(review, hasFiles, git) {
1997
+ const head = git.head();
1998
+ if (head === null) return null;
1999
+ const state = review.get();
2000
+ const reviewEmpty = state.threads.length === 0 && !state.lastFinish;
2001
+ let outcome = null;
2002
+ if (state.landed && state.landed.sha !== head) {
2003
+ if (git.isAncestor(state.landed.sha, head) === false) {
2004
+ review.clearLanded();
2005
+ outcome = "cleared";
2006
+ }
2007
+ }
2008
+ if (!review.get().landed && !hasFiles && !reviewEmpty && state.seenHead !== void 0 && state.seenHead !== head && git.isAncestor(state.seenHead, head) === true) {
2009
+ review.markLanded({
2010
+ sha: head,
2011
+ subject: git.subject(head),
2012
+ at: (/* @__PURE__ */ new Date()).toISOString()
2013
+ });
2014
+ outcome = "stamped";
2015
+ }
2016
+ if (hasFiles || reviewEmpty) review.noteHead(head);
2017
+ return outcome;
2018
+ }
2019
+ //#endregion
2020
+ //#region src/server/review.ts
2021
+ const STATES = [
2022
+ "open",
2023
+ "sent",
2024
+ "addressed",
2025
+ "resolved"
2026
+ ];
2027
+ var ReviewStore = class {
2028
+ db;
2029
+ state;
2030
+ listeners = /* @__PURE__ */ new Set();
2031
+ repoPath;
2032
+ key;
2033
+ constructor(root, db, spec) {
2034
+ this.db = db;
2035
+ this.repoPath = resolve(root);
2036
+ this.key = {
2037
+ repoPath: this.repoPath,
2038
+ branch: getBranchName(this.repoPath),
2039
+ base: spec.kind === "branch" ? spec.base : ""
2040
+ };
2041
+ this.state = this.load();
2042
+ }
2043
+ get scope() {
2044
+ return this.key;
2045
+ }
2046
+ /**
2047
+ * HEAD moved under a running server: swap to that branch's review. Deliberately
2048
+ * does NOT commit first — the state on screen belongs to the branch we are
2049
+ * leaving, and writing it under the new key is the bleed this scoping prevents.
2050
+ */
2051
+ rescope(branch) {
2052
+ if (branch === this.key.branch) return;
2053
+ this.key = {
2054
+ ...this.key,
2055
+ branch
2056
+ };
2057
+ this.state = this.load();
2058
+ for (const listener of this.listeners) listener(this.state);
2059
+ }
2060
+ load() {
2061
+ const stored = this.db.getReview(this.key);
2062
+ return (stored !== null ? parseReview(stored) : null) ?? structuredClone(EMPTY_REVIEW);
2063
+ }
2064
+ get() {
2065
+ return this.state;
2066
+ }
2067
+ subscribe(listener) {
2068
+ this.listeners.add(listener);
2069
+ return () => this.listeners.delete(listener);
2070
+ }
2071
+ createThread(anchor, text, codeContext, intent, author = "reviewer") {
2072
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2073
+ return this.insert({
2074
+ id: randomUUID(),
2075
+ anchor,
2076
+ state: "open",
2077
+ ...intent ? { intent } : {},
2078
+ codeContext,
2079
+ codeChanged: false,
2080
+ messages: [{
2081
+ id: randomUUID(),
2082
+ author,
2083
+ text,
2084
+ at: now
2085
+ }],
2086
+ createdAt: now,
2087
+ updatedAt: now
2088
+ });
2089
+ }
2090
+ /**
2091
+ * The closing note of a Finish round, as a thread. Anchored to the changeset
2092
+ * and opened in the reviewer's voice, so it travels the ordinary path: the
2093
+ * agent replies to it with `diffo reply`, the queue counts it as feedback
2094
+ * still owed an answer, and the reviewer sees their own summing-up in the rail
2095
+ * beside everything else they said.
2096
+ */
2097
+ closingNote(text) {
2098
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2099
+ return this.insert({
2100
+ id: randomUUID(),
2101
+ anchor: { kind: "changeset" },
2102
+ state: "open",
2103
+ closingNote: true,
2104
+ codeContext: null,
2105
+ codeChanged: false,
2106
+ messages: [{
2107
+ id: randomUUID(),
2108
+ author: "reviewer",
2109
+ text,
2110
+ at: now
2111
+ }],
2112
+ createdAt: now,
2113
+ updatedAt: now
2114
+ });
2115
+ }
2116
+ insert(thread) {
2117
+ this.state = {
2118
+ ...this.state,
2119
+ threads: [...this.state.threads, thread]
2120
+ };
2121
+ this.commit();
2122
+ return thread;
2123
+ }
2124
+ /** `withheld` marks a reviewer reply the agent has not been given. Only ever true
2125
+ * for the reviewer: an agent message proves the agent has the thread.
2126
+ *
2127
+ * `seenThroughMs` is when the delivery this agent reply answers was handed over.
2128
+ * The reply is inserted after the messages that delivery carried and before any
2129
+ * the reviewer raced in since — appended at the end, it would render as an
2130
+ * answer to a comment it never saw, and the raced comment would stop reading
2131
+ * as "waiting on the agent" even though it still is. */
2132
+ addMessage(threadId, author, text, withheld = false, seenThroughMs) {
2133
+ return this.update(threadId, ({ unanswered: _answered, ...thread }) => {
2134
+ const message = {
2135
+ id: randomUUID(),
2136
+ author,
2137
+ text,
2138
+ at: (/* @__PURE__ */ new Date()).toISOString()
2139
+ };
2140
+ const raced = author === "agent" && seenThroughMs !== void 0 ? thread.messages.findIndex((m) => Date.parse(m.at) > seenThroughMs) : -1;
2141
+ return {
2142
+ ...thread,
2143
+ ...withheld && author === "reviewer" ? { withheld: true } : {},
2144
+ messages: raced === -1 ? [...thread.messages, message] : [
2145
+ ...thread.messages.slice(0, raced),
2146
+ message,
2147
+ ...thread.messages.slice(raced)
2148
+ ]
2149
+ };
2150
+ });
2151
+ }
2152
+ /** A delivery just handed these threads to the agent — remember through when,
2153
+ * so replies and the typing indicator can tell delivered words from raced ones. */
2154
+ markDelivered(threadIds) {
2155
+ const ids = new Set(threadIds);
2156
+ if (ids.size === 0) return;
2157
+ const at = (/* @__PURE__ */ new Date()).toISOString();
2158
+ this.state = {
2159
+ ...this.state,
2160
+ threads: this.state.threads.map((t) => ids.has(t.id) ? {
2161
+ ...t,
2162
+ deliveredThrough: at
2163
+ } : t)
2164
+ };
2165
+ this.commit();
2166
+ }
2167
+ clearWithheld(threadIds) {
2168
+ const ids = new Set(threadIds);
2169
+ if (!this.state.threads.some((t) => ids.has(t.id) && t.withheld)) return;
2170
+ this.state = {
2171
+ ...this.state,
2172
+ threads: this.state.threads.map(({ withheld, ...t }) => ids.has(t.id) ? t : {
2173
+ ...t,
2174
+ ...withheld ? { withheld } : {}
2175
+ })
2176
+ };
2177
+ this.commit();
2178
+ }
2179
+ markUnanswered(threadIds) {
2180
+ this.setUnanswered(threadIds, (t) => !t.unanswered && t.state !== "resolved");
2181
+ }
2182
+ clearUnanswered(threadIds) {
2183
+ this.setUnanswered(threadIds, (t) => t.unanswered === true);
2184
+ }
2185
+ setUnanswered(threadIds, applies) {
2186
+ const wanted = new Set(threadIds);
2187
+ let changed = false;
2188
+ const threads = this.state.threads.map((thread) => {
2189
+ if (!wanted.has(thread.id) || !applies(thread)) return thread;
2190
+ changed = true;
2191
+ const { unanswered: _was, ...rest } = thread;
2192
+ return thread.unanswered ? rest : {
2193
+ ...rest,
2194
+ unanswered: true
2195
+ };
2196
+ });
2197
+ if (!changed) return;
2198
+ this.state = {
2199
+ ...this.state,
2200
+ threads
2201
+ };
2202
+ this.commit();
2203
+ }
2204
+ setState(threadId, state) {
2205
+ return this.update(threadId, (thread) => ({
2206
+ ...thread,
2207
+ state,
2208
+ ...state === "sent" && !thread.sentAt ? { sentAt: (/* @__PURE__ */ new Date()).toISOString() } : {},
2209
+ ...state === "resolved" ? { codeContext: null } : {}
2210
+ }));
2211
+ }
2212
+ removeThread(threadId) {
2213
+ const threads = this.state.threads.filter((t) => t.id !== threadId);
2214
+ if (threads.length === this.state.threads.length) return false;
2215
+ this.state = {
2216
+ ...this.state,
2217
+ threads
2218
+ };
2219
+ this.commit();
2220
+ return true;
2221
+ }
2222
+ /**
2223
+ * Start the review over: threads, the last-finish record, and the landed
2224
+ * marker all go. Not just the threads — a kept `lastFinish` would carry hunk
2225
+ * ids from the dead changeset into the next review's "since last review"
2226
+ * lens, reporting everything as new. `seenHead` survives: it describes the
2227
+ * repo, not the review being discarded.
2228
+ */
2229
+ reset() {
2230
+ const ids = this.state.threads.map((t) => t.id);
2231
+ if (ids.length === 0 && !this.state.lastFinish && !this.state.landed) return [];
2232
+ const { lastFinish: _finish, landed: _landed, ...rest } = this.state;
2233
+ this.state = {
2234
+ ...rest,
2235
+ threads: []
2236
+ };
2237
+ this.commit();
2238
+ return ids;
2239
+ }
2240
+ /** The base the work under review sits on — see `ReviewState.seenHead` for
2241
+ * when the caller must NOT move it. */
2242
+ noteHead(sha) {
2243
+ if (this.state.seenHead === sha) return;
2244
+ this.state = {
2245
+ ...this.state,
2246
+ seenHead: sha
2247
+ };
2248
+ this.commit();
2249
+ }
2250
+ markLanded(landed) {
2251
+ if (this.state.landed?.sha === landed.sha) return;
2252
+ this.state = {
2253
+ ...this.state,
2254
+ landed
2255
+ };
2256
+ this.commit();
2257
+ }
2258
+ clearLanded() {
2259
+ if (!this.state.landed) return;
2260
+ const { landed: _landed, ...rest } = this.state;
2261
+ this.state = rest;
2262
+ this.commit();
2263
+ }
2264
+ annotateAgentReplies(threadIds, durationMs) {
2265
+ let changed = false;
2266
+ const threads = this.state.threads.map((thread) => {
2267
+ if (!threadIds.includes(thread.id)) return thread;
2268
+ const index = thread.messages.findLastIndex((m) => m.author === "agent");
2269
+ if (index === -1 || thread.messages[index].durationMs !== void 0) return thread;
2270
+ changed = true;
2271
+ const messages = [...thread.messages];
2272
+ messages[index] = {
2273
+ ...messages[index],
2274
+ durationMs
2275
+ };
2276
+ return {
2277
+ ...thread,
2278
+ messages
2279
+ };
2280
+ });
2281
+ if (!changed) return;
2282
+ this.state = {
2283
+ ...this.state,
2284
+ threads
2285
+ };
2286
+ this.commit();
2287
+ }
2288
+ send(threadId) {
2289
+ const thread = this.state.threads.find((t) => t.id === threadId);
2290
+ if (!thread) return null;
2291
+ if (thread.state !== "open") return thread;
2292
+ return this.setState(threadId, "sent");
2293
+ }
2294
+ finish(only) {
2295
+ const flush = (t) => t.state === "open" && (only?.has(t.id) ?? true);
2296
+ if (this.state.threads.some(flush)) {
2297
+ this.state = {
2298
+ ...this.state,
2299
+ threads: this.state.threads.map((t) => flush(t) ? {
2300
+ ...t,
2301
+ state: "sent",
2302
+ ...t.sentAt ? {} : { sentAt: (/* @__PURE__ */ new Date()).toISOString() },
2303
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2304
+ } : t)
2305
+ };
2306
+ this.commit();
2307
+ }
2308
+ return this.state.threads;
2309
+ }
2310
+ recordFinish(hunkIds, coverage) {
2311
+ const lastFinish = {
2312
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2313
+ hunkIds: [...hunkIds],
2314
+ coverage
2315
+ };
2316
+ this.state = {
2317
+ ...this.state,
2318
+ lastFinish
2319
+ };
2320
+ this.commit();
2321
+ return lastFinish;
2322
+ }
2323
+ markFinishCollected() {
2324
+ const lastFinish = this.state.lastFinish;
2325
+ if (!lastFinish || lastFinish.collectedAt) return;
2326
+ this.state = {
2327
+ ...this.state,
2328
+ lastFinish: {
2329
+ ...lastFinish,
2330
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
2331
+ }
2332
+ };
2333
+ this.commit();
2334
+ }
2335
+ /**
2336
+ * Changeset changed — reconcile hunk-anchored threads against the new hunk ids.
2337
+ * Content-addressing does the work: an edited hunk has a new id, so its old one
2338
+ * vanishes. `sent` + vanished → `addressed`; `open` + vanished → `codeChanged`,
2339
+ * cleared again if it comes back (a revert).
2340
+ */
2341
+ reconcile(currentHunkIds) {
2342
+ let changed = false;
2343
+ const threads = this.state.threads.map((thread) => {
2344
+ if (thread.anchor.kind !== "hunk") return thread;
2345
+ const exists = currentHunkIds.has(thread.anchor.hunkId);
2346
+ if (thread.state === "sent" && !exists) {
2347
+ changed = true;
2348
+ return {
2349
+ ...thread,
2350
+ state: "addressed",
2351
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2352
+ };
2353
+ }
2354
+ if ((thread.state === "open" || thread.state === "sent") && thread.codeChanged === exists) {
2355
+ changed = true;
2356
+ return {
2357
+ ...thread,
2358
+ codeChanged: !exists
2359
+ };
2360
+ }
2361
+ return thread;
2362
+ });
2363
+ if (!changed) return;
2364
+ this.state = {
2365
+ ...this.state,
2366
+ threads
2367
+ };
2368
+ this.commit();
2369
+ }
2370
+ update(threadId, change) {
2371
+ const index = this.state.threads.findIndex((t) => t.id === threadId);
2372
+ if (index === -1) return null;
2373
+ const updated = {
2374
+ ...change(this.state.threads[index]),
2375
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2376
+ };
2377
+ const threads = [...this.state.threads];
2378
+ threads[index] = updated;
2379
+ this.state = {
2380
+ ...this.state,
2381
+ threads
2382
+ };
2383
+ this.commit();
2384
+ return updated;
2385
+ }
2386
+ commit() {
2387
+ this.db.setReview(this.key, JSON.stringify(this.state));
2388
+ for (const listener of this.listeners) listener(this.state);
2389
+ }
2390
+ };
2391
+ /** Tolerant parse: agents hand-edit this file, so anything recoverable is
2392
+ * recovered. Threads with a broken shape are dropped, optional fields are filled
2393
+ * in. Returns null only when the file as a whole is unusable. */
2394
+ function parseReview(raw) {
2395
+ let data;
2396
+ try {
2397
+ data = JSON.parse(raw);
2398
+ } catch {
2399
+ return null;
2400
+ }
2401
+ if (typeof data !== "object" || data === null) return null;
2402
+ const parsed = data;
2403
+ const threads = parsed.threads;
2404
+ if (!Array.isArray(threads)) return null;
2405
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2406
+ const valid = threads.map((t) => normalizeThread(t, now)).filter((t) => t !== null);
2407
+ const migrated = Array.isArray(parsed.suggestions) ? parsed.suggestions.map((s) => migrateLegacySuggestion(s, now)).filter((t) => t !== null) : [];
2408
+ const lastFinish = normalizeLastFinish(parsed.lastFinish, now);
2409
+ const landed = normalizeLanded(parsed.landed, now);
2410
+ return {
2411
+ version: 1,
2412
+ threads: [...valid, ...migrated],
2413
+ ...lastFinish ? { lastFinish } : {},
2414
+ ...typeof parsed.seenHead === "string" && parsed.seenHead !== "" ? { seenHead: parsed.seenHead } : {},
2415
+ ...landed ? { landed } : {}
2416
+ };
2417
+ }
2418
+ /** A landed marker without a sha can't be checked against history, so it is
2419
+ * dropped; the subject is only a caption and defaults away. */
2420
+ function normalizeLanded(value, now) {
2421
+ if (typeof value !== "object" || value === null) return null;
2422
+ const l = value;
2423
+ if (typeof l.sha !== "string" || l.sha === "") return null;
2424
+ return {
2425
+ sha: l.sha,
2426
+ subject: typeof l.subject === "string" ? l.subject : "",
2427
+ at: typeof l.at === "string" ? l.at : now
2428
+ };
2429
+ }
2430
+ /** A finish with no hunk ids can't answer the only question it exists for, so it is
2431
+ * dropped rather than kept as a record reporting "nothing changed". */
2432
+ function normalizeLastFinish(value, now) {
2433
+ if (typeof value !== "object" || value === null) return null;
2434
+ const f = value;
2435
+ if (!Array.isArray(f.hunkIds)) return null;
2436
+ return {
2437
+ at: typeof f.at === "string" ? f.at : now,
2438
+ hunkIds: f.hunkIds.filter((x) => typeof x === "string"),
2439
+ coverage: normalizeCoverage(f.coverage),
2440
+ ...typeof f.collectedAt === "string" ? { collectedAt: f.collectedAt } : {}
2441
+ };
2442
+ }
2443
+ function normalizeCoverage(value) {
2444
+ const c = typeof value === "object" && value !== null ? value : {};
2445
+ const count = (v) => typeof v === "number" && v >= 0 ? v : 0;
2446
+ const fileList = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : void 0;
2447
+ const changedFiles = fileList(c.changedFiles);
2448
+ const commentedUnread = fileList(c.commentedUnread);
2449
+ return {
2450
+ viewedHunks: count(c.viewedHunks),
2451
+ totalHunks: count(c.totalHunks),
2452
+ ...typeof c.viewedFiles === "number" ? { viewedFiles: count(c.viewedFiles) } : {},
2453
+ ...typeof c.totalFiles === "number" ? { totalFiles: count(c.totalFiles) } : {},
2454
+ skippedFiles: fileList(c.skippedFiles) ?? [],
2455
+ ...changedFiles !== void 0 ? { changedFiles } : {},
2456
+ ...commentedUnread !== void 0 ? { commentedUnread } : {},
2457
+ ...REVIEW_VERDICTS.includes(c.verdict) ? { verdict: c.verdict } : {},
2458
+ ...typeof c.note === "string" ? { note: c.note } : {}
2459
+ };
2460
+ }
2461
+ function migrateLegacySuggestion(value, now) {
2462
+ if (typeof value !== "object" || value === null) return null;
2463
+ const s = value;
2464
+ if (typeof s.file !== "string" || typeof s.text !== "string") return null;
2465
+ const at = typeof s.at === "string" ? s.at : now;
2466
+ return {
2467
+ id: typeof s.id === "string" ? s.id : randomUUID(),
2468
+ anchor: {
2469
+ kind: "file",
2470
+ path: s.file
2471
+ },
2472
+ state: "open",
2473
+ codeContext: null,
2474
+ codeChanged: false,
2475
+ messages: [{
2476
+ id: randomUUID(),
2477
+ author: "agent",
2478
+ text: s.text,
2479
+ at
2480
+ }],
2481
+ createdAt: at,
2482
+ updatedAt: at
2483
+ };
2484
+ }
2485
+ function parseAnchor(value) {
2486
+ if (typeof value !== "object" || value === null) return null;
2487
+ const anchor = value;
2488
+ if (anchor.kind === "hunk") {
2489
+ if (typeof anchor.hunkId !== "string" || typeof anchor.path !== "string") return null;
2490
+ if (anchor.side !== "old" && anchor.side !== "new") return null;
2491
+ if (typeof anchor.line !== "number") return null;
2492
+ return {
2493
+ kind: "hunk",
2494
+ hunkId: anchor.hunkId,
2495
+ path: anchor.path,
2496
+ side: anchor.side,
2497
+ line: anchor.line
2498
+ };
2499
+ }
2500
+ if (anchor.kind === "file") return typeof anchor.path === "string" ? {
2501
+ kind: "file",
2502
+ path: anchor.path
2503
+ } : null;
2504
+ return anchor.kind === "changeset" ? { kind: "changeset" } : null;
2505
+ }
2506
+ function normalizeThread(value, now) {
2507
+ if (typeof value !== "object" || value === null) return null;
2508
+ const t = value;
2509
+ if (typeof t.id !== "string" || !STATES.includes(t.state)) return null;
2510
+ const anchor = parseAnchor(t.anchor);
2511
+ if (!anchor) return null;
2512
+ if (!Array.isArray(t.messages)) return null;
2513
+ const messages = [];
2514
+ for (const m of t.messages) {
2515
+ if (typeof m !== "object" || m === null) return null;
2516
+ const msg = m;
2517
+ if (typeof msg.text !== "string") return null;
2518
+ if (msg.author !== "reviewer" && msg.author !== "agent") return null;
2519
+ messages.push({
2520
+ id: typeof msg.id === "string" ? msg.id : randomUUID(),
2521
+ author: msg.author,
2522
+ text: msg.text,
2523
+ at: typeof msg.at === "string" ? msg.at : now,
2524
+ ...typeof msg.durationMs === "number" ? { durationMs: msg.durationMs } : {}
2525
+ });
2526
+ }
2527
+ return {
2528
+ id: t.id,
2529
+ anchor,
2530
+ state: t.state,
2531
+ ...THREAD_INTENTS.includes(t.intent) ? { intent: t.intent } : {},
2532
+ codeContext: typeof t.codeContext === "string" ? t.codeContext : null,
2533
+ codeChanged: t.codeChanged === true,
2534
+ ...t.unanswered === true ? { unanswered: true } : {},
2535
+ ...t.closingNote === true ? { closingNote: true } : {},
2536
+ ...typeof t.sentAt === "string" ? { sentAt: t.sentAt } : {},
2537
+ messages,
2538
+ createdAt: typeof t.createdAt === "string" ? t.createdAt : now,
2539
+ updatedAt: typeof t.updatedAt === "string" ? t.updatedAt : now
2540
+ };
2541
+ }
2542
+ //#endregion
2543
+ //#region src/server/parse.ts
2544
+ const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
2545
+ ".png",
2546
+ ".jpg",
2547
+ ".jpeg",
2548
+ ".gif",
2549
+ ".webp",
2550
+ ".bmp",
2551
+ ".svg",
2552
+ ".ico",
2553
+ ".avif"
2554
+ ]);
2555
+ function isImagePath(path) {
2556
+ const dot = path.lastIndexOf(".");
2557
+ return dot !== -1 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
2558
+ }
2559
+ /** Content-addressed hunk identity: the hash covers the file path and the changed
2560
+ * lines only — not line numbers, not context. Editing the hunk itself mints a new
2561
+ * id; the occurrence index separates identical hunks within one file. */
2562
+ function hunkId(path, lines, occurrence) {
2563
+ const changed = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind === "add" ? "+" : "-"}${l.text}`).join("\n");
2564
+ return createHash("sha256").update(`${path}\0${changed}\0${occurrence}`).digest("hex").slice(0, 16);
2565
+ }
2566
+ function parseSectionMeta(section) {
2567
+ return {
2568
+ renamedFrom: section.match(/^rename from (.+)$/m)?.[1] ?? null,
2569
+ renamedTo: section.match(/^rename to (.+)$/m)?.[1] ?? null,
2570
+ binary: /^Binary files .* differ$/m.test(section) || /^GIT binary patch$/m.test(section),
2571
+ symlink: /^(?:new file|deleted file|new|old) mode 120000$/m.test(section) || /^index [0-9a-f.]+ 120000$/m.test(section)
2572
+ };
2573
+ }
2574
+ function splitSections(patch) {
2575
+ if (!patch.trim()) return [];
2576
+ const sections = [];
2577
+ const lines = patch.split("\n");
2578
+ let current = null;
2579
+ for (const line of lines) if (line.startsWith("diff --git ")) {
2580
+ if (current) sections.push(current.join("\n"));
2581
+ current = [line];
2582
+ } else if (current) current.push(line);
2583
+ if (current) sections.push(current.join("\n"));
2584
+ return sections;
2585
+ }
2586
+ /**
2587
+ * `@@ -oldStart,oldCount +newStart,newCount @@ scope`. Both counts are optional in
2588
+ * the format and mean 1 when absent (`@@ -1 +1 @@`). The trailing scope is git's own
2589
+ * guess at what you are inside.
2590
+ */
2591
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
2592
+ /**
2593
+ * Read one hunk body, starting at the line after its header. The declared counts
2594
+ * are authoritative: consume exactly that many old and new lines and stop, rather
2595
+ * than reading up to the next `@@` — git's own rule, and what keeps the blank line
2596
+ * every patch ends with out of the last hunk.
2597
+ */
2598
+ function parseHunkBody(lines, start, oldStart, newStart, oldCount, newCount) {
2599
+ const out = [];
2600
+ let oldNo = oldStart;
2601
+ let newNo = newStart;
2602
+ let remainingOld = oldCount;
2603
+ let remainingNew = newCount;
2604
+ let i = start;
2605
+ while (remainingOld > 0 || remainingNew > 0) {
2606
+ const line = lines[i];
2607
+ if (line === void 0) break;
2608
+ if (line.startsWith("@@") || line.startsWith("diff --git ")) break;
2609
+ i++;
2610
+ if (line.startsWith("\\")) continue;
2611
+ if (line.startsWith("+")) {
2612
+ out.push({
2613
+ kind: "add",
2614
+ oldNo: null,
2615
+ newNo: newNo++,
2616
+ text: line.slice(1)
2617
+ });
2618
+ remainingNew--;
2619
+ } else if (line.startsWith("-")) {
2620
+ out.push({
2621
+ kind: "del",
2622
+ oldNo: oldNo++,
2623
+ newNo: null,
2624
+ text: line.slice(1)
2625
+ });
2626
+ remainingOld--;
2627
+ } else {
2628
+ out.push({
2629
+ kind: "context",
2630
+ oldNo: oldNo++,
2631
+ newNo: newNo++,
2632
+ text: line.slice(1)
2633
+ });
2634
+ remainingOld--;
2635
+ remainingNew--;
2636
+ }
2637
+ }
2638
+ return {
2639
+ lines: out,
2640
+ next: i
2641
+ };
2642
+ }
2643
+ function parseHunks(lines) {
2644
+ const hunks = [];
2645
+ let i = 0;
2646
+ while (i < lines.length) {
2647
+ const header = lines[i]?.match(HUNK_HEADER);
2648
+ if (!header) {
2649
+ i++;
2650
+ continue;
2651
+ }
2652
+ const oldStart = Number(header[1]);
2653
+ const newStart = Number(header[3]);
2654
+ const body = parseHunkBody(lines, i + 1, oldStart, newStart, header[2] === void 0 ? 1 : Number(header[2]), header[4] === void 0 ? 1 : Number(header[4]));
2655
+ const context = (header[5] ?? "").trim();
2656
+ hunks.push({
2657
+ oldStart,
2658
+ newStart,
2659
+ context: context === "" ? null : context,
2660
+ lines: body.lines
2661
+ });
2662
+ i = body.next;
2663
+ }
2664
+ return hunks;
2665
+ }
2666
+ /**
2667
+ * `--- a/x` / `+++ b/x`, read only from the part of the section before the first
2668
+ * `@@`. That bound is load-bearing: inside a hunk body an added line starting with
2669
+ * `++` is spelled `+++ …`, so scanning the whole section would read file content as
2670
+ * a path header.
2671
+ */
2672
+ function parseSectionPaths(section, lines) {
2673
+ let from = null;
2674
+ let to = null;
2675
+ for (const line of lines) {
2676
+ if (line.startsWith("@@")) break;
2677
+ if (from === null && line.startsWith("--- ")) from = diffPath(line.slice(4));
2678
+ else if (to === null && line.startsWith("+++ ")) to = diffPath(line.slice(4));
2679
+ }
2680
+ return {
2681
+ from,
2682
+ to,
2683
+ isNew: from === "/dev/null" || /^new file mode /m.test(section),
2684
+ isDeleted: to === "/dev/null" || /^deleted file mode /m.test(section)
2685
+ };
2686
+ }
2687
+ function diffPath(raw) {
2688
+ const path = raw.split(" ", 1)[0] ?? raw;
2689
+ return path === "/dev/null" ? path : stripAB(path);
2690
+ }
2691
+ function parsePatch(patch) {
2692
+ const files = [];
2693
+ for (const section of splitSections(patch)) {
2694
+ const sectionLines = section.split("\n");
2695
+ const meta = parseSectionMeta(section);
2696
+ const paths = parseSectionPaths(section, sectionLines);
2697
+ let path;
2698
+ let oldPath = null;
2699
+ let status;
2700
+ if (meta.renamedTo) {
2701
+ path = meta.renamedTo;
2702
+ oldPath = meta.renamedFrom;
2703
+ status = "renamed";
2704
+ } else if (paths.isNew) {
2705
+ path = paths.to ?? headerPath(section);
2706
+ status = "added";
2707
+ } else if (paths.isDeleted) {
2708
+ path = paths.from ?? headerPath(section);
2709
+ status = "deleted";
2710
+ } else if (paths.to !== null && paths.to !== "/dev/null") {
2711
+ path = paths.to;
2712
+ status = "modified";
2713
+ } else {
2714
+ path = headerPath(section);
2715
+ status = "modified";
2716
+ }
2717
+ const kind = meta.symlink ? "symlink" : meta.binary ? isImagePath(path) ? "image" : "binary" : isImagePath(path) ? "image" : "text";
2718
+ const seen = /* @__PURE__ */ new Map();
2719
+ const hunks = meta.binary ? [] : parseHunks(sectionLines).map((hunk) => {
2720
+ const lines = hunk.lines;
2721
+ const contentKey = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind}:${l.text}`).join("\n");
2722
+ const occurrence = seen.get(contentKey) ?? 0;
2723
+ seen.set(contentKey, occurrence + 1);
2724
+ return {
2725
+ id: hunkId(path, lines, occurrence),
2726
+ path,
2727
+ oldStart: hunk.oldStart,
2728
+ newStart: hunk.newStart,
2729
+ lines,
2730
+ ...hunk.context === null ? {} : { context: hunk.context }
2731
+ };
2732
+ });
2733
+ files.push({
2734
+ path,
2735
+ oldPath,
2736
+ status,
2737
+ kind,
2738
+ staged: false,
2739
+ hunks
2740
+ });
2741
+ }
2742
+ return files;
2743
+ }
2744
+ function stripAB(p) {
2745
+ return p.startsWith("b/") || p.startsWith("a/") ? p.slice(2) : p;
2746
+ }
2747
+ function headerPath(section) {
2748
+ const header = section.split("\n", 1)[0] ?? "";
2749
+ return header.match(/^diff --git a\/(.*) b\/(.*)$/)?.[2] ?? header.replace(/^diff --git /, "");
2750
+ }
2751
+ //#endregion
2752
+ //#region src/server/changeset.ts
2753
+ function stats$1(files) {
2754
+ let additions = 0;
2755
+ let deletions = 0;
2756
+ for (const file of files) for (const hunk of file.hunks) for (const line of hunk.lines) if (line.kind === "add") additions++;
2757
+ else if (line.kind === "del") deletions++;
2758
+ return {
2759
+ files: files.length,
2760
+ additions,
2761
+ deletions
2762
+ };
2763
+ }
2764
+ /** Head-side line count — what sizes the expandable gap after a file's last hunk. */
2765
+ function headLineCount(root, file) {
2766
+ if (file.kind !== "text" || file.status === "deleted") return null;
2767
+ const bytes = getHeadFileBytes(root, file.path);
2768
+ if (bytes === null) return null;
2769
+ const text = bytes.toString("utf-8");
2770
+ if (text === "") return 0;
2771
+ return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
2772
+ }
2773
+ function buildChangesetFromRaw(root, spec, rawDiff, version) {
2774
+ const staged = getStagedPaths(root);
2775
+ const files = parsePatch(rawDiff).map((file) => ({
2776
+ ...file,
2777
+ staged: staged.has(file.path) || file.oldPath !== null && staged.has(file.oldPath),
2778
+ newLineCount: headLineCount(root, file)
2779
+ }));
2780
+ return {
2781
+ version,
2782
+ spec,
2783
+ repo: {
2784
+ path: resolve(root),
2785
+ name: getRepoName(root),
2786
+ branch: getBranchName(root),
2787
+ worktree: getWorktreeName(root)
2788
+ },
2789
+ files,
2790
+ stats: stats$1(files)
2791
+ };
2792
+ }
2793
+ //#endregion
2794
+ //#region src/server/store.ts
2795
+ var ChangesetStore = class {
2796
+ root;
2797
+ spec;
2798
+ version = 0;
2799
+ hash = null;
2800
+ changeset;
2801
+ listeners = /* @__PURE__ */ new Set();
2802
+ constructor(root, spec) {
2803
+ this.root = root;
2804
+ this.spec = spec;
2805
+ this.refreshSync();
2806
+ }
2807
+ async refresh() {
2808
+ this.inFlight ??= this.compute().finally(() => {
2809
+ this.inFlight = null;
2810
+ });
2811
+ return this.inFlight;
2812
+ }
2813
+ inFlight = null;
2814
+ async compute() {
2815
+ const [raw, staged, branch] = await Promise.all([
2816
+ getFullDiffAsync(this.root, this.spec),
2817
+ getStagedPathsAsync(this.root),
2818
+ getBranchNameAsync(this.root)
2819
+ ]);
2820
+ return this.apply(raw, staged, branch);
2821
+ }
2822
+ refreshSync() {
2823
+ return this.apply(getFullDiff(this.root, this.spec), getStagedPaths(this.root), getBranchName(this.root));
2824
+ }
2825
+ apply(raw, staged, branch) {
2826
+ const stagedKey = [...staged].sort().join("\n");
2827
+ const newHash = createHash("sha256").update(raw).update("\0").update(stagedKey).update("\0").update(branch).digest("hex");
2828
+ if (newHash === this.hash) return false;
2829
+ this.hash = newHash;
2830
+ this.version++;
2831
+ this.changeset = buildChangesetFromRaw(this.root, this.spec, raw, this.version);
2832
+ for (const listener of this.listeners) listener(this.changeset);
2833
+ return true;
2834
+ }
2835
+ get() {
2836
+ return this.changeset;
2837
+ }
2838
+ subscribe(listener) {
2839
+ this.listeners.add(listener);
2840
+ return () => this.listeners.delete(listener);
2841
+ }
2842
+ };
2843
+ //#endregion
2844
+ //#region src/server/watcher.ts
2845
+ const DEBOUNCE_MS = 300;
2846
+ const MAX_COOLDOWN_MS = 5e3;
2847
+ const GIT_STATE_FILES = /* @__PURE__ */ new Set([
2848
+ "HEAD",
2849
+ "index",
2850
+ "packed-refs",
2851
+ "logs/HEAD"
2852
+ ]);
2853
+ /**
2854
+ * Which relative paths can change the diff. Watching the `.git` directory rather
2855
+ * than those four files is deliberate: git replaces HEAD and index by lockfile
2856
+ * rename, so a watch pinned to the file follows the old inode and goes deaf.
2857
+ *
2858
+ * Everything else passes, gitignored build output included — the cooldown below
2859
+ * bounds what a build storm costs, and the store's hash dedup keeps it correct.
2860
+ */
2861
+ function affectsDiff(rel) {
2862
+ const parts = rel.split(/[\\/]/);
2863
+ if (parts.includes("node_modules")) return false;
2864
+ if (parts[0] !== ".git") return true;
2865
+ return GIT_STATE_FILES.has(parts.slice(1).join("/"));
2866
+ }
2867
+ /**
2868
+ * Watch the repo for anything that can change the diff — working-tree writes, plus
2869
+ * the git state files behind commits, branch switches and staging. Events are
2870
+ * debounced into one `onChange` per burst.
2871
+ *
2872
+ * **One** watcher for the whole tree, via the platform's recursive watch: a
2873
+ * per-directory walk over a 17,639-directory monorepo opened ~16.5k descriptors and
2874
+ * hit `EMFILE`, leaving the server bound to its port and unable to accept a
2875
+ * connection. Recursive watch costs one descriptor however big the repo is.
2876
+ */
2877
+ function watchRepo(root, onChange, debounceMs = DEBOUNCE_MS) {
2878
+ let timer = null;
2879
+ /**
2880
+ * How long the last recompute took — the cooldown before the next one. A full
2881
+ * recompute measures ~700ms on a large repo and runs on the event loop, so a
2882
+ * debounce alone does not bound it: a build writing continuously buys another
2883
+ * stall per surviving burst. Charging the measured cost back as idle time caps the
2884
+ * duty cycle near 50%. Nothing is dropped — the trailing recompute is delayed,
2885
+ * never cancelled.
2886
+ */
2887
+ let cooldownUntil = 0;
2888
+ const fire = () => {
2889
+ if (timer) clearTimeout(timer);
2890
+ const wait = Math.max(debounceMs, cooldownUntil - Date.now());
2891
+ timer = setTimeout(() => {
2892
+ timer = null;
2893
+ const started = Date.now();
2894
+ Promise.resolve().then(onChange).catch((err) => {
2895
+ console.error(`[diffo] changeset refresh failed: ${err.message}`);
2896
+ }).finally(() => {
2897
+ const ended = Date.now();
2898
+ cooldownUntil = ended + Math.min(ended - started, MAX_COOLDOWN_MS);
2899
+ });
2900
+ }, wait);
2901
+ };
2902
+ let watcher = null;
2903
+ /**
2904
+ * A watcher that cannot run gives up its handle and says so **once**, and the
2905
+ * server keeps answering with a changeset that no longer refreshes itself. Logging
2906
+ * per failure is what turned an EMFILE into 7,549 identical lines.
2907
+ */
2908
+ const giveUp = (err) => {
2909
+ if (!watcher) return;
2910
+ watcher.close();
2911
+ watcher = null;
2912
+ console.error(`[diffo] live updates off — file watcher failed: ${err.message}\n[diffo] the review still serves; reload after a change to see it.`);
2913
+ };
2914
+ try {
2915
+ watcher = watch(root, {
2916
+ recursive: true,
2917
+ persistent: true
2918
+ });
2919
+ watcher.on("change", (_event, filename) => {
2920
+ if (filename == null) return fire();
2921
+ if (affectsDiff(String(filename))) fire();
2922
+ });
2923
+ watcher.on("error", giveUp);
2924
+ } catch (err) {
2925
+ giveUp(err);
2926
+ }
2927
+ return () => {
2928
+ if (timer) clearTimeout(timer);
2929
+ watcher?.close();
2930
+ watcher = null;
2931
+ };
2932
+ }
2933
+ //#endregion
2934
+ //#region src/server/index.ts
2935
+ /** Types the UI embeds in <img>/srcset — the only repo content that may keep
2936
+ * its real MIME on `/api/file`. `.svg` can carry script, so every `/api/file`
2937
+ * response also gets `Content-Security-Policy: sandbox`: a direct navigation
2938
+ * renders it scriptless in an opaque origin, while <img> decoding is untouched. */
2939
+ const IMAGE_MIME = {
2940
+ ".svg": "image/svg+xml",
2941
+ ".png": "image/png",
2942
+ ".jpg": "image/jpeg",
2943
+ ".jpeg": "image/jpeg",
2944
+ ".gif": "image/gif",
2945
+ ".webp": "image/webp",
2946
+ ".bmp": "image/bmp",
2947
+ ".avif": "image/avif",
2948
+ ".ico": "image/x-icon"
2949
+ };
2950
+ const MIME = {
2951
+ ".html": "text/html",
2952
+ ".js": "application/javascript",
2953
+ ".css": "text/css",
2954
+ ".woff2": "font/woff2",
2955
+ ...IMAGE_MIME
2956
+ };
2957
+ /** Whitespace heartbeats keep a long poll alive through proxies and idle
2958
+ * timeouts — the final JSON parses fine after them. */
2959
+ const POLL_HEARTBEAT_MS = 15e3;
2960
+ /**
2961
+ * How long one `diffo poll` may wait before being sent away to try again.
2962
+ * Bounds the *connection*, not the agent's patience: a half-open socket never
2963
+ * aborts, and a listening poll blocks the daemon's idle reap.
2964
+ */
2965
+ const POLL_MAX_MS = 18e5;
2966
+ function createApp(ctx, store, review, queue) {
2967
+ const app = new Hono();
2968
+ if (review && queue) queue.onBatchClosed((closed) => {
2969
+ if (closed.unanswered.length > 0) review.markUnanswered(closed.unanswered);
2970
+ });
2971
+ const LOOPBACK = /* @__PURE__ */ new Set([
2972
+ "localhost",
2973
+ "127.0.0.1",
2974
+ "[::1]",
2975
+ "::1"
2976
+ ]);
2977
+ const isLoopbackHost = (value) => {
2978
+ const host = value.startsWith("[") ? value.replace(/^(\[[^\]]*\])(?::\d+)?$/, "$1") : value.replace(/:\d+$/, "");
2979
+ return LOOPBACK.has(host.toLowerCase());
2980
+ };
2981
+ app.use("*", async (c, next) => {
2982
+ const host = c.req.header("host") ?? new URL(c.req.url).host;
2983
+ if (!isLoopbackHost(host)) return c.text("Forbidden: unexpected Host header", 403);
2984
+ const origin = c.req.header("origin");
2985
+ if (origin !== void 0) {
2986
+ let originHost;
2987
+ try {
2988
+ originHost = new URL(origin).hostname;
2989
+ } catch {
2990
+ return c.text("Forbidden: malformed Origin", 403);
2991
+ }
2992
+ if (!LOOPBACK.has(originHost.toLowerCase())) return c.text("Forbidden: cross-origin request", 403);
2993
+ }
2994
+ return next();
2995
+ });
2996
+ const parseSessionPid = (raw) => {
2997
+ if (!raw) return null;
2998
+ const pid = Number.parseInt(raw, 10);
2999
+ return Number.isInteger(pid) && pid > 1 ? pid : null;
3000
+ };
3001
+ app.use("*", async (_c, next) => {
3002
+ ctx.idle?.touch();
3003
+ return next();
3004
+ });
3005
+ app.get("/api/health", (c) => c.json({
3006
+ ok: true,
3007
+ repo: resolve(ctx.root),
3008
+ app: "diffo",
3009
+ version: VERSION,
3010
+ pid: process.pid
3011
+ }));
3012
+ app.post("/api/shutdown", (c) => {
3013
+ setImmediate(() => ctx.onShutdownRequest?.());
3014
+ return c.json({
3015
+ ok: true,
3016
+ note: "shutting down"
3017
+ });
3018
+ });
3019
+ const repoInfo = () => store?.get().repo ?? {
3020
+ path: resolve(ctx.root),
3021
+ name: "",
3022
+ branch: "",
3023
+ worktree: null
3024
+ };
3025
+ const promptCtx = (...excludeThreadIds) => ({
3026
+ repo: repoInfo(),
3027
+ changeset: store?.get() ?? null,
3028
+ siblings: review?.get().threads.filter((t) => !excludeThreadIds.includes(t.id) && (t.state === "open" || t.state === "sent")) ?? []
3029
+ });
3030
+ const deliverThreads = (threadIds) => {
3031
+ if (!queue) return false;
3032
+ const live = queue.hasListener();
3033
+ queue.enqueueThreads(threadIds);
3034
+ return live;
3035
+ };
3036
+ app.get("/api/review", (c) => {
3037
+ if (!review) return c.json({ error: "review unavailable" }, 503);
3038
+ return c.json(review.get());
3039
+ });
3040
+ /** Where an agent thread lands: the hunk showing its new-side line, else its
3041
+ * file, else the changeset — the same fallback ladder rendered threads use. */
3042
+ const agentAnchor = (file, line) => {
3043
+ if (file === null) return { kind: "changeset" };
3044
+ const inChangeset = (store?.get().files ?? []).find((f) => f.path === file);
3045
+ if (inChangeset && line !== null) {
3046
+ for (const hunk of inChangeset.hunks) if (hunk.lines.some((l) => l.kind !== "del" && l.newNo === line)) return {
3047
+ kind: "hunk",
3048
+ hunkId: hunk.id,
3049
+ path: file,
3050
+ side: "new",
3051
+ line
3052
+ };
3053
+ }
3054
+ return inChangeset ? {
3055
+ kind: "file",
3056
+ path: file
3057
+ } : { kind: "changeset" };
3058
+ };
3059
+ app.post("/api/review/threads", async (c) => {
3060
+ if (!review) return c.json({ error: "review unavailable" }, 503);
3061
+ const body = await c.req.json().catch(() => null);
3062
+ const text = typeof body?.text === "string" ? body.text.trim() : "";
3063
+ if (!text) return c.json({ error: "need {anchor, text}" }, 400);
3064
+ if (body?.author === "agent") {
3065
+ const file = typeof body?.file === "string" && body.file.trim() !== "" ? body.file.trim() : null;
3066
+ const line = typeof body?.line === "number" && Number.isInteger(body.line) && body.line >= 1 ? body.line : null;
3067
+ 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"));
3070
+ }
3071
+ const anchor = parseAnchor(body?.anchor);
3072
+ if (!anchor) return c.json({ error: "need {anchor, text}" }, 400);
3073
+ 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));
3076
+ });
3077
+ app.post("/api/review/threads/:id/messages", async (c) => {
3078
+ if (!review) return c.json({ error: "review unavailable" }, 503);
3079
+ const body = await c.req.json().catch(() => null);
3080
+ const text = typeof body?.text === "string" ? body.text.trim() : "";
3081
+ if (!text) return c.json({ error: "need {text}" }, 400);
3082
+ if (body?.author === "agent") {
3083
+ const id = c.req.param("id");
3084
+ if (!review.get().threads.some((t) => t.id === id)) return c.json({ error: "no such thread" }, 404);
3085
+ queue?.noteSession(parseSessionPid(c.req.header("x-diffo-session-pid")));
3086
+ const waitedMs = queue?.agentReplied(id) ?? null;
3087
+ const seenThroughMs = waitedMs === null ? void 0 : Date.now() - waitedMs;
3088
+ const thread = review.addMessage(id, "agent", text, false, seenThroughMs);
3089
+ if (!thread) return c.json({ error: "no such thread" }, 404);
3090
+ if (waitedMs !== null) review.annotateAgentReplies([thread.id], waitedMs);
3091
+ return c.json({
3092
+ thread,
3093
+ delivered: false
3094
+ });
3095
+ }
3096
+ const deliver = body?.deliver !== false;
3097
+ let thread = review.addMessage(c.req.param("id"), "reviewer", text, !deliver);
3098
+ if (!thread) return c.json({ error: "no such thread" }, 404);
3099
+ let delivered = false;
3100
+ if (deliver && (thread.state === "sent" || thread.state === "addressed")) {
3101
+ delivered = deliverThreads([thread.id]);
3102
+ review.clearWithheld([thread.id]);
3103
+ thread = review.get().threads.find((t) => t.id === thread.id) ?? thread;
3104
+ }
3105
+ return c.json({
3106
+ thread,
3107
+ delivered,
3108
+ presence: queue?.presence() ?? "waiting"
3109
+ });
3110
+ });
3111
+ app.post("/api/review/threads/:id/state", async (c) => {
3112
+ if (!review) return c.json({ error: "review unavailable" }, 503);
3113
+ const state = (await c.req.json().catch(() => null))?.state;
3114
+ if (state !== "open" && state !== "resolved") return c.json({ error: "state must be \"resolved\" or \"open\" (reopen)" }, 400);
3115
+ const thread = review.setState(c.req.param("id"), state);
3116
+ return thread ? c.json(thread) : c.json({ error: "no such thread" }, 404);
3117
+ });
3118
+ app.delete("/api/review/threads", (c) => {
3119
+ if (!review) return c.json({ error: "review unavailable" }, 503);
3120
+ const removed = review.reset();
3121
+ for (const id of removed) queue?.drop(id);
3122
+ return c.json({ removed: removed.length });
3123
+ });
3124
+ app.delete("/api/review/landed", (c) => {
3125
+ if (!review) return c.json({ error: "review unavailable" }, 503);
3126
+ review.clearLanded();
3127
+ return c.json({ ok: true });
3128
+ });
3129
+ app.delete("/api/review/threads/:id", (c) => {
3130
+ if (!review) return c.json({ error: "review unavailable" }, 503);
3131
+ const id = c.req.param("id");
3132
+ if (!review.removeThread(id)) return c.json({ error: "no such thread" }, 404);
3133
+ queue?.drop(id);
3134
+ return c.json({ removed: true });
3135
+ });
3136
+ app.post("/api/review/threads/:id/send", (c) => {
3137
+ if (!review) return c.json({ error: "review unavailable" }, 503);
3138
+ const thread = review.send(c.req.param("id"));
3139
+ if (!thread) return c.json({ error: "no such thread" }, 404);
3140
+ const prompt = buildThreadPrompt(thread, promptCtx(thread.id));
3141
+ const presence = queue?.presence() ?? "waiting";
3142
+ const handedOver = thread.state === "sent" || thread.state === "addressed" && thread.withheld === true;
3143
+ const delivered = handedOver ? deliverThreads([thread.id]) : false;
3144
+ if (handedOver) review.clearWithheld([thread.id]);
3145
+ return c.json({
3146
+ thread,
3147
+ prompt,
3148
+ delivered,
3149
+ presence
3150
+ });
3151
+ });
3152
+ /** Coverage off the wire: a real 0 must not read as "absent" (which
3153
+ * `Number(x) || 0` would), and NaN-shaped garbage must not read as a number. */
3154
+ const parseCoverage = (raw) => {
3155
+ const src = raw ?? {};
3156
+ const count = (value) => {
3157
+ const n = Number(value);
3158
+ return Number.isFinite(n) ? n : void 0;
3159
+ };
3160
+ const verdict = REVIEW_VERDICTS.includes(src.verdict) ? src.verdict : void 0;
3161
+ const note = typeof src.note === "string" && src.note.trim() !== "" ? src.note.trim().slice(0, 4e3) : void 0;
3162
+ const fileList = (value) => Array.isArray(value) ? value.filter((f) => typeof f === "string") : void 0;
3163
+ const changedFiles = fileList(src.changedFiles);
3164
+ const commentedUnread = fileList(src.commentedUnread);
3165
+ return {
3166
+ viewedHunks: count(src.viewedHunks) ?? 0,
3167
+ totalHunks: count(src.totalHunks) ?? 0,
3168
+ viewedFiles: count(src.viewedFiles),
3169
+ totalFiles: count(src.totalFiles),
3170
+ skippedFiles: fileList(src.skippedFiles) ?? [],
3171
+ ...changedFiles !== void 0 ? { changedFiles } : {},
3172
+ ...commentedUnread !== void 0 ? { commentedUnread } : {},
3173
+ ...verdict !== void 0 ? { verdict } : {},
3174
+ ...note !== void 0 ? { note } : {}
3175
+ };
3176
+ };
3177
+ const activeThreads = (threads) => threadsInChangeset(store?.get().files ?? [], threads).active;
3178
+ const flushableIds = (threads) => new Set(activeThreads(threads).filter((t) => !untouchedAgentVoice(t)).map((t) => t.id));
3179
+ const projectFinish = (threads) => {
3180
+ const flushable = flushableIds(threads);
3181
+ return threads.map((t) => t.state === "open" && flushable.has(t.id) ? {
3182
+ ...t,
3183
+ state: "sent"
3184
+ } : t);
3185
+ };
3186
+ /**
3187
+ * A closing note only becomes a thread while the changeset still has files to
3188
+ * anchor it to — an empty diff would file it straight into the past, where the
3189
+ * reviewer can't see it and the agent is never given it. In that case the note
3190
+ * stays prose in the prompt (`buildFinishPrompt` quotes it when no thread carries
3191
+ * it), which is all it ever was.
3192
+ */
3193
+ const notable = (coverage) => coverage.note !== void 0 && (store?.get().files.length ?? 0) > 0;
3194
+ /** The preview must show the note the same way Finish will send it — a thread in
3195
+ * the batch — without writing one for a review the reviewer may still walk away
3196
+ * from. */
3197
+ const projectClosingNote = (threads, coverage) => {
3198
+ if (!notable(coverage)) return threads;
3199
+ const at = (/* @__PURE__ */ new Date()).toISOString();
3200
+ return [...threads, {
3201
+ id: "closing-note-preview",
3202
+ anchor: { kind: "changeset" },
3203
+ state: "sent",
3204
+ closingNote: true,
3205
+ codeContext: null,
3206
+ codeChanged: false,
3207
+ messages: [{
3208
+ id: "closing-note-preview-message",
3209
+ author: "reviewer",
3210
+ text: coverage.note,
3211
+ at
3212
+ }],
3213
+ createdAt: at,
3214
+ updatedAt: at
3215
+ }];
3216
+ };
3217
+ const outgoing = (projected, before) => projected.filter((t) => t.state === "sent").map((t) => ({
3218
+ id: t.id,
3219
+ anchor: t.anchor,
3220
+ text: t.messages[0]?.text ?? "",
3221
+ fresh: (before.find((b) => b.id === t.id)?.state ?? "open") === "open"
3222
+ }));
3223
+ app.post("/api/review/finish/preview", async (c) => {
3224
+ if (!review) return c.json({ error: "review unavailable" }, 503);
3225
+ const body = await c.req.json().catch(() => null);
3226
+ const coverage = parseCoverage(body?.coverage);
3227
+ const before = review.get().threads;
3228
+ const batch = projectClosingNote(activeThreads(projectFinish(before)), coverage);
3229
+ return c.json({
3230
+ outgoing: outgoing(batch, before),
3231
+ prompt: buildFinishPrompt(batch, {
3232
+ repo: repoInfo(),
3233
+ changeset: store?.get() ?? null
3234
+ }, coverage)
3235
+ });
3236
+ });
3237
+ app.post("/api/review/finish", async (c) => {
3238
+ if (!review) return c.json({ error: "review unavailable" }, 503);
3239
+ const body = await c.req.json().catch(() => null);
3240
+ const coverage = parseCoverage(body?.coverage);
3241
+ const deliver = body?.deliver !== false;
3242
+ if (notable(coverage)) review.closingNote(coverage.note);
3243
+ const threads = review.finish(flushableIds(review.get().threads));
3244
+ const batch = activeThreads(threads);
3245
+ review.recordFinish((store?.get().files ?? []).flatMap((f) => f.hunks.map((h) => h.id)), coverage);
3246
+ const prompt = buildFinishPrompt(batch, {
3247
+ repo: repoInfo(),
3248
+ changeset: store?.get() ?? null
3249
+ }, coverage);
3250
+ const presence = queue?.presence() ?? "waiting";
3251
+ let delivered = false;
3252
+ if (queue && deliver) {
3253
+ delivered = queue.hasListener();
3254
+ queue.enqueueFinish(coverage);
3255
+ }
3256
+ review.clearWithheld(batch.map((t) => t.id));
3257
+ return c.json({
3258
+ threads: review.get().threads,
3259
+ prompt,
3260
+ delivered,
3261
+ presence
3262
+ });
3263
+ });
3264
+ const pollPayload = (snapshot) => {
3265
+ const threads = review?.get().threads ?? [];
3266
+ if (snapshot.kind === "finish") {
3267
+ const batch = activeThreads(threads);
3268
+ return {
3269
+ status: "feedback",
3270
+ kind: "finish",
3271
+ threadIds: batch.filter((t) => t.state === "sent").map((t) => t.id),
3272
+ prompt: buildFinishPrompt(batch, {
3273
+ repo: repoInfo(),
3274
+ changeset: store?.get() ?? null
3275
+ }, snapshot.coverage)
3276
+ };
3277
+ }
3278
+ 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));
3280
+ return {
3281
+ status: "feedback",
3282
+ kind: "threads",
3283
+ threadIds: delivered.map((t) => t.id),
3284
+ prompt
3285
+ };
3286
+ };
3287
+ app.get("/api/agent/poll", (c) => {
3288
+ if (!review || !queue) return c.json({ error: "review unavailable" }, 503);
3289
+ 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);
3290
+ const tookOverFrom = queue.claimSession(parseSessionPid(c.req.header("x-diffo-session-pid")));
3291
+ if (tookOverFrom !== null) c.header("x-diffo-took-over-from", String(tookOverFrom));
3292
+ const heartbeatMs = ctx.pollHeartbeatMs ?? POLL_HEARTBEAT_MS;
3293
+ return stream(c, async (s) => {
3294
+ let aborted = false;
3295
+ let detach = null;
3296
+ const release = () => detach?.();
3297
+ ctx.idle?.connect();
3298
+ let counted = true;
3299
+ const drop = () => {
3300
+ if (!counted) return;
3301
+ counted = false;
3302
+ ctx.idle?.disconnect();
3303
+ };
3304
+ s.onAbort(() => {
3305
+ aborted = true;
3306
+ release();
3307
+ drop();
3308
+ });
3309
+ try {
3310
+ const deadline = Date.now() + (ctx.pollMaxMs ?? POLL_MAX_MS);
3311
+ while (!aborted) {
3312
+ let settled = false;
3313
+ const attach = queue.attach((d) => detach = d).then((outcome) => {
3314
+ settled = true;
3315
+ return outcome;
3316
+ });
3317
+ while (!settled && !aborted && Date.now() < deadline) {
3318
+ const nap = Math.min(heartbeatMs, deadline - Date.now());
3319
+ await Promise.race([attach, new Promise((r) => setTimeout(r, nap))]);
3320
+ if (!settled && !aborted) await s.write(" ");
3321
+ }
3322
+ if (aborted) return;
3323
+ if (!settled) {
3324
+ release();
3325
+ await s.write(JSON.stringify({
3326
+ status: "timeout",
3327
+ message: "no feedback within the poll window — nothing is lost; re-run `diffo poll` to keep listening"
3328
+ }));
3329
+ return;
3330
+ }
3331
+ const outcome = await attach;
3332
+ if (outcome === "gone") return;
3333
+ if (outcome === "superseded") {
3334
+ await s.write(JSON.stringify({
3335
+ status: "superseded",
3336
+ message: "another poll attached for this repo — this one is released. The newer poll carries the review now, so do nothing here and do not re-poll unless the user asks. If that was not you, another agent session is on this repo: say so, so the user knows where their feedback is going."
3337
+ }));
3338
+ return;
3339
+ }
3340
+ if (outcome === "ended") {
3341
+ await s.write(JSON.stringify({
3342
+ status: "ended",
3343
+ message: "the agent detached (`diffo end`) — do not re-poll unless asked; deliver anything remaining directly in the conversation"
3344
+ }));
3345
+ return;
3346
+ }
3347
+ const snapshot = queue.take();
3348
+ if (!snapshot) continue;
3349
+ const inner = pollPayload(snapshot);
3350
+ const payload = {
3351
+ ...inner,
3352
+ next_step: nextStepFor(inner.kind, inner.threadIds.length)
3353
+ };
3354
+ await s.write(JSON.stringify(payload));
3355
+ if (aborted) return;
3356
+ queue.confirm(snapshot, payload.threadIds);
3357
+ review.markDelivered(payload.threadIds);
3358
+ review.clearUnanswered(payload.threadIds);
3359
+ if (snapshot.kind === "finish") review.markFinishCollected();
3360
+ return;
3361
+ }
3362
+ } finally {
3363
+ drop();
3364
+ }
3365
+ });
3366
+ });
3367
+ app.post("/api/agent/end", (c) => {
3368
+ if (!queue) return c.json({ error: "agent queue unavailable" }, 503);
3369
+ const sessionPid = parseSessionPid(c.req.header("x-diffo-session-pid"));
3370
+ if (queue.end(sessionPid)) return c.json({ ok: true });
3371
+ return c.json({
3372
+ ok: false,
3373
+ reason: "not-owner",
3374
+ ownerPid: queue.ownerPid(),
3375
+ message: "this review belongs to another agent session — nothing was detached, and that session's poll is untouched"
3376
+ });
3377
+ });
3378
+ app.get("/api/agent/invite", (c) => c.json({
3379
+ install: INSTALL_SKILL,
3380
+ join: JOIN_PROMPT,
3381
+ presence: queue?.presence() ?? "waiting"
3382
+ }));
3383
+ app.get("/api/changeset", (c) => {
3384
+ try {
3385
+ return c.json(store ? store.get() : new ChangesetStore(ctx.root, ctx.spec).get());
3386
+ } catch (err) {
3387
+ if (err instanceof MissingBaseError) return c.json({ error: err.message }, 400);
3388
+ throw err;
3389
+ }
3390
+ });
3391
+ app.get("/api/file", (c) => {
3392
+ const path = c.req.query("path");
3393
+ const side = c.req.query("side");
3394
+ if (!path || side !== "base" && side !== "head") return c.json({ error: "need ?path= and ?side=base|head" }, 400);
3395
+ let bytes;
3396
+ try {
3397
+ bytes = side === "head" ? getHeadFileBytes(ctx.root, path) : getBaseFileBytes(ctx.root, resolveBaseRef(ctx.root, ctx.spec), path);
3398
+ } catch (err) {
3399
+ if (err instanceof MissingBaseError) return c.json({ error: err.message }, 400);
3400
+ throw err;
3401
+ }
3402
+ if (bytes === null) return c.json({ error: "no such file on that side" }, 404);
3403
+ const mime = IMAGE_MIME[extname(path)] ?? "text/plain; charset=utf-8";
3404
+ return c.body(new Uint8Array(bytes), 200, {
3405
+ "Content-Type": mime,
3406
+ "Content-Security-Policy": "sandbox",
3407
+ "X-Content-Type-Options": "nosniff"
3408
+ });
3409
+ });
3410
+ app.get("/api/events", (c) => streamSSE(c, async (sse) => {
3411
+ if (!store) {
3412
+ await sse.close();
3413
+ return;
3414
+ }
3415
+ let id = 0;
3416
+ let closed = false;
3417
+ ctx.idle?.connect();
3418
+ let counted = true;
3419
+ const drop = () => {
3420
+ if (!counted) return;
3421
+ counted = false;
3422
+ ctx.idle?.disconnect();
3423
+ };
3424
+ const unsubscribe = store.subscribe((changeset) => {
3425
+ sse.writeSSE({
3426
+ event: "changeset",
3427
+ data: JSON.stringify({ version: changeset.version }),
3428
+ id: String(id++)
3429
+ });
3430
+ });
3431
+ const unsubscribeReview = review?.subscribe(() => {
3432
+ sse.writeSSE({
3433
+ event: "review",
3434
+ data: "{}",
3435
+ id: String(id++)
3436
+ });
3437
+ });
3438
+ const unsubscribePresence = queue?.subscribe(() => {
3439
+ sse.writeSSE({
3440
+ event: "presence",
3441
+ data: JSON.stringify({
3442
+ ...queue.presenceDetail(),
3443
+ workingOn: queue.deliveredThreadIds(),
3444
+ queued: queue.queuedThreadIds(),
3445
+ answered: queue.currentBatch()?.answered ?? []
3446
+ }),
3447
+ id: String(id++)
3448
+ });
3449
+ });
3450
+ sse.onAbort(() => {
3451
+ closed = true;
3452
+ drop();
3453
+ unsubscribe();
3454
+ unsubscribeReview?.();
3455
+ unsubscribePresence?.();
3456
+ });
3457
+ await sse.writeSSE({
3458
+ event: "changeset",
3459
+ data: JSON.stringify({ version: store.get().version }),
3460
+ id: String(id++)
3461
+ });
3462
+ if (queue) await sse.writeSSE({
3463
+ event: "presence",
3464
+ data: JSON.stringify({
3465
+ ...queue.presenceDetail(),
3466
+ workingOn: queue.deliveredThreadIds(),
3467
+ queued: queue.queuedThreadIds(),
3468
+ answered: queue.currentBatch()?.answered ?? []
3469
+ }),
3470
+ id: String(id++)
3471
+ });
3472
+ while (!closed) {
3473
+ await sse.sleep(15e3);
3474
+ await sse.writeSSE({
3475
+ event: "ping",
3476
+ data: ""
3477
+ });
3478
+ }
3479
+ }));
3480
+ const serveIndex = async (c) => {
3481
+ try {
3482
+ const index = await readFile(resolve(ctx.clientDir, "index.html"));
3483
+ return c.html(markDevIndex(index.toString(), IS_DEV));
3484
+ } catch {
3485
+ return c.text("Client not built — run `pnpm build`", 404);
3486
+ }
3487
+ };
3488
+ app.get("/*", async (c) => {
3489
+ const requested = c.req.path === "/" ? "index.html" : c.req.path.slice(1);
3490
+ const fullPath = resolve(ctx.clientDir, requested);
3491
+ if (!fullPath.startsWith(resolve(ctx.clientDir) + sep)) return c.text("Forbidden", 403);
3492
+ if (resolve(fullPath) === resolve(ctx.clientDir, "index.html")) return serveIndex(c);
3493
+ try {
3494
+ const content = await readFile(fullPath);
3495
+ const mime = MIME[extname(fullPath)] ?? "application/octet-stream";
3496
+ return c.body(new Uint8Array(content), 200, { "Content-Type": mime });
3497
+ } catch {
3498
+ return serveIndex(c);
3499
+ }
3500
+ });
3501
+ return app;
3502
+ }
3503
+ /**
3504
+ * A review served from a source checkout has to LOOK like one. The reviewer may
3505
+ * have the shipped `/diffo` open in another tab, and nothing else on the page
3506
+ * distinguishes them — same UI, same repo, same diff. The title carries it into
3507
+ * the tab strip; the meta tag lets the header render a badge without an extra
3508
+ * round trip, so there is no flash of a review that looks released.
3509
+ */
3510
+ function markDevIndex(html, isDev) {
3511
+ if (!isDev || html.includes("name=\"diffo-env\"")) return html;
3512
+ return html.replace(/<title>[^<]*<\/title>/, "<title>diffo-dev</title>").replace(/<\/head>/, " <meta name=\"diffo-env\" content=\"development\" />\n </head>");
3513
+ }
3514
+ function rehydrateQueue(review, queue) {
3515
+ const state = review.get();
3516
+ queue.enqueueThreads(undeliveredThreadIds(state.threads));
3517
+ const finish = state.lastFinish;
3518
+ if (finish && !finish.collectedAt) queue.enqueueFinish(finish.coverage);
3519
+ }
3520
+ var RepoAlreadyServedError = class extends Error {
3521
+ holder;
3522
+ constructor(holder) {
3523
+ super(`diffo is already serving this repo on port ${holder.port}`);
3524
+ this.holder = holder;
3525
+ this.name = "RepoAlreadyServedError";
3526
+ }
3527
+ };
3528
+ function startServer(options) {
3529
+ const store = new ChangesetStore(options.root, options.spec);
3530
+ const db = new DiffoDb();
3531
+ const { claimed, holder } = db.claimServer(resolve(options.root), options.port, process.pid);
3532
+ if (!claimed) {
3533
+ db.close();
3534
+ throw new RepoAlreadyServedError(holder);
3535
+ }
3536
+ db.setPreferredPort(resolve(options.root), options.port);
3537
+ const review = new ReviewStore(options.root, db, options.spec);
3538
+ const queue = new DeliveryQueue();
3539
+ queue.rescope(store.get().repo.branch);
3540
+ rehydrateQueue(review, queue);
3541
+ const log = (message) => console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { hour12: false })}] ${message}`);
3542
+ queue.subscribe((presence) => log(`agent ${presence}`));
3543
+ const unsubscribeBatch = queue.onBatchClosed((closed) => {
3544
+ const took = Math.round((closed.closedAt - closed.deliveredAt) / 1e3);
3545
+ log(`agent finished a batch of ${closed.threadIds.length} in ${took}s (${closed.reason})` + (closed.unanswered.length > 0 ? ` — ${closed.unanswered.length} unanswered` : ""));
3546
+ });
3547
+ const landedGit = {
3548
+ head: () => getHeadSha(options.root),
3549
+ isAncestor: (ancestor, descendant) => isAncestor(options.root, ancestor, descendant),
3550
+ subject: (sha) => getCommitSubject(options.root, sha)
3551
+ };
3552
+ const checkLanded = (hasFiles) => {
3553
+ if (maintainLanded(review, hasFiles, landedGit) === "stamped") {
3554
+ const landed = review.get().landed;
3555
+ if (landed) log(`changeset landed in ${landed.sha.slice(0, 7)} — offering a fresh review`);
3556
+ }
3557
+ };
3558
+ const stopWatching = watchRepo(options.root, () => store.refresh());
3559
+ const unsubscribeReconcile = store.subscribe((changeset) => {
3560
+ const before = review.scope.branch;
3561
+ review.rescope(changeset.repo.branch);
3562
+ queue.rescope(changeset.repo.branch);
3563
+ 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);
3566
+ });
3567
+ review.reconcile(new Set(store.get().files.flatMap((f) => f.hunks.map((h) => h.id))));
3568
+ checkLanded(store.get().files.length > 0);
3569
+ const idleTimeoutMs = resolveIdleTimeoutMs(process.env);
3570
+ let idle;
3571
+ if (idleTimeoutMs !== null) {
3572
+ idle = new IdleMonitor({
3573
+ timeoutMs: idleTimeoutMs,
3574
+ onIdle: () => {
3575
+ log(`idle for ${Math.round(idleTimeoutMs / 6e4)}m with no browser or agent — shutting down`);
3576
+ process.exit(0);
3577
+ }
3578
+ });
3579
+ idle.start();
3580
+ }
3581
+ const app = createApp({
3582
+ ...options,
3583
+ idle,
3584
+ onShutdownRequest: options.onShutdownRequest ?? (() => process.exit(0))
3585
+ }, store, review, queue);
3586
+ const server = serve({
3587
+ fetch: app.fetch,
3588
+ port: options.port,
3589
+ hostname: "127.0.0.1"
3590
+ });
3591
+ const deregister = () => {
3592
+ try {
3593
+ db.removeServer(review.repoPath, options.port);
3594
+ } catch {}
3595
+ };
3596
+ process.once("exit", deregister);
3597
+ server.on("error", (err) => {
3598
+ deregister();
3599
+ if (options.onListenError) return options.onListenError(err);
3600
+ console.error(err.code === "EADDRINUSE" ? `diffo: port ${options.port} was taken while starting up — re-run to pick another` : `diffo: cannot listen on port ${options.port} — ${err.message}`);
3601
+ process.exit(1);
3602
+ });
3603
+ return {
3604
+ server,
3605
+ store,
3606
+ review,
3607
+ queue,
3608
+ stopWatching: async () => {
3609
+ idle?.stop();
3610
+ unsubscribeReconcile();
3611
+ unsubscribeBatch();
3612
+ await stopWatching();
3613
+ deregister();
3614
+ process.removeListener("exit", deregister);
3615
+ db.close();
3616
+ }
3617
+ };
3618
+ }
3619
+ //#endregion
3620
+ //#region src/serverUrl.ts
3621
+ /**
3622
+ * Every URL that reaches a review server is built here, and nowhere else.
3623
+ * Two names on purpose — `apiUrl` is what the CLI speaks over HTTP, `reviewUrl`
3624
+ * is what a human opens — so the managed version can swap either base (a remote
3625
+ * host, auth in the URL) without touching a call site. The listen host in
3626
+ * `src/server/index.ts` is the other side of this seam and stays separate: it
3627
+ * says where the local server binds, not how anything reaches it.
3628
+ */
3629
+ function apiUrl(port, path = "") {
3630
+ return `http://127.0.0.1:${port}${path}`;
3631
+ }
3632
+ function reviewUrl(port) {
3633
+ return `http://localhost:${port}`;
3634
+ }
3635
+ //#endregion
3636
+ //#region src/serverLifecycle.ts
3637
+ const MAX_LOG_BYTES = 2097152;
3638
+ function assessRunningServer(health, repoPath, version) {
3639
+ if (health?.ok !== true || health.repo !== repoPath) return "foreign";
3640
+ return health.app === "diffo" && health.version === version ? "reuse" : "replace";
3641
+ }
3642
+ /**
3643
+ * argv for the daemon: the same runtime flags executing this CLI, the same entry
3644
+ * script, headless. `--foreground` is not a contradiction — it means "hold the
3645
+ * server in THIS process", and without it the child would spawn another one of
3646
+ * itself, forever.
3647
+ */
3648
+ function serverSpawnArgs(entry, execArgv, port, base) {
3649
+ const args = [
3650
+ ...execArgv,
3651
+ entry,
3652
+ "--no-open",
3653
+ "--foreground"
3654
+ ];
3655
+ if (port !== void 0) args.push("-p", String(port));
3656
+ if (base !== void 0) args.push("--base", base);
3657
+ return args;
3658
+ }
3659
+ async function fetchHealth(port, timeoutMs = 1500) {
3660
+ try {
3661
+ const res = await fetch(apiUrl(port, "/api/health"), { signal: AbortSignal.timeout(timeoutMs) });
3662
+ if (!res.ok) return null;
3663
+ return await res.json();
3664
+ } catch {
3665
+ return null;
3666
+ }
3667
+ }
3668
+ async function delay(ms) {
3669
+ await new Promise((r) => setTimeout(r, ms));
3670
+ }
3671
+ async function waitUntil(predicate, timeoutMs, stepMs = 120) {
3672
+ const deadline = Date.now() + timeoutMs;
3673
+ while (Date.now() < deadline) {
3674
+ if (await predicate()) return true;
3675
+ await delay(stepMs);
3676
+ }
3677
+ return predicate();
3678
+ }
3679
+ /**
3680
+ * Retire a server we own: ask nicely over HTTP, and only if it lingers (a
3681
+ * pre-shutdown-endpoint build 404s the POST) fall back to SIGTERM.
3682
+ *
3683
+ * The health check proves the *port* belongs to this repo's diffo; the pid comes
3684
+ * from the registry, and after a crash and a pid reuse it can name something else.
3685
+ * So the signal is only sent when the server itself named the same pid — except on
3686
+ * builds that report no pid, where the registry's stays the fallback.
3687
+ */
3688
+ async function retireServer(port, pid, reportedPid = null) {
3689
+ try {
3690
+ await fetch(apiUrl(port, "/api/shutdown"), {
3691
+ method: "POST",
3692
+ signal: AbortSignal.timeout(1500)
3693
+ });
3694
+ } catch {}
3695
+ const gone = () => fetchHealth(port).then((h) => h === null);
3696
+ if (await waitUntil(gone, 2e3)) return true;
3697
+ if (pid !== null && (reportedPid === null || reportedPid === pid)) {
3698
+ try {
3699
+ process.kill(pid, "SIGTERM");
3700
+ } catch {}
3701
+ return waitUntil(gone, 3e3);
3702
+ }
3703
+ return false;
3704
+ }
3705
+ /**
3706
+ * Look up this repo's registered server and settle its fate: a healthy same-build
3707
+ * server is returned for reuse; a server from another build is retired; a stale
3708
+ * row (dead port, or a foreign squatter) is cleared. Returns null once the
3709
+ * registry holds no reusable server. Throws when an other-build server would not
3710
+ * step aside — the row is NOT cleared then: two servers on one review each hold
3711
+ * their own copy and write the whole blob back, so the slower watcher erases the
3712
+ * reviewer's threads, and clearing the row would hand the claim to a newcomer.
3713
+ */
3714
+ async function settleExistingServer(db, repoPath, version, onStatus = () => {}) {
3715
+ const record = db.getServer(repoPath);
3716
+ if (!record) return null;
3717
+ const health = await fetchHealth(record.port);
3718
+ const verdict = assessRunningServer(health, repoPath, version);
3719
+ if (verdict === "reuse") return {
3720
+ port: record.port,
3721
+ pid: health?.pid ?? record.pid ?? null
3722
+ };
3723
+ if (verdict === "replace") {
3724
+ onStatus(`replacing the diffo server from another build (${health?.version ?? "pre-handshake"} → ${version})`);
3725
+ 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
+ }
3727
+ db.removeServer(repoPath, record.port);
3728
+ return null;
3729
+ }
3730
+ function defaultLogPath(repoPath) {
3731
+ const abs = resolve(repoPath);
3732
+ const hash = createHash("sha256").update(abs).digest("hex").slice(0, 8);
3733
+ return join(homedir(), ".diffo", "logs", `${basename(abs)}-${hash}.log`);
3734
+ }
3735
+ function rotateLog(logPath) {
3736
+ try {
3737
+ if (statSync(logPath).size < MAX_LOG_BYTES) return;
3738
+ renameSync(logPath, `${logPath}.1`);
3739
+ } catch {}
3740
+ }
3741
+ function logSize(logPath) {
3742
+ try {
3743
+ return statSync(logPath).size;
3744
+ } catch {
3745
+ return 0;
3746
+ }
3747
+ }
3748
+ /**
3749
+ * The daemon's own last word — only what THIS spawn appended (past `from`),
3750
+ * preferring its `diffo:` lines — so a failed handshake names the reason
3751
+ * instead of pointing at a file.
3752
+ */
3753
+ function daemonComplaint(logPath, from) {
3754
+ try {
3755
+ const lines = readFileSync(logPath).subarray(from).toString("utf-8").split("\n").map((line) => line.trim()).filter(Boolean);
3756
+ const line = lines.filter((l) => l.startsWith("diffo: ")).at(-1) ?? lines.at(-1);
3757
+ return line ? line.replace(/^diffo: /, "").slice(0, 300) : null;
3758
+ } catch {
3759
+ return null;
3760
+ }
3761
+ }
3762
+ function spawnDaemonProcess(opts) {
3763
+ mkdirSync(dirname(opts.logPath), { recursive: true });
3764
+ rotateLog(opts.logPath);
3765
+ const logFd = openSync(opts.logPath, "a");
3766
+ spawn(opts.execPath, serverSpawnArgs(opts.entry, opts.execArgv, opts.port, opts.base), {
3767
+ cwd: opts.repoPath,
3768
+ detached: true,
3769
+ stdio: [
3770
+ "ignore",
3771
+ logFd,
3772
+ logFd
3773
+ ],
3774
+ env: {
3775
+ ...opts.env ?? process.env,
3776
+ DIFFO_DAEMON: "1"
3777
+ }
3778
+ }).unref();
3779
+ }
3780
+ async function ensureServer(opts, deps = {}) {
3781
+ const status = opts.onStatus ?? (() => {});
3782
+ const db = new DiffoDb(opts.dbPath);
3783
+ try {
3784
+ const existing = await settleExistingServer(db, opts.repoPath, opts.version, status);
3785
+ if (existing) return existing.port;
3786
+ status("no diffo server for this repo — starting one");
3787
+ mkdirSync(dirname(opts.logPath), { recursive: true });
3788
+ rotateLog(opts.logPath);
3789
+ const logStart = logSize(opts.logPath);
3790
+ (deps.spawnDaemon ?? spawnDaemonProcess)(opts);
3791
+ let port = null;
3792
+ if (!await waitUntil(async () => {
3793
+ const row = db.getServer(opts.repoPath);
3794
+ if (!row) return false;
3795
+ if (assessRunningServer(await fetchHealth(row.port, 500), opts.repoPath, opts.version) !== "reuse") return false;
3796
+ port = row.port;
3797
+ return true;
3798
+ }, opts.spawnTimeoutMs ?? 8e3) || port === null) {
3799
+ const complaint = daemonComplaint(opts.logPath, logStart);
3800
+ throw new Error(complaint ? `the diffo server did not start: ${complaint} (log: ${opts.logPath})` : `the diffo server did not start — see ${opts.logPath}`);
3801
+ }
3802
+ return port;
3803
+ } finally {
3804
+ db.close();
3805
+ }
3806
+ }
3807
+ //#endregion
3808
+ //#region src/setup.ts
3809
+ /**
3810
+ * What a freshly registered client needs before diffo shows up in it. Skill
3811
+ * files are read per session, so those clients need no restart; Cursor scans
3812
+ * local plugins at startup and Reload Window is unreliable for it; VS Code
3813
+ * reads chat.pluginLocations on window load.
3814
+ */
3815
+ const postRegisterHint = {
3816
+ "claude-code": "available in new agent sessions",
3817
+ "agents-dir": "available in new agent sessions",
3818
+ cursor: "restart Cursor to load it",
3819
+ vscode: "reload the VS Code window to load it",
3820
+ copilot: "available in new copilot sessions"
3821
+ };
3822
+ function isDiffoSkill(content) {
3823
+ const frontmatter = /^---\n([\s\S]*?)\n---/.exec(content)?.[1] ?? "";
3824
+ return /^name:\s*diffo\s*$/m.test(frontmatter);
3825
+ }
3826
+ function pluginName(packageRoot) {
3827
+ try {
3828
+ const manifest = JSON.parse(readFileSync(join(packageRoot, "plugin.json"), "utf-8"));
3829
+ if (typeof manifest.name === "string" && manifest.name !== "") return manifest.name;
3830
+ } catch {}
3831
+ return "diffo";
3832
+ }
3833
+ const collapse = (target, home) => home && target.startsWith(home) ? `~${target.slice(home.length)}` : target;
3834
+ function packagedSkill(ctx) {
3835
+ return readFileSync(join(ctx.packageRoot, "skills", "diffo", "SKILL.md"), "utf-8");
3836
+ }
3837
+ function installSkillCopy(ctx, client, targetDir) {
3838
+ const home = ctx.homeDir;
3839
+ const content = packagedSkill(ctx);
3840
+ const target = join(targetDir, "SKILL.md");
3841
+ if (existsSync(target)) {
3842
+ const existing = readFileSync(target, "utf-8");
3843
+ if (existing === content) return {
3844
+ client,
3845
+ status: "current",
3846
+ detail: collapse(target, home)
3847
+ };
3848
+ if (!isDiffoSkill(existing)) return {
3849
+ client,
3850
+ status: "manual",
3851
+ detail: `a different skill lives at ${collapse(target, home)} — remove it and re-run \`diffo setup\``
3852
+ };
3853
+ writeFileSync(target, content);
3854
+ return {
3855
+ client,
3856
+ status: "registered",
3857
+ detail: collapse(target, home)
3858
+ };
3859
+ }
3860
+ mkdirSync(targetDir, { recursive: true });
3861
+ writeFileSync(target, content);
3862
+ return {
3863
+ client,
3864
+ status: "registered",
3865
+ detail: collapse(target, home)
3866
+ };
3867
+ }
3868
+ function registerSkillsDir(ctx) {
3869
+ const home = ctx.homeDir;
3870
+ const client = "claude-code";
3871
+ if (!existsSync(join(home, ".claude"))) return {
3872
+ client,
3873
+ status: "absent",
3874
+ detail: "no ~/.claude directory found"
3875
+ };
3876
+ return installSkillCopy(ctx, client, join(home, ".claude", "skills", "diffo"));
3877
+ }
3878
+ /**
3879
+ * The cross-tool skills directory of the Agent Skills spec: Codex reads
3880
+ * `~/.agents/skills` as its user-level location, and Gemini CLI, Amp, Goose,
3881
+ * OpenCode and others follow the same convention. One copy covers them all.
3882
+ * `~/.codex` counts as presence too — Codex reads the directory without
3883
+ * creating it.
3884
+ */
3885
+ function registerAgentsDir(ctx) {
3886
+ const home = ctx.homeDir;
3887
+ const client = "agents-dir";
3888
+ if (!existsSync(join(home, ".agents")) && !existsSync(join(home, ".codex"))) return {
3889
+ client,
3890
+ status: "absent",
3891
+ detail: "no ~/.agents (or ~/.codex) directory found"
3892
+ };
3893
+ return installSkillCopy(ctx, client, join(home, ".agents", "skills", "diffo"));
3894
+ }
3895
+ /** The dev skill drives a source checkout; a shipped CLI must never replace it. */
3896
+ const DEV_SKILL_MARKER = "This is a DEV build";
3897
+ /**
3898
+ * Freshen the file-copy skill installs. `npx -y @diffohq/diffo` resolves a current CLI
3899
+ * on every review, which makes the CLI the one component that is always fresh —
3900
+ * so it carries the installed skill forward, instead of every release waiting
3901
+ * on users to re-run `diffo setup`. Refresh only: it never installs anew, never
3902
+ * touches a skill that isn't ours, and never replaces a dev skill.
3903
+ */
3904
+ function refreshInstalledSkills(ctx) {
3905
+ if ((ctx.env ?? process.env).ENV === "development") return [];
3906
+ let content;
3907
+ try {
3908
+ content = packagedSkill(ctx);
3909
+ } catch {
3910
+ return [];
3911
+ }
3912
+ const refreshed = [];
3913
+ for (const target of [join(ctx.homeDir, ".claude", "skills", "diffo", "SKILL.md"), join(ctx.homeDir, ".agents", "skills", "diffo", "SKILL.md")]) try {
3914
+ const existing = readFileSync(target, "utf-8");
3915
+ if (existing === content) continue;
3916
+ if (!isDiffoSkill(existing)) continue;
3917
+ if (existing.includes(DEV_SKILL_MARKER)) continue;
3918
+ writeFileSync(target, content);
3919
+ refreshed.push(target);
3920
+ } catch {}
3921
+ return refreshed;
3922
+ }
3923
+ function registerCursor(ctx) {
3924
+ const home = ctx.homeDir;
3925
+ const platform = ctx.platform ?? process.platform;
3926
+ const client = "cursor";
3927
+ if (!existsSync(join(home, ".cursor"))) return {
3928
+ client,
3929
+ status: "absent",
3930
+ detail: "no ~/.cursor directory found"
3931
+ };
3932
+ const localPlugins = join(home, ".cursor", "plugins", "local");
3933
+ const target = join(localPlugins, pluginName(ctx.packageRoot));
3934
+ const link = (to, at) => symlinkSync(to, at, platform === "win32" ? "junction" : "dir");
3935
+ let existing = null;
3936
+ try {
3937
+ existing = lstatSync(target);
3938
+ } catch {}
3939
+ if (existing && !existing.isSymbolicLink()) return {
3940
+ client,
3941
+ status: "manual",
3942
+ detail: `${collapse(target, home)} exists and is not a symlink — remove it and re-run \`diffo setup\``
3943
+ };
3944
+ if (existing) {
3945
+ if (resolve(readlinkSync(target)) === ctx.packageRoot) return {
3946
+ client,
3947
+ status: "current",
3948
+ detail: collapse(target, home)
3949
+ };
3950
+ const replacement = `${target}.${process.pid}.tmp`;
3951
+ try {
3952
+ link(ctx.packageRoot, replacement);
3953
+ if (platform === "win32") rmSync(target, { force: true });
3954
+ renameSync(replacement, target);
3955
+ } catch (error) {
3956
+ rmSync(replacement, { force: true });
3957
+ return {
3958
+ client,
3959
+ status: "manual",
3960
+ detail: `cannot link ${collapse(target, home)} (${error.message}) — link it to ${ctx.packageRoot} manually`
3961
+ };
3962
+ }
3963
+ return {
3964
+ client,
3965
+ status: "registered",
3966
+ detail: collapse(target, home)
3967
+ };
3968
+ }
3969
+ mkdirSync(localPlugins, { recursive: true });
3970
+ try {
3971
+ link(ctx.packageRoot, target);
3972
+ } catch (error) {
3973
+ return {
3974
+ client,
3975
+ status: "manual",
3976
+ detail: `cannot link ${collapse(target, home)} (${error.message}) — link it to ${ctx.packageRoot} manually`
3977
+ };
3978
+ }
3979
+ return {
3980
+ client,
3981
+ status: "registered",
3982
+ detail: collapse(target, home)
3983
+ };
3984
+ }
3985
+ function vsCodeSettingsFile(env, homeDir, platform) {
3986
+ if (platform === "win32") return join(env.APPDATA || join(homeDir, "AppData", "Roaming"), "Code", "User", "settings.json");
3987
+ if (platform === "darwin") return join(homeDir, "Library", "Application Support", "Code", "User", "settings.json");
3988
+ return join(env.XDG_CONFIG_HOME || join(homeDir, ".config"), "Code", "User", "settings.json");
3989
+ }
3990
+ function updatePluginLocations(settings, packageRoot, name) {
3991
+ const updated = structuredClone(settings);
3992
+ const existing = updated["chat.pluginLocations"];
3993
+ const locations = existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
3994
+ let changed = false;
3995
+ for (const key of Object.keys(locations)) {
3996
+ if (key === packageRoot) continue;
3997
+ let stale = false;
3998
+ try {
3999
+ stale = JSON.parse(readFileSync(join(key, "plugin.json"), "utf-8")).name === name;
4000
+ } catch {
4001
+ stale = !existsSync(key) && basename(key) === name;
4002
+ }
4003
+ if (stale) {
4004
+ delete locations[key];
4005
+ changed = true;
4006
+ }
4007
+ }
4008
+ if (locations[packageRoot] !== true) {
4009
+ locations[packageRoot] = true;
4010
+ changed = true;
4011
+ }
4012
+ updated["chat.pluginLocations"] = locations;
4013
+ return [updated, changed];
4014
+ }
4015
+ function registerVsCode(ctx) {
4016
+ const home = ctx.homeDir;
4017
+ const client = "vscode";
4018
+ const settingsFile = vsCodeSettingsFile(ctx.env ?? process.env, home, ctx.platform ?? process.platform);
4019
+ const hasFile = existsSync(settingsFile);
4020
+ if (!hasFile && !existsSync(dirname(settingsFile))) return {
4021
+ client,
4022
+ status: "absent",
4023
+ detail: "no VS Code user configuration found"
4024
+ };
4025
+ let settings = {};
4026
+ if (hasFile) try {
4027
+ settings = JSON.parse(readFileSync(settingsFile, "utf-8"));
4028
+ } catch {
4029
+ return {
4030
+ client,
4031
+ status: "manual",
4032
+ detail: `add "chat.plugins.enabled": true and "chat.pluginLocations": {"${ctx.packageRoot}": true} to ${collapse(settingsFile, home)}`
4033
+ };
4034
+ }
4035
+ const [updated, locationsChanged] = updatePluginLocations(settings, ctx.packageRoot, pluginName(ctx.packageRoot));
4036
+ let changed = locationsChanged;
4037
+ if (!("chat.plugins.enabled" in updated)) {
4038
+ updated["chat.plugins.enabled"] = true;
4039
+ changed = true;
4040
+ }
4041
+ if (!changed) return {
4042
+ client,
4043
+ status: "current",
4044
+ detail: collapse(settingsFile, home)
4045
+ };
4046
+ try {
4047
+ writeFileSync(settingsFile, `${JSON.stringify(updated, null, 2)}\n`);
4048
+ } catch (error) {
4049
+ return {
4050
+ client,
4051
+ status: "failed",
4052
+ detail: error.message
4053
+ };
4054
+ }
4055
+ return {
4056
+ client,
4057
+ status: "registered",
4058
+ detail: collapse(settingsFile, home)
4059
+ };
4060
+ }
4061
+ function registerCopilot(ctx) {
4062
+ const client = "copilot";
4063
+ const run = ctx.run ?? ((command, args) => spawnSync(command, args, { encoding: "utf-8" }));
4064
+ const listed = run("copilot", [
4065
+ "plugins",
4066
+ "list",
4067
+ "--scope",
4068
+ "user",
4069
+ "--kind",
4070
+ "plugin",
4071
+ "--json"
4072
+ ]);
4073
+ if (listed.error) return {
4074
+ client,
4075
+ status: "absent",
4076
+ detail: "copilot CLI not found on PATH"
4077
+ };
4078
+ const name = pluginName(ctx.packageRoot);
4079
+ if (listed.status === 0) try {
4080
+ const parsed = JSON.parse(listed.stdout);
4081
+ if ((Array.isArray(parsed) ? parsed : Array.isArray(parsed?.plugins) ? parsed.plugins : []).find((r) => typeof r === "object" && r !== null && r.name === name && resolve(String(r.source ?? r.path ?? "")) === ctx.packageRoot)) return {
4082
+ client,
4083
+ status: "current",
4084
+ detail: collapse(ctx.packageRoot, ctx.homeDir)
4085
+ };
4086
+ } catch {}
4087
+ const installed = run("copilot", [
4088
+ "plugin",
4089
+ "install",
4090
+ ctx.packageRoot
4091
+ ]);
4092
+ if (installed.status !== 0) return {
4093
+ client,
4094
+ status: "failed",
4095
+ detail: String(installed.stderr || installed.stdout || `exit ${installed.status}`).trim().split("\n")[0] ?? "plugin install failed"
4096
+ };
4097
+ return {
4098
+ client,
4099
+ status: "registered",
4100
+ detail: "copilot plugin install"
4101
+ };
4102
+ }
4103
+ function runSetup(ctx) {
4104
+ return [
4105
+ registerSkillsDir(ctx),
4106
+ registerAgentsDir(ctx),
4107
+ registerCursor(ctx),
4108
+ registerVsCode(ctx),
4109
+ registerCopilot(ctx)
4110
+ ];
4111
+ }
4112
+ //#endregion
4113
+ //#region src/cli.ts
4114
+ const command = parseCliArgs(process.argv.slice(2));
4115
+ function fail(message) {
4116
+ console.error(`diffo: ${message}`);
4117
+ process.exit(1);
4118
+ }
4119
+ function probePort(port) {
4120
+ return new Promise((resolvePort, reject) => {
4121
+ const probe = createServer();
4122
+ probe.unref();
4123
+ probe.once("error", reject);
4124
+ probe.listen({
4125
+ port,
4126
+ host: "127.0.0.1"
4127
+ }, () => {
4128
+ const address = probe.address();
4129
+ const bound = typeof address === "object" && address !== null ? address.port : port;
4130
+ probe.close(() => resolvePort(bound));
4131
+ });
4132
+ });
4133
+ }
4134
+ async function preferredPort() {
4135
+ const db = new DiffoDb();
4136
+ let remembered;
4137
+ try {
4138
+ remembered = db.getPreferredPort(repoPath);
4139
+ } finally {
4140
+ db.close();
4141
+ }
4142
+ if (remembered === null) return probePort(0);
4143
+ try {
4144
+ return await probePort(remembered);
4145
+ } catch {
4146
+ return probePort(0);
4147
+ }
4148
+ }
4149
+ function specLabel(spec) {
4150
+ return spec.kind === "working-tree" ? "working tree vs HEAD" : `vs ${spec.base}`;
4151
+ }
4152
+ /** The platform's URL opener. `start` is a `cmd` builtin, not an executable. */
4153
+ function opener(url) {
4154
+ if (process.platform === "darwin") return ["open", [url]];
4155
+ if (process.platform === "win32") return ["cmd", [
4156
+ "/c",
4157
+ "start",
4158
+ "",
4159
+ url
4160
+ ]];
4161
+ return ["xdg-open", [url]];
4162
+ }
4163
+ function openBrowser(url) {
4164
+ const [command, args] = opener(url);
4165
+ try {
4166
+ const child = spawn(command, args, {
4167
+ stdio: "ignore",
4168
+ detached: true
4169
+ });
4170
+ child.once("error", () => {});
4171
+ child.unref();
4172
+ } catch {}
4173
+ }
4174
+ if (command.kind === "error") {
4175
+ console.error(`diffo: ${command.message}`);
4176
+ console.error(`try: diffo --help`);
4177
+ process.exit(1);
4178
+ }
4179
+ if (command.kind === "help") {
4180
+ console.log(helpFor(command.topic));
4181
+ process.exit(0);
4182
+ }
4183
+ if (command.kind === "version") {
4184
+ console.log(VERSION);
4185
+ process.exit(0);
4186
+ }
4187
+ if (command.kind === "setup") {
4188
+ const outcomes = runSetup({
4189
+ packageRoot: resolve(dirname(fileURLToPath(import.meta.url)), ".."),
4190
+ homeDir: homedir()
4191
+ });
4192
+ const width = Math.max(...outcomes.map((o) => o.client.length));
4193
+ for (const outcome of outcomes) {
4194
+ const hint = outcome.status === "registered" ? postRegisterHint[outcome.client] : void 0;
4195
+ console.log(`${outcome.client.padEnd(width)} ${outcome.status.padEnd(10)} ${outcome.detail}${hint ? ` — ${hint}` : ""}`);
4196
+ }
4197
+ if (outcomes.some((o) => o.status === "manual")) console.log("rows marked manual need the one step described; re-run `diffo setup` after");
4198
+ if (outcomes.every((o) => o.status === "absent")) console.log("\nno supported client found — install one, then re-run `diffo setup`");
4199
+ process.exit(outcomes.some((o) => o.status === "failed") ? 1 : 0);
4200
+ }
4201
+ const root = findRepoRoot(process.cwd());
4202
+ if (!root) fail("not inside a git repository");
4203
+ const repoPath = resolve(root);
4204
+ async function discoverServer() {
4205
+ const db = new DiffoDb();
4206
+ try {
4207
+ return await settleExistingServer(db, repoPath, VERSION, (line) => process.stderr.write(`diffo: ${line}\n`));
4208
+ } catch (err) {
4209
+ fail(err.message);
4210
+ } finally {
4211
+ db.close();
4212
+ }
4213
+ }
4214
+ function sessionHeaders() {
4215
+ const pid = detectSessionPid();
4216
+ return {
4217
+ "x-diffo-agent": "cli",
4218
+ ...pid === null ? {} : { "x-diffo-session-pid": String(pid) }
4219
+ };
4220
+ }
4221
+ async function requireServer(port, base) {
4222
+ try {
4223
+ return await ensureServer({
4224
+ repoPath,
4225
+ version: VERSION,
4226
+ entry: resolve(process.argv[1] ?? fileURLToPath(import.meta.url)),
4227
+ execPath: process.execPath,
4228
+ execArgv: process.execArgv,
4229
+ port,
4230
+ base,
4231
+ logPath: process.env.DIFFO_SERVER_LOG || defaultLogPath(repoPath),
4232
+ onStatus: (line) => process.stderr.write(`diffo: ${line}\n`)
4233
+ });
4234
+ } catch (err) {
4235
+ fail(err.message);
4236
+ }
4237
+ }
4238
+ /**
4239
+ * An empty working-tree review usually means the work was committed, not that
4240
+ * there is no work: point at the branch's commits when there are any. The one
4241
+ * moment the flag is needed is the one moment the default mode shows nothing.
4242
+ */
4243
+ function printBaseHint(spec, files) {
4244
+ if (spec.kind !== "working-tree" || files !== 0) return;
4245
+ const hint = suggestedBase(repoPath);
4246
+ if (!hint) return;
4247
+ console.log(`this branch has ${hint.commits} ${hint.commits === 1 ? "commit" : "commits"} since ${hint.base} — review them with \`diffo --base ${hint.base}\``);
4248
+ }
4249
+ async function fetchChangesetInfo(port) {
4250
+ try {
4251
+ return await (await fetch(apiUrl(port, "/api/changeset"), { signal: AbortSignal.timeout(2e3) })).json();
4252
+ } catch {
4253
+ return null;
4254
+ }
4255
+ }
4256
+ async function fetchReviewState(port) {
4257
+ try {
4258
+ const res = await fetch(apiUrl(port, "/api/review"), { signal: AbortSignal.timeout(2e3) });
4259
+ if (!res.ok) return null;
4260
+ return await res.json();
4261
+ } catch {
4262
+ return null;
4263
+ }
4264
+ }
4265
+ /**
4266
+ * The guide: an agent comment on the whole changeset. One per review by
4267
+ * convention, so the newest wins if an agent ever posted two. Resolved ones
4268
+ * still count — the reviewer resolving a guide means they are done with it, and
4269
+ * re-asking for one would only get the review a second guide.
4270
+ *
4271
+ * A landed review's guide doesn't count at all: it describes a changeset that
4272
+ * was committed away, so the next changeset deserves a fresh one — inheriting
4273
+ * it would orient the reviewer to work that is already behind them.
4274
+ */
4275
+ function findGuideThread(review) {
4276
+ if (review.landed) return void 0;
4277
+ return review.threads.filter((t) => t.anchor.kind === "changeset" && t.messages[0]?.author === "agent").at(-1);
4278
+ }
4279
+ /** Two lines of repo + changeset context. A courtesy: any failure prints nothing. */
4280
+ async function printChangesetSummary(port) {
4281
+ const info = await fetchChangesetInfo(port);
4282
+ if (!info) return;
4283
+ const { spec, stats, repo } = info;
4284
+ console.log(`diffo · ${repo.name}${repo.branch ? ` (${repo.branch})` : ""}`);
4285
+ console.log(stats.files === 0 ? `watching ${specLabel(spec)} · working tree is clean — nothing to review yet` : `watching ${specLabel(spec)} · ${stats.files} ${stats.files === 1 ? "file" : "files"} · +${stats.additions} −${stats.deletions}`);
4286
+ printBaseHint(spec, stats.files);
4287
+ }
4288
+ /**
4289
+ * An explicit `--base` against a server watching something else deserves the
4290
+ * truth: the server keeps its spec (switching would move the diff under the
4291
+ * reviewer), so say what it IS watching and how to switch.
4292
+ */
4293
+ async function warnSpecMismatch(port, asked) {
4294
+ if (asked.kind !== "branch") return;
4295
+ const info = await fetchChangesetInfo(port);
4296
+ if (!info) return;
4297
+ const watching = info.spec;
4298
+ if (watching.kind === "branch" && watching.base === asked.base) return;
4299
+ process.stderr.write(`diffo: this server is watching ${specLabel(watching)}, not vs ${asked.base} — run \`diffo stop\`, then re-run with --base to switch\n`);
4300
+ }
4301
+ function readStdin() {
4302
+ return new Promise((resolvePromise, reject) => {
4303
+ let data = "";
4304
+ process.stdin.setEncoding("utf-8");
4305
+ process.stdin.on("data", (chunk) => data += chunk);
4306
+ process.stdin.on("end", () => resolvePromise(data));
4307
+ process.stdin.on("error", reject);
4308
+ });
4309
+ }
4310
+ /**
4311
+ * Every verb except `poll` — a short request/response against a loopback server,
4312
+ * so anything past ten seconds is a wedge, not slowness. Without the deadline a
4313
+ * server that goes quiet held the verb for undici's 300s default, long past the
4314
+ * point an agent harness gives up, so a `reply` could vanish with no error.
4315
+ */
4316
+ const POST_TIMEOUT_MS = 1e4;
4317
+ async function postJson(port, path, body) {
4318
+ let res;
4319
+ try {
4320
+ res = await fetch(apiUrl(port, path), {
4321
+ method: "POST",
4322
+ headers: {
4323
+ "Content-Type": "application/json",
4324
+ ...sessionHeaders()
4325
+ },
4326
+ body: JSON.stringify(body),
4327
+ signal: AbortSignal.timeout(POST_TIMEOUT_MS)
4328
+ });
4329
+ } catch {
4330
+ fail("lost connection to the diffo server — re-run the command");
4331
+ }
4332
+ return {
4333
+ status: res.status,
4334
+ body: await res.json().catch(() => null)
4335
+ };
4336
+ }
4337
+ if (command.kind === "status") {
4338
+ const db = new DiffoDb();
4339
+ let record;
4340
+ try {
4341
+ record = db.getServer(repoPath);
4342
+ } finally {
4343
+ db.close();
4344
+ }
4345
+ const health = record ? await fetchHealth(record.port) : null;
4346
+ const verdict = record ? assessRunningServer(health, repoPath, VERSION) : "foreign";
4347
+ if (!record || verdict === "foreign") {
4348
+ if (command.json) console.log(JSON.stringify({ running: false }));
4349
+ else console.log(record ? "no diffo server is watching this repo (its last registration is stale)" : "no diffo server is watching this repo");
4350
+ process.exit(1);
4351
+ }
4352
+ const pid = health?.pid ?? record.pid;
4353
+ const url = reviewUrl(record.port);
4354
+ if (command.json) {
4355
+ console.log(JSON.stringify({
4356
+ running: true,
4357
+ port: record.port,
4358
+ pid: pid || null,
4359
+ version: health?.version ?? null,
4360
+ url
4361
+ }));
4362
+ process.exit(0);
4363
+ }
4364
+ 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)` : ""));
4366
+ console.log(`→ ${url}`);
4367
+ process.exit(0);
4368
+ }
4369
+ if (command.kind === "stop") {
4370
+ const db = new DiffoDb();
4371
+ try {
4372
+ const record = db.getServer(repoPath);
4373
+ if (!record) {
4374
+ console.log("no diffo server is watching this repo — nothing to stop");
4375
+ process.exit(0);
4376
+ }
4377
+ const health = await fetchHealth(record.port);
4378
+ if (assessRunningServer(health, repoPath, VERSION) === "foreign") {
4379
+ db.removeServer(repoPath, record.port);
4380
+ console.log("no diffo server is watching this repo — cleared a stale registration");
4381
+ process.exit(0);
4382
+ }
4383
+ if (!await retireServer(record.port, record.pid ?? null, health?.pid ?? null)) fail(`the server on port ${record.port}` + (record.pid ? ` (pid ${record.pid})` : "") + ` would not stop — kill it yourself, then re-run \`diffo stop\``);
4384
+ db.removeServer(repoPath, record.port);
4385
+ console.log(`stopped the diffo server on port ${record.port}`);
4386
+ } finally {
4387
+ db.close();
4388
+ }
4389
+ process.exit(0);
4390
+ }
4391
+ if (command.kind === "poll") {
4392
+ 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");
4394
+ try {
4395
+ const res = await fetch(apiUrl(port, "/api/agent/poll"), { headers: sessionHeaders() });
4396
+ if (!res.ok) fail(`poll failed (${res.status})`);
4397
+ const tookOverFrom = res.headers.get("x-diffo-took-over-from");
4398
+ if (tookOverFrom) {
4399
+ process.stderr.write(`diffo: another agent session (pid ${tookOverFrom}) was connected to this review —\nthis poll has taken it over, and that session has been told. Mention this to the\nuser: their feedback now comes here.\n`);
4400
+ const review = await fetchReviewState(port);
4401
+ const guide = review ? findGuideThread(review) : void 0;
4402
+ if (guide) process.stderr.write(guideInherit(guide.id));
4403
+ }
4404
+ console.log((await res.text()).trim());
4405
+ } catch {
4406
+ fail("lost connection to the diffo server — re-run `poll` to keep listening");
4407
+ }
4408
+ process.exit(0);
4409
+ }
4410
+ if (command.kind === "reply") {
4411
+ if (command.message === null && process.stdin.isTTY) fail("reply needs a message — pass --message \"<text>\" or pipe it on stdin");
4412
+ const message = (command.message ?? await readStdin()).trim();
4413
+ if (!message) fail("reply needs a message — pass --message \"<text>\" or pipe it on stdin");
4414
+ const { status, body } = await postJson(await requireServer(), `/api/review/threads/${encodeURIComponent(command.threadId)}/messages`, {
4415
+ author: "agent",
4416
+ text: message
4417
+ });
4418
+ if (status === 404) fail(`no thread with id '${command.threadId}'`);
4419
+ if (status !== 200) fail(`reply failed (${status})`);
4420
+ const thread = body.thread;
4421
+ console.log(JSON.stringify({
4422
+ ok: true,
4423
+ threadId: thread.id,
4424
+ state: thread.state,
4425
+ next_step: ACK_NEXT_STEP.reply
4426
+ }));
4427
+ process.exit(0);
4428
+ }
4429
+ if (command.kind === "comment") {
4430
+ if (command.message === null && process.stdin.isTTY) fail("comment needs a message — pass --message \"<text>\" or pipe it on stdin");
4431
+ const message = (command.message ?? await readStdin()).trim();
4432
+ if (!message) fail("comment needs a message — pass --message \"<text>\" or pipe it on stdin");
4433
+ const { status, body } = await postJson(await requireServer(), "/api/review/threads", {
4434
+ author: "agent",
4435
+ file: command.file,
4436
+ line: command.line,
4437
+ text: message
4438
+ });
4439
+ if (status !== 200) fail(`comment failed (${status})`);
4440
+ console.log(JSON.stringify({
4441
+ ok: true,
4442
+ threadId: body.id,
4443
+ next_step: ACK_NEXT_STEP.comment
4444
+ }));
4445
+ process.exit(0);
4446
+ }
4447
+ if (command.kind === "end") {
4448
+ const { status, body } = await postJson(await requireServer(), "/api/agent/end", {});
4449
+ if (status !== 200) fail(`end failed (${status})`);
4450
+ const result = body;
4451
+ console.log(JSON.stringify(result?.ok === false ? {
4452
+ ...result,
4453
+ next_step: "nothing to do — you were not the attached agent"
4454
+ } : {
4455
+ ok: true,
4456
+ next_step: ACK_NEXT_STEP.end
4457
+ }));
4458
+ process.exit(0);
4459
+ }
4460
+ if (!process.env.DIFFO_DAEMON) {
4461
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
4462
+ for (const target of refreshInstalledSkills({
4463
+ packageRoot,
4464
+ homeDir: homedir()
4465
+ })) process.stderr.write(`diffo: refreshed the installed skill (${target})\n`);
4466
+ }
4467
+ /** Agents read piped stdout; a human's terminal never shows these lines. */
4468
+ async function printAgentNextStep(port) {
4469
+ if (process.stdout.isTTY) return;
4470
+ const review = await fetchReviewState(port);
4471
+ const nudge = review ? guideNudge(findGuideThread(review) !== void 0) : null;
4472
+ if (nudge) console.log(`first: ${nudge}`);
4473
+ console.log("next: run `diffo poll` to receive the reviewer's feedback (`diffo help agent` prints the whole loop)");
4474
+ }
4475
+ const existing = await discoverServer();
4476
+ const takeOverPort = existing !== null && command.foreground && command.port !== void 0 && command.port !== existing.port ? command.port : null;
4477
+ if (existing !== null && takeOverPort === null) {
4478
+ const url = reviewUrl(existing.port);
4479
+ console.log(`diffo is already watching this repo`);
4480
+ await warnSpecMismatch(existing.port, command.spec);
4481
+ console.log(`→ ${url}`);
4482
+ await printAgentNextStep(existing.port);
4483
+ process.exit(0);
4484
+ }
4485
+ if (existing !== null && takeOverPort !== null) {
4486
+ try {
4487
+ await probePort(takeOverPort);
4488
+ } catch (err) {
4489
+ const code = err.code;
4490
+ fail(code === "EADDRINUSE" ? `port ${takeOverPort} is already in use, so there is nothing to take over to — the server on port ${existing.port} is untouched and still watching this repo` : `cannot listen on port ${takeOverPort} (${code ?? err.message})`);
4491
+ }
4492
+ console.error(`diffo: taking this repo over from the server on port ${existing.port}`);
4493
+ if (!await retireServer(existing.port, existing.pid, existing.pid)) fail(`the server on port ${existing.port} would not step aside — stop it, then re-run`);
4494
+ const db = new DiffoDb();
4495
+ db.removeServer(repoPath, existing.port);
4496
+ db.close();
4497
+ }
4498
+ if (!command.foreground) {
4499
+ const daemonPort = await requireServer(command.port, command.spec.kind === "branch" ? command.spec.base : void 0);
4500
+ const url = reviewUrl(daemonPort);
4501
+ await printChangesetSummary(daemonPort);
4502
+ console.log(`→ ${url}`);
4503
+ await printAgentNextStep(daemonPort);
4504
+ if (command.open) openBrowser(url);
4505
+ process.exit(0);
4506
+ }
4507
+ let port;
4508
+ try {
4509
+ port = command.port === void 0 ? await preferredPort() : await probePort(command.port);
4510
+ } catch (err) {
4511
+ const code = err.code;
4512
+ if (command.port === void 0) fail(`no free port available (${code ?? err.message})`);
4513
+ fail(code === "EADDRINUSE" ? `port ${command.port} is already in use — omit --port to take any free one` : `cannot listen on port ${command.port} (${code ?? err.message})`);
4514
+ }
4515
+ const here = dirname(fileURLToPath(import.meta.url));
4516
+ const clientDir = [resolve(here, "client"), resolve(here, "../dist/client")].find(existsSync) ?? resolve(here, "client");
4517
+ let started;
4518
+ try {
4519
+ started = startServer({
4520
+ port,
4521
+ clientDir,
4522
+ root,
4523
+ spec: command.spec
4524
+ });
4525
+ } catch (err) {
4526
+ if (err instanceof MissingBaseError) {
4527
+ console.error(`diffo: base branch '${err.base}' doesn't exist in this repo`);
4528
+ process.exit(1);
4529
+ }
4530
+ if (err instanceof RepoAlreadyServedError) {
4531
+ console.log(`diffo is already watching this repo`);
4532
+ console.log(`→ ${reviewUrl(err.holder.port)}`);
4533
+ process.exit(0);
4534
+ }
4535
+ throw err;
4536
+ }
4537
+ const { stats, repo } = started.store.get();
4538
+ const url = reviewUrl(port);
4539
+ const label = specLabel(command.spec);
4540
+ console.log(`diffo · ${repo.name}${repo.branch ? ` (${repo.branch})` : ""}`);
4541
+ if (stats.files === 0) console.log(`watching ${label} · working tree is clean — nothing to review yet`);
4542
+ else console.log(`watching ${label} · ${stats.files} ${stats.files === 1 ? "file" : "files"} · +${stats.additions} −${stats.deletions}`);
4543
+ printBaseHint(command.spec, stats.files);
4544
+ console.log(`→ ${url}`);
4545
+ if (command.open) openBrowser(url);
4546
+ process.on("SIGINT", () => {
4547
+ process.exit(0);
4548
+ });
4549
+ process.on("SIGTERM", () => {
4550
+ process.exit(0);
4551
+ });
4552
+ //#endregion
4553
+ export {};