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