@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.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +300 -0
- package/dist/cli.mjs +4553 -0
- package/dist/client/assets/abnfDiagram-VCTEODGH--nG4YFZf.js +1 -0
- package/dist/client/assets/arc-DUDGa5yC.js +1 -0
- package/dist/client/assets/architecture-7GRP2DOG-BS3omA-i.js +1 -0
- package/dist/client/assets/architectureDiagram-5GKGNRK7-DvSKG4lO.js +36 -0
- package/dist/client/assets/array-BifhSqXX.js +1 -0
- package/dist/client/assets/blockDiagram-NRAW4CY4-t9YA0Zds.js +129 -0
- package/dist/client/assets/c-DWGmR78Y.js +1 -0
- package/dist/client/assets/c-SSSlmE39.js +1 -0
- package/dist/client/assets/c4Diagram-UCG6FXSJ-D0NJEiQf.js +38 -0
- package/dist/client/assets/channel-C2N97jf-.js +1 -0
- package/dist/client/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/client/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/client/assets/chunk-75Z2AOVW-Bai5ank7.js +2 -0
- package/dist/client/assets/chunk-DU6HZSFF-D2kJr6Sd.js +125 -0
- package/dist/client/assets/chunk-F27PBJKO-Bfqt90G-.js +1 -0
- package/dist/client/assets/chunk-FOHPRMQF-DHwB1DNv.js +161 -0
- package/dist/client/assets/chunk-G27WJ6UU-BaH77Llj.js +231 -0
- package/dist/client/assets/chunk-GMAD6QVW-DEk8GHWm.js +72 -0
- package/dist/client/assets/chunk-JQ64N6SF-DSC3eBmu.js +1 -0
- package/dist/client/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/client/assets/chunk-LCL6LL3I-CbXTQnS7.js +206 -0
- package/dist/client/assets/chunk-OBVCFTLP-DTR5L5DZ.js +1 -0
- package/dist/client/assets/chunk-P2QGCYS3-B4CbyKz8.js +1 -0
- package/dist/client/assets/chunk-POPQ4Y6H-bUwnKEA6.js +1 -0
- package/dist/client/assets/chunk-PWAF6VOD-zdMmz14g.js +1 -0
- package/dist/client/assets/chunk-R7TYR2AO-Bij6COIm.js +10 -0
- package/dist/client/assets/chunk-RHFEMEQ7-B2_qXJHf.js +168 -0
- package/dist/client/assets/chunk-SVP7TREG-2A6DuC2c.js +88 -0
- package/dist/client/assets/chunk-TEH6E4GO-ByR-u0fj.js +62 -0
- package/dist/client/assets/chunk-XXDRQBXY-D_O-C3As.js +1 -0
- package/dist/client/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/client/assets/classDiagram-DTDB5LWJ-DkiwiYZN.js +1 -0
- package/dist/client/assets/classDiagram-v2-JRS7N3AN-DkiwiYZN.js +1 -0
- package/dist/client/assets/core-Bnn4HFdG.js +12 -0
- package/dist/client/assets/cose-bilkent-JH36ORCC-Z148C14w.js +1 -0
- package/dist/client/assets/cpp-CJIxL8VH.js +1 -0
- package/dist/client/assets/cpp-Cts1PyIS.js +1 -0
- package/dist/client/assets/csharp-oqKa8noW.js +1 -0
- package/dist/client/assets/css-CdtkT07m.js +1 -0
- package/dist/client/assets/css-LbU1hoFO.js +1 -0
- package/dist/client/assets/cynefin-OW5HDTMX-_nvdHJ_6.js +1 -0
- package/dist/client/assets/cynefinDiagram-5FMLGOSQ-DgjK3dBx.js +62 -0
- package/dist/client/assets/cytoscape.esm-C2x7inlt.js +321 -0
- package/dist/client/assets/dagre-3AP2YEHR-ZpUDL3As.js +4 -0
- package/dist/client/assets/dagre-PrKaheQc.js +1 -0
- package/dist/client/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/client/assets/diagram-S7CK7UJ4-CkZIS9iX.js +30 -0
- package/dist/client/assets/diagram-UQ7AKVKN-CQ18pzfD.js +41 -0
- package/dist/client/assets/diagram-VSXAHHWV-BHksZhNT.js +3 -0
- package/dist/client/assets/diagram-VX7I27RA-JaMo95UB.js +24 -0
- package/dist/client/assets/diagram-Z3DM3KII-BtbUi7Zp.js +24 -0
- package/dist/client/assets/dist-frF0Fo17.js +1 -0
- package/dist/client/assets/docker-IyjqRm3v.js +1 -0
- package/dist/client/assets/ebnfDiagram-PWID7BFC-CrkTnETC.js +1 -0
- package/dist/client/assets/engine-oniguruma-BDMkTjCm.js +1 -0
- package/dist/client/assets/erDiagram-SSCWMZ5O-BU_DeMcX.js +99 -0
- package/dist/client/assets/eventmodeling-NTZA5JFV-C6iBQfJc.js +1 -0
- package/dist/client/assets/flowDiagram-A5DVABFB-Dq3MklmQ.js +1 -0
- package/dist/client/assets/ganttDiagram-EL5Y4UJY-DCoVu2Ob.js +292 -0
- package/dist/client/assets/gitGraph-4MIJSDKK-D_vOLjJd.js +1 -0
- package/dist/client/assets/gitGraphDiagram-WWUBYQGX-CbJeUVdZ.js +106 -0
- package/dist/client/assets/github-dark-C-LZuMrd.js +1 -0
- package/dist/client/assets/github-light-EUqPIrTm.js +1 -0
- package/dist/client/assets/go-rLFTqkRN.js +1 -0
- package/dist/client/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/client/assets/html-BcACZWSP.js +1 -0
- package/dist/client/assets/html-DDNsd9lV.js +1 -0
- package/dist/client/assets/index-DUY5AD4r.js +90 -0
- package/dist/client/assets/index-pWZ6lr0e.css +1 -0
- package/dist/client/assets/info-A6RAGUB7-CRcr_2Kl.js +1 -0
- package/dist/client/assets/infoDiagram-RXCK75RN-B4UffpYj.js +2 -0
- package/dist/client/assets/init-C-OQMol4.js +1 -0
- package/dist/client/assets/ishikawaDiagram-5VMMS53U-DO6STczE.js +70 -0
- package/dist/client/assets/java-BtIQNnN0.js +1 -0
- package/dist/client/assets/java-DxlWrXwe.js +1 -0
- package/dist/client/assets/javascript-BzbQS41l.js +1 -0
- package/dist/client/assets/javascript-Yelw-5ZN.js +1 -0
- package/dist/client/assets/journeyDiagram-EYS64GPL-CJqu4rjo.js +139 -0
- package/dist/client/assets/json-B88Oqtu5.js +1 -0
- package/dist/client/assets/json-CaljABEy.js +1 -0
- package/dist/client/assets/jsx-D3KKoC5L.js +1 -0
- package/dist/client/assets/jsx-hEbJ82HF.js +1 -0
- package/dist/client/assets/kanban-definition-3QL26DDD-ugjVwpzB.js +89 -0
- package/dist/client/assets/katex-CXMH3UgJ.js +257 -0
- package/dist/client/assets/kotlin-DhhofPvG.js +1 -0
- package/dist/client/assets/line-C42aEa5T.js +1 -0
- package/dist/client/assets/linear-CAfNWogY.js +1 -0
- package/dist/client/assets/markdown-BYOwaDjH.js +1 -0
- package/dist/client/assets/mermaid-parser.core-DIpynd7a.js +7 -0
- package/dist/client/assets/mermaid.core-b7ipG6MC.js +44 -0
- package/dist/client/assets/mindmap-definition-FBJOCRG2-Btow11vP.js +96 -0
- package/dist/client/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/client/assets/packet-AYTQ26CC-DmMiSJ_n.js +1 -0
- package/dist/client/assets/path-fybaL0A-.js +1 -0
- package/dist/client/assets/pegDiagram-XKGWAZYB-ZE6QXs-N.js +1 -0
- package/dist/client/assets/php-CQT4wx5i.js +1 -0
- package/dist/client/assets/pie-WAS4IAKB-CWO1GXQk.js +1 -0
- package/dist/client/assets/pieDiagram-E7YTZNPT-CkYClGbv.js +39 -0
- package/dist/client/assets/python-gzcpVVnB.js +1 -0
- package/dist/client/assets/quadrantDiagram-AXDQQJYC-CLah-4Vu.js +7 -0
- package/dist/client/assets/radar-RG4KPBEZ-VI14cwZd.js +1 -0
- package/dist/client/assets/railroad-74A4TZTK-s1iFKSLC.js +1 -0
- package/dist/client/assets/railroad-abnf-HS5TGJTU-DCYBjW-8.js +1 -0
- package/dist/client/assets/railroad-ebnf-LZEXJU2U-Cpg28CUR.js +1 -0
- package/dist/client/assets/railroad-peg-WCYAUIDC-D3OHUWgZ.js +1 -0
- package/dist/client/assets/railroadDiagram-O6MQD6OU-DSEEuZLE.js +1 -0
- package/dist/client/assets/requirementDiagram-EFPCY7ZU-DKPeLCXy.js +84 -0
- package/dist/client/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/client/assets/ruby-D_UtGFHI.js +1 -0
- package/dist/client/assets/rust-Cfkwpbl8.js +1 -0
- package/dist/client/assets/sankeyDiagram-P5KCCOFB-CoRGTY0Z.js +40 -0
- package/dist/client/assets/scss-DHtrkMkT.js +1 -0
- package/dist/client/assets/sequenceDiagram-WJ2MYXX4-vOT2DQAD.js +162 -0
- package/dist/client/assets/shellscript-BiGZg9Om.js +1 -0
- package/dist/client/assets/shellscript-CqHdAlCp.js +1 -0
- package/dist/client/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
- package/dist/client/assets/sql-0Qcw9ht3.js +1 -0
- package/dist/client/assets/sql-CP0rg-5b.js +1 -0
- package/dist/client/assets/src-COpTHlse.js +1 -0
- package/dist/client/assets/stateDiagram-HBIQ2CUA-B1Q5gx1k.js +1 -0
- package/dist/client/assets/stateDiagram-v2-4QOOHH4V-Bq1OmWqW.js +1 -0
- package/dist/client/assets/svelte-BUVJxsYv.js +1 -0
- package/dist/client/assets/swift-CyEgAFGc.js +1 -0
- package/dist/client/assets/swimlanes-XN3QIQJK-BHsSTPP_.js +1 -0
- package/dist/client/assets/swimlanesDiagram-VK2B7HYN-DACxnx54.js +8 -0
- package/dist/client/assets/timeline-definition-24CTP7MA-gMAK8Huz.js +120 -0
- package/dist/client/assets/toml-CcmNWLt0.js +1 -0
- package/dist/client/assets/treeView-Q6P3EWNA-D_ac7x_f.js +1 -0
- package/dist/client/assets/treemap-WGGIJYW6-CmKDF5Bk.js +1 -0
- package/dist/client/assets/tsx-5f3ulCja.js +1 -0
- package/dist/client/assets/tsx-BOvsFOI6.js +1 -0
- package/dist/client/assets/typescript-Utq2Cl8c.js +1 -0
- package/dist/client/assets/typescript-j_1H8WHN.js +1 -0
- package/dist/client/assets/vennDiagram-4TSXK5OY-BwoJOmDU.js +34 -0
- package/dist/client/assets/vue-CSBlZ1UP.js +1 -0
- package/dist/client/assets/wardley-WFR3VGLG-DBwnOUBJ.js +1 -0
- package/dist/client/assets/wardleyDiagram-VM6X3IG4-DSpTGNPm.js +78 -0
- package/dist/client/assets/wasm-BnjxR4X6.js +1 -0
- package/dist/client/assets/xml-CoDIzWF0.js +1 -0
- package/dist/client/assets/xml-WY0zc4PG.js +1 -0
- package/dist/client/assets/xychartDiagram-S5SC5T6Z-I-7pacDu.js +7 -0
- package/dist/client/assets/yaml-CxbubiML.js +1 -0
- package/dist/client/assets/yaml-DijMHRDA.js +1 -0
- package/dist/client/favicon.svg +27 -0
- package/dist/client/index.html +16 -0
- package/package.json +81 -0
- package/plugin.json +14 -0
- 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 {};
|