@shiyuphan/follow-me 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +119 -69
  2. package/dist/src/cli.js +1069 -140
  3. package/dist/src/comments.js +361 -0
  4. package/dist/src/git.js +32 -3
  5. package/dist/src/host.js +106 -9
  6. package/dist/src/install.js +22 -1
  7. package/dist/src/naive.js +26 -0
  8. package/dist/src/plan.js +282 -26
  9. package/dist/src/prompt.js +711 -98
  10. package/dist/src/server.js +573 -140
  11. package/dist/src/session.js +106 -104
  12. package/dist/src/syntax.js +199 -0
  13. package/dist/src/types.js +3 -1
  14. package/dist/ui/dist/assets/abnfDiagram-WEYOIA3C-DcmzRkVZ.js +1 -0
  15. package/dist/ui/dist/assets/arc-T5QB0T1C.js +1 -0
  16. package/dist/ui/dist/assets/architecture-WOLXFQ4H-DUIkQTeE.js +1 -0
  17. package/dist/ui/dist/assets/architectureDiagram-ZHVYKT53-BUYi4-bA.js +36 -0
  18. package/dist/ui/dist/assets/array-BifhSqXX.js +1 -0
  19. package/dist/ui/dist/assets/blockDiagram-RJAE7QA3-dyJxtT9e.js +140 -0
  20. package/dist/ui/dist/assets/c4Diagram-XDPPPE54-CrAgiaZR.js +38 -0
  21. package/dist/ui/dist/assets/channel-wW7ebm0i.js +1 -0
  22. package/dist/ui/dist/assets/chunk-2E7WHVMD-C6fAf4Vj.js +88 -0
  23. package/dist/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  24. package/dist/ui/dist/assets/chunk-4EMNKR2T-BLZjwZ4Q.js +1 -0
  25. package/dist/ui/dist/assets/chunk-55XCU76K-DGLK2Q-L.js +1 -0
  26. package/dist/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  27. package/dist/ui/dist/assets/chunk-7S6XSTFO-BablszTX.js +2 -0
  28. package/dist/ui/dist/assets/chunk-AQDTSKZB-Cuk3B_vb.js +72 -0
  29. package/dist/ui/dist/assets/chunk-DSNLSHJZ-DiC78RqE.js +127 -0
  30. package/dist/ui/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  31. package/dist/ui/dist/assets/chunk-HAGFOTED-X_RbvixX.js +62 -0
  32. package/dist/ui/dist/assets/chunk-HNE4Q36P-C14_x5ck.js +1 -0
  33. package/dist/ui/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  34. package/dist/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  35. package/dist/ui/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  36. package/dist/ui/dist/assets/chunk-NQOKRVIF-Dxd0dtYR.js +213 -0
  37. package/dist/ui/dist/assets/chunk-UHW7PFBP-BIxePNUS.js +1 -0
  38. package/dist/ui/dist/assets/chunk-VQ5W62QI-CKSKLbj4.js +1 -0
  39. package/dist/ui/dist/assets/chunk-WQQL4CDK-CWNmZGlR.js +10 -0
  40. package/dist/ui/dist/assets/chunk-XXDRQBXY-D-I5QHgG.js +1 -0
  41. package/dist/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  42. package/dist/ui/dist/assets/chunk-YZO67HFG-DFP68CFd.js +1 -0
  43. package/dist/ui/dist/assets/chunk-ZAUX2LTX-DY90d4QG.js +1 -0
  44. package/dist/ui/dist/assets/chunk-ZCHMQSOR-Dlpr-5UC.js +2 -0
  45. package/dist/ui/dist/assets/classDiagram-v2-4MYWR3UA-CH9uhoLJ.js +217 -0
  46. package/dist/ui/dist/assets/cose-bilkent-JH36ORCC-DtYHrp8D.js +1 -0
  47. package/dist/ui/dist/assets/cynefin-EF2NZ3EQ-BtslbtrY.js +1 -0
  48. package/dist/ui/dist/assets/cynefinDiagram-WF24UDRP-DUU1Jcc7.js +62 -0
  49. package/dist/ui/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  50. package/dist/ui/dist/assets/dagre-NK4GIZVX-CDlP-dRW.js +4 -0
  51. package/dist/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  52. package/dist/ui/dist/assets/diagram-CBK3MWTC-DiV2fn-r.js +24 -0
  53. package/dist/ui/dist/assets/diagram-I5XIIV2D-Bg5RehGi.js +24 -0
  54. package/dist/ui/dist/assets/diagram-ODIL2UCW-CFmazz3f.js +30 -0
  55. package/dist/ui/dist/assets/diagram-PFBEJUXE-uw3ZBqDp.js +3 -0
  56. package/dist/ui/dist/assets/diagram-RJA3I4VV-uEOYYRYa.js +41 -0
  57. package/dist/ui/dist/assets/diagram-TYUNFSNR-BN58LF_3.js +200 -0
  58. package/dist/ui/dist/assets/dist-tWS_lXDS.js +1 -0
  59. package/dist/ui/dist/assets/ebnfDiagram-V4P3TTPT-B68Tz-w8.js +1 -0
  60. package/dist/ui/dist/assets/elk-QONTBCTQ-DZZohNea.js +27 -0
  61. package/dist/ui/dist/assets/erDiagram-KYQIGQAA-BH_m74pv.js +99 -0
  62. package/dist/ui/dist/assets/eventmodeling-K75KTNOO-CTp5FHos.js +1 -0
  63. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CsCvexry.js +1 -0
  64. package/dist/ui/dist/assets/ganttDiagram-R7GPNTJY-BDau7_0f.js +292 -0
  65. package/dist/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  66. package/dist/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  67. package/dist/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  68. package/dist/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  69. package/dist/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  70. package/dist/ui/dist/assets/gitGraph-VSP46ZUC-Bv84IoCm.js +1 -0
  71. package/dist/ui/dist/assets/gitGraphDiagram-PDYL7XHY-DzXcMlm8.js +106 -0
  72. package/dist/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
  73. package/dist/ui/dist/assets/index-BTJoqVVN.js +133 -0
  74. package/dist/ui/dist/assets/index-D60U3smc.css +1 -0
  75. package/dist/ui/dist/assets/info-OHQRW6UA-222J0O_j.js +1 -0
  76. package/dist/ui/dist/assets/infoDiagram-MVDRKAX5-DE2sa3Vz.js +2 -0
  77. package/dist/ui/dist/assets/init-C-OQMol4.js +1 -0
  78. package/dist/ui/dist/assets/ishikawaDiagram-HPEHV2GE-BJDk0Zcj.js +70 -0
  79. package/dist/ui/dist/assets/journeyDiagram-BOSHW7O7-JSUXX5Hb.js +139 -0
  80. package/dist/ui/dist/assets/kanban-definition-FNSA4HC5-Bg79b49M.js +89 -0
  81. package/dist/ui/dist/assets/katex-B_JcIwqj.js +257 -0
  82. package/dist/ui/dist/assets/line-Bk5qQSIa.js +1 -0
  83. package/dist/ui/dist/assets/linear-PFnVMru5.js +1 -0
  84. package/dist/ui/dist/assets/mermaid-parser.core-Dcxlzc-S.js +7 -0
  85. package/dist/ui/dist/assets/mermaid.core-TeOHQJ00.js +44 -0
  86. package/dist/ui/dist/assets/mindmap-definition-6OY57UG3-IaNk8NnW.js +98 -0
  87. package/dist/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  88. package/dist/ui/dist/assets/packet-JDAUHWVQ-DMff5cVK.js +1 -0
  89. package/dist/ui/dist/assets/path-fybaL0A-.js +1 -0
  90. package/dist/ui/dist/assets/pegDiagram-ECIUJNBX-66Xu4YRc.js +1 -0
  91. package/dist/ui/dist/assets/pie-XZMESJXO-GEtzLCIM.js +1 -0
  92. package/dist/ui/dist/assets/pieDiagram-GNMPY74T-7jMBi-kU.js +39 -0
  93. package/dist/ui/dist/assets/prism-bash-D6zCJ74D.js +1 -0
  94. package/dist/ui/dist/assets/prism-c-04YixN25.js +1 -0
  95. package/dist/ui/dist/assets/prism-clike-DapgWyxx.js +1 -0
  96. package/dist/ui/dist/assets/prism-core-FDHlWQ43.js +1 -0
  97. package/dist/ui/dist/assets/prism-cpp-C-lJbC-6.js +1 -0
  98. package/dist/ui/dist/assets/prism-css-CPgOzxXp.js +1 -0
  99. package/dist/ui/dist/assets/prism-go-C_8qAH5n.js +1 -0
  100. package/dist/ui/dist/assets/prism-java-6wtusvIl.js +1 -0
  101. package/dist/ui/dist/assets/prism-javascript-BnO-swvr.js +1 -0
  102. package/dist/ui/dist/assets/prism-json-Dp_-W-Hv.js +1 -0
  103. package/dist/ui/dist/assets/prism-jsx-D-D0NWtC.js +1 -0
  104. package/dist/ui/dist/assets/prism-kotlin-BUlJgC-o.js +1 -0
  105. package/dist/ui/dist/assets/prism-markdown-Bc9-36OC.js +1 -0
  106. package/dist/ui/dist/assets/prism-markup-Cp04rIa1.js +1 -0
  107. package/dist/ui/dist/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
  108. package/dist/ui/dist/assets/prism-php-Co4fM40K.js +1 -0
  109. package/dist/ui/dist/assets/prism-python-CfiSVtM6.js +1 -0
  110. package/dist/ui/dist/assets/prism-ruby-DFCASkuD.js +1 -0
  111. package/dist/ui/dist/assets/prism-rust-Bu9N8X3N.js +1 -0
  112. package/dist/ui/dist/assets/prism-sql-CYhVDMEE.js +1 -0
  113. package/dist/ui/dist/assets/prism-swift-B0kWc-ne.js +1 -0
  114. package/dist/ui/dist/assets/prism-toml-DKb54GAT.js +1 -0
  115. package/dist/ui/dist/assets/prism-tsx-p6J2Kc_4.js +1 -0
  116. package/dist/ui/dist/assets/prism-typescript-BMqONzxf.js +1 -0
  117. package/dist/ui/dist/assets/prism-yaml-C0JHu8gr.js +1 -0
  118. package/dist/ui/dist/assets/quadrantDiagram-D57K4MC3-8hIWayC2.js +7 -0
  119. package/dist/ui/dist/assets/radar-ABXABTNO-BTdwUOOF.js +1 -0
  120. package/dist/ui/dist/assets/railroad-I3PHUGI6-DcMbDqBL.js +1 -0
  121. package/dist/ui/dist/assets/railroad-abnf-LNEFI6M7-gTzUTWeI.js +1 -0
  122. package/dist/ui/dist/assets/railroad-ebnf-DWYD2UWJ-CNtdUPtk.js +1 -0
  123. package/dist/ui/dist/assets/railroad-peg-JR7BVNR7-DOiExAtE.js +1 -0
  124. package/dist/ui/dist/assets/railroadDiagram-Y72NRBL7-2kE_ygvM.js +1 -0
  125. package/dist/ui/dist/assets/requirementDiagram-EQYSW5RA-CoIAHRq2.js +84 -0
  126. package/dist/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  127. package/dist/ui/dist/assets/sankeyDiagram-SY24JHO5-7p8D3wim.js +40 -0
  128. package/dist/ui/dist/assets/sequenceDiagram-BLEHNHQU-C6QFnMvn.js +169 -0
  129. package/dist/ui/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  130. package/dist/ui/dist/assets/src-FgijoBVs.js +1 -0
  131. package/dist/ui/dist/assets/stateDiagram-v2-A6IESKWZ-n9eAb_bT.js +291 -0
  132. package/dist/ui/dist/assets/swimlanes-B3ENRKFQ-U18mtUzX.js +1 -0
  133. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DX07QVy9.js +8 -0
  134. package/dist/ui/dist/assets/timeline-definition-JXYKGLGB-CD2Z8QGP.js +120 -0
  135. package/dist/ui/dist/assets/treeView-D4JQ5SDB-sqUjH5gB.js +1 -0
  136. package/dist/ui/dist/assets/treemap-SAJKECNS-DvZ38FP7.js +1 -0
  137. package/dist/ui/dist/assets/usecaseDiagram-WPOLBNNX-CqOGidaZ.js +328 -0
  138. package/dist/ui/dist/assets/vennDiagram-Q3Y3IDCW-D1SXSBQ5.js +34 -0
  139. package/dist/ui/dist/assets/wardley-7MLQ67FV-Bpwio-Dx.js +1 -0
  140. package/dist/ui/dist/assets/wardleyDiagram-IVAIDKWA-JSUXHMnk.js +78 -0
  141. package/dist/ui/dist/assets/xychartDiagram-4URNHDU4-B4IFqQTN.js +7 -0
  142. package/dist/ui/dist/index.html +15 -0
  143. package/dist/ui/dist/style.css +121 -0
  144. package/package.json +31 -8
  145. package/skills/follow-me/SKILL.md +15 -61
  146. package/dist/src/anthropic.js +0 -101
  147. package/dist/ui/app.js +0 -797
  148. package/dist/ui/index.html +0 -40
  149. package/dist/ui/playback.js +0 -175
  150. package/dist/ui/style.css +0 -466
@@ -1,9 +1,12 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { createServer } from "node:http";
4
- import { isAbsolute, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { basename, isAbsolute, join } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
- import { isPlanVersion } from "./plan.js";
7
+ import { isReplyId, parseComment, parseDraft, parseNewDecision, parseNewReply, } from "./comments.js";
8
+ import { isConcernId, isPlanVersion } from "./plan.js";
9
+ import { renderReport, reportItems } from "./prompt.js";
7
10
  import { isLockToken } from "./session.js";
8
11
  /** The requested port is taken; host.ts decides whether that is our server or a stranger. */
9
12
  export class PortInUseError extends Error {
@@ -20,16 +23,19 @@ export class RegistrationError extends Error {
20
23
  }
21
24
  /** GET /api/health answers with this, so a CLI can tell our server from another program. */
22
25
  export const HEALTH_APP = "follow-me";
23
- const UI_DIR = new URL("../ui/", import.meta.url);
24
- const STATIC_FILES = new Map([
25
- ["/app.js", { file: "app.js", type: "text/javascript; charset=utf-8" }],
26
- ["/playback.js", { file: "playback.js", type: "text/javascript; charset=utf-8" }],
27
- ["/style.css", { file: "style.css", type: "text/css; charset=utf-8" }],
26
+ /** The page Vite builds from ui/ into ui/dist; `npm run build` makes it. */
27
+ const UI_DIR = new URL("../ui/dist/", import.meta.url);
28
+ /** Hashed bundles under /assets/ and the few flat files of ui/public at the root. */
29
+ const STATIC_FILE = /^\/((?:assets\/)?[\w-]+(?:\.[\w-]+)*\.(js|css|woff2|svg))$/;
30
+ const STATIC_TYPES = new Map([
31
+ ["js", "text/javascript; charset=utf-8"],
32
+ ["css", "text/css; charset=utf-8"],
33
+ ["woff2", "font/woff2"],
34
+ ["svg", "image/svg+xml"],
28
35
  ]);
29
36
  const MAX_BODY_BYTES = 1_000_000;
30
37
  const BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
31
38
  const COMMENT_ID = /^c_[a-z2-7]{6}$/;
32
- const UNIT_ID = /^[hfm]_[0-9a-f]{12}(~\d+)?$/;
33
39
  const SESSION_ID = /^[0-9a-f]{12}$/;
34
40
  function isRecord(value) {
35
41
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -37,15 +43,6 @@ function isRecord(value) {
37
43
  function isCommentId(value) {
38
44
  return typeof value === "string" && COMMENT_ID.test(value);
39
45
  }
40
- function isUnitId(value) {
41
- return typeof value === "string" && UNIT_ID.test(value);
42
- }
43
- function isRepoPath(value) {
44
- return typeof value === "string" && value.length > 0;
45
- }
46
- function isIndex(value) {
47
- return typeof value === "number" && Number.isInteger(value) && value >= 0;
48
- }
49
46
  export function isSessionId(value) {
50
47
  return typeof value === "string" && SESSION_ID.test(value);
51
48
  }
@@ -59,50 +56,6 @@ export function sessionIdOf(dir) {
59
56
  export function sessionPath(id) {
60
57
  return `/s/${id}/`;
61
58
  }
62
- function parseAnchor(raw) {
63
- if (!isRecord(raw))
64
- return null;
65
- const commit = raw["commit"];
66
- switch (raw["kind"]) {
67
- case "plan":
68
- return { kind: "plan" };
69
- case "message":
70
- return isIndex(commit) ? { kind: "message", commit } : null;
71
- case "position":
72
- return isIndex(commit) ? { kind: "position", commit } : null;
73
- case "hunk": {
74
- const unit = raw["unit"];
75
- const changes = raw["changes"];
76
- if (!isIndex(commit) || !isUnitId(unit))
77
- return null;
78
- if (changes === null)
79
- return { kind: "hunk", unit, commit, changes: null };
80
- if (!Array.isArray(changes) || !changes.every(isIndex))
81
- return null;
82
- return { kind: "hunk", unit, commit, changes };
83
- }
84
- default:
85
- return null;
86
- }
87
- }
88
- /** The one place untrusted JSON becomes a Comment. Returns null when any field is wrong. */
89
- export function parseComment(raw) {
90
- if (!isRecord(raw))
91
- return null;
92
- const { id, body, createdAt } = raw;
93
- const context = raw["context"];
94
- const anchor = parseAnchor(raw["anchor"]);
95
- if (!isCommentId(id) || !anchor || !isRecord(context))
96
- return null;
97
- const { commitMessage, path } = context;
98
- if (typeof commitMessage !== "string" || typeof body !== "string")
99
- return null;
100
- if (typeof createdAt !== "string")
101
- return null;
102
- if (path !== null && !isRepoPath(path))
103
- return null;
104
- return { id, anchor, context: { commitMessage, path }, body, createdAt };
105
- }
106
59
  /** The one place untrusted JSON becomes a Registration. */
107
60
  export function parseRegistration(raw) {
108
61
  if (!isRecord(raw))
@@ -121,25 +74,26 @@ export function parseRegistration(raw) {
121
74
  function draftPath(reviewsDir, version) {
122
75
  return join(reviewsDir, `v${version}.draft.json`);
123
76
  }
124
- /** Comments autosaved by an unfinished review of this version, or none. */
125
- export async function loadDraftComments(reviewsDir, version) {
77
+ /** What an unfinished review of this version autosaved, or nothing. */
78
+ export async function loadDraft(reviewsDir, version) {
126
79
  let text;
127
80
  try {
128
81
  text = await readFile(draftPath(reviewsDir, version), "utf8");
129
82
  }
130
83
  catch (error) {
131
- if (isRecord(error) && error["code"] === "ENOENT")
132
- return [];
84
+ if (isRecord(error) && error["code"] === "ENOENT") {
85
+ return { comments: [], concerns: [], resolved: [], excluded: { comments: [], concerns: [] } };
86
+ }
133
87
  throw error;
134
88
  }
135
- const raw = JSON.parse(text);
136
- const list = isRecord(raw) ? raw["comments"] : null;
137
- if (!Array.isArray(list))
89
+ const draft = parseDraft(JSON.parse(text));
90
+ if (typeof draft === "string")
138
91
  throw new Error(`malformed review draft for v${version}`);
139
- return list.flatMap((item) => {
140
- const comment = parseComment(item);
141
- return comment ? [comment] : [];
142
- });
92
+ return draft;
93
+ }
94
+ /** Comments autosaved by an unfinished review of this version, or none. */
95
+ export async function loadDraftComments(reviewsDir, version) {
96
+ return (await loadDraft(reviewsDir, version)).comments;
143
97
  }
144
98
  async function writeAtomic(path, text) {
145
99
  // The pid lets `follow-me clean` tell an abandoned temp file from one being written.
@@ -184,6 +138,17 @@ function sendJson(res, status, body) {
184
138
  res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
185
139
  res.end(JSON.stringify(body));
186
140
  }
141
+ /** An integer query parameter within [min, max], else the default when absent; bad is a 400. */
142
+ function queryInt(url, name, min, max, fallback) {
143
+ const raw = url.searchParams.get(name);
144
+ if (raw === null)
145
+ return fallback;
146
+ const n = Number(raw);
147
+ if (raw === "" || !Number.isInteger(n) || n < min) {
148
+ throw new HttpError(400, `${name} must be an integer of at least ${min}`);
149
+ }
150
+ return Math.min(n, max);
151
+ }
187
152
  function sendHtml(res, status, html) {
188
153
  res.writeHead(status, {
189
154
  "content-type": "text/html; charset=utf-8",
@@ -194,15 +159,47 @@ function sendHtml(res, status, html) {
194
159
  function escapeHtml(text) {
195
160
  return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
196
161
  }
197
- function indexPage(sessions) {
198
- const rows = sessions.map((s) => {
199
- const what = s.mode === "walk" ? "code review" : `review of plan v${s.version}`;
162
+ /** "just now", "5 min ago", "2 h ago" or "3 days ago". */
163
+ export function ago(ms) {
164
+ const minutes = Math.floor(ms / 60_000);
165
+ if (minutes < 1)
166
+ return "just now";
167
+ if (minutes < 60)
168
+ return `${minutes} min ago`;
169
+ const hours = Math.floor(minutes / 60);
170
+ if (hours < 24)
171
+ return `${hours} h ago`;
172
+ const days = Math.floor(hours / 24);
173
+ return `${days} day${days === 1 ? "" : "s"} ago`;
174
+ }
175
+ function tildePath(path) {
176
+ const home = homedir();
177
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
178
+ }
179
+ function indexPage(rows, now) {
180
+ const items = rows.map(({ summary: s, openedAt }) => {
181
+ const mode = s.mode === "walk"
182
+ ? '<span class="mode walk">code review</span>'
183
+ : `<span class="mode split">plan review v${s.version}</span>`;
200
184
  const count = `${s.comments} comment${s.comments === 1 ? "" : "s"}`;
201
- return ` <li><a href="${s.url}">${escapeHtml(s.branch)}</a> <span class="muted">${what} · ${count} · ${escapeHtml(s.repo)}</span></li>`;
185
+ const repo = escapeHtml(basename(s.repo));
186
+ return ` <li>
187
+ <a class="session" href="${s.url}"><span class="repo">${repo}</span> <span class="branch">${escapeHtml(s.branch)}</span></a>
188
+ ${mode}
189
+ <span class="muted">${count} · opened ${ago(now - openedAt)}</span>
190
+ <span class="path muted">${escapeHtml(tildePath(s.repo))}</span>
191
+ </li>`;
202
192
  });
203
- const body = rows.length === 0
204
- ? ' <p class="muted">No sessions. Run follow-me review or follow-me walk in a repo.</p>'
205
- : ` <ul class="session-index">\n${rows.join("\n")}\n </ul>`;
193
+ const body = items.length === 0
194
+ ? ` <section class="empty">
195
+ <h1>No sessions yet</h1>
196
+ <p>Ask your coding agent to review a branch with follow-me. Each review opens here.</p>
197
+ <p><a href="https://github.com/phanshiyu/follow-me#readme">How follow-me works</a></p>
198
+ </section>`
199
+ : ` <h1>Sessions</h1>
200
+ <ul class="session-index">
201
+ ${items.join("\n")}
202
+ </ul>`;
206
203
  return `<!doctype html>
207
204
  <html lang="en">
208
205
  <head>
@@ -213,109 +210,464 @@ function indexPage(sessions) {
213
210
  <link rel="stylesheet" href="/style.css" />
214
211
  </head>
215
212
  <body class="index">
216
- <h1>follow-me sessions</h1>
213
+ <header class="topbar"><strong>follow-me</strong></header>
214
+ <main class="index-main">
217
215
  ${body}
216
+ </main>
218
217
  </body>
219
218
  </html>
220
219
  `;
221
220
  }
222
- /** One hosted review: its comments, its autosave chain, and how it ends. */
223
- function hostSession(id, registration, loaded) {
221
+ async function readUi(dir, name) {
222
+ try {
223
+ return await readFile(fileURLToPath(new URL(name, dir)));
224
+ }
225
+ catch (error) {
226
+ if (isRecord(error) && error["code"] === "ENOENT")
227
+ return null;
228
+ throw error;
229
+ }
230
+ }
231
+ const NOT_BUILT = `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="color-scheme" content="light dark" /><title>follow-me</title></head><body style="font-family: system-ui; max-width: 40rem; margin: 4rem auto; padding: 0 1rem"><h1>The review page is not built</h1><p>This follow-me runs from a checkout without <code>ui/dist</code>. Run <code>npm run build</code> (or <code>npm run ui:build</code>) in the checkout, then reload.</p></body></html>
232
+ `;
233
+ function viewOf(registration, loaded, draft) {
224
234
  const { state, reviewsDir } = loaded;
225
- const comments = new Map(state.comments.map((c) => [c.id, c]));
226
- const unitIds = new Set(state.units.map((u) => u.id));
227
- const draftFile = draftPath(reviewsDir, state.version);
228
- const reviewFile = join(reviewsDir, `v${state.version}.json`);
235
+ return {
236
+ registration,
237
+ loaded,
238
+ comments: new Map(state.comments.map((c) => [c.id, c])),
239
+ decisions: new Map(draft.concerns.map((d) => [d.concern, d])),
240
+ resolved: new Set(draft.resolved),
241
+ excludedComments: new Set(draft.excluded.comments),
242
+ excludedConcerns: new Set(draft.excluded.concerns),
243
+ unitIds: new Set(state.units.map((u) => u.id)),
244
+ hunkRows: new Map(state.units.flatMap((u) => u.kind === "hunk" ? [[u.id, u.body.length]] : [])),
245
+ concernIds: new Set(state.commits.flatMap((c) => (c.concerns ?? []).map((k) => k.id))),
246
+ draftFile: draftPath(reviewsDir, state.version),
247
+ reviewFile: join(reviewsDir, `v${state.version}.json`),
248
+ };
249
+ }
250
+ function withReplies(comment, replies) {
251
+ const { replies: _dropped, ...rest } = comment;
252
+ return replies === undefined || replies.length === 0 ? rest : { ...rest, replies };
253
+ }
254
+ function mintReplyId(taken) {
255
+ for (;;) {
256
+ let id = "r_";
257
+ for (const byte of randomBytes(6))
258
+ id += BASE32.charAt(byte % 32);
259
+ if (isReplyId(id) && !taken.has(id))
260
+ return id;
261
+ }
262
+ }
263
+ /**
264
+ * What the agent may see of the log: a pending flag is held back with everything said about it,
265
+ * and reaches the agent as an ordinary "comment" event when the reviewer submits.
266
+ */
267
+ function forAgent(log) {
268
+ const held = new Set();
269
+ const out = [];
270
+ for (const event of log) {
271
+ if (event.type === "comment" || event.type === "comment-updated") {
272
+ const { comment } = event;
273
+ if (comment.pending === true) {
274
+ held.add(comment.id);
275
+ continue;
276
+ }
277
+ if (held.delete(comment.id))
278
+ out.push({ ...event, type: "comment" });
279
+ else
280
+ out.push(event);
281
+ }
282
+ else if (event.type === "comment-deleted") {
283
+ if (!held.delete(event.id))
284
+ out.push(event);
285
+ }
286
+ else if (event.type === "reply") {
287
+ if (!held.has(event.commentId))
288
+ out.push(event);
289
+ }
290
+ else
291
+ out.push(event);
292
+ }
293
+ return out;
294
+ }
295
+ const DEFAULT_POLL_MS = 25_000;
296
+ const MAX_POLL_MS = 60_000;
297
+ const STREAM_PING_MS = 15_000;
298
+ /** One hosted review: its comments, its event log, its autosave chain, and how it ends. */
299
+ function hostSession(id, registration, loaded, draft, load) {
300
+ const openedAt = Date.now();
301
+ let view = viewOf(registration, loaded, draft);
229
302
  let finish = () => { };
230
303
  const done = new Promise((resolve) => {
231
304
  finish = resolve;
232
305
  });
306
+ // The append-only log a watcher long-polls. seq is the position, counted from 1.
307
+ const events = [];
308
+ const waiters = new Set();
309
+ const listeners = new Set();
310
+ const emit = (body) => {
311
+ events.push({ ...body, seq: events.length + 1 });
312
+ for (const wake of [...waiters])
313
+ wake();
314
+ for (const listener of [...listeners])
315
+ listener();
316
+ };
317
+ const seen = (agent) => (agent ? forAgent(events) : events);
318
+ const waitForEvents = (since, timeoutMs, signal, agent) => new Promise((resolve) => {
319
+ const news = () => seen(agent).filter((e) => e.seq > since);
320
+ const answer = () => resolve({ seq: events.length, events: news() });
321
+ if (news().length > 0 || timeoutMs === 0 || signal.aborted)
322
+ return answer();
323
+ const wake = () => {
324
+ if (news().length === 0 && !signal.aborted)
325
+ return;
326
+ clearTimeout(timer);
327
+ waiters.delete(wake);
328
+ signal.removeEventListener("abort", wake);
329
+ answer();
330
+ };
331
+ const timer = setTimeout(() => {
332
+ waiters.delete(wake);
333
+ signal.removeEventListener("abort", wake);
334
+ answer();
335
+ }, timeoutMs);
336
+ waiters.add(wake);
337
+ signal.addEventListener("abort", wake);
338
+ });
233
339
  let ending = false;
340
+ let swapping = false;
341
+ const open = () => {
342
+ if (ending)
343
+ throw new HttpError(409, "this review is already ending");
344
+ if (swapping)
345
+ throw new HttpError(409, "the session is switching plan versions; try again");
346
+ };
347
+ /** The exclusions as stored, or null when nothing is excluded. */
348
+ const excludedOf = () => view.excludedComments.size + view.excludedConcerns.size === 0
349
+ ? null
350
+ : { comments: [...view.excludedComments], concerns: [...view.excludedConcerns] };
234
351
  // Writes are chained so two rapid edits never race on the same file.
235
352
  let writes = Promise.resolve();
236
353
  const saveDraft = () => {
354
+ const { state } = view.loaded;
355
+ const file = view.draftFile;
356
+ const concerns = [...view.decisions.values()];
357
+ const resolved = [...view.resolved];
358
+ const excluded = excludedOf();
237
359
  const snapshot = JSON.stringify({
238
360
  schema: "follow-me/review-draft@1",
239
361
  planVersion: state.version,
240
- comments: [...comments.values()],
362
+ comments: [...view.comments.values()],
363
+ ...(concerns.length === 0 ? {} : { concerns }),
364
+ ...(resolved.length === 0 ? {} : { resolved }),
365
+ ...(excluded === null ? {} : { excluded }),
241
366
  }, null, 2);
367
+ const dir = view.loaded.reviewsDir;
242
368
  writes = writes.then(async () => {
243
- await mkdir(reviewsDir, { recursive: true });
244
- await writeAtomic(draftFile, `${snapshot}\n`);
369
+ await mkdir(dir, { recursive: true });
370
+ await writeAtomic(file, `${snapshot}\n`);
245
371
  });
246
372
  return writes;
247
373
  };
248
- const accept = (comment) => {
249
- if (!comment)
250
- throw new HttpError(400, "malformed comment");
251
- const { anchor } = comment;
252
- if (anchor.kind !== "plan" && anchor.commit >= state.commits.length) {
374
+ const accept = (parsed) => {
375
+ if (typeof parsed === "string")
376
+ throw new HttpError(400, `malformed comment: ${parsed}`);
377
+ if (!COMMENT_ID.test(parsed.id))
378
+ throw new HttpError(400, "malformed comment: bad id");
379
+ const { anchor } = parsed;
380
+ if (anchor.kind !== "plan" && anchor.commit >= view.loaded.state.commits.length) {
253
381
  throw new HttpError(400, "anchor commit out of range");
254
382
  }
255
- if (anchor.kind === "hunk" && !unitIds.has(anchor.unit)) {
383
+ if (anchor.kind === "hunk" && !view.unitIds.has(anchor.unit)) {
256
384
  throw new HttpError(400, "anchor names an unknown unit");
257
385
  }
258
- return comment;
386
+ if (anchor.kind === "hunk" && anchor.offsets !== undefined) {
387
+ const rows = view.hunkRows.get(anchor.unit);
388
+ if (rows === undefined)
389
+ throw new HttpError(400, "anchor.offsets names a unit without rows");
390
+ if (anchor.offsets.some((o) => o >= rows)) {
391
+ throw new HttpError(400, `anchor.offsets is out of range, the unit has ${rows} rows`);
392
+ }
393
+ }
394
+ return parsed;
259
395
  };
396
+ /** Clears `pending` on every held flag at once and tells the agent what it missed. */
397
+ const release = () => {
398
+ const held = [...view.comments.values()].filter((c) => c.pending === true);
399
+ if (held.length === 0)
400
+ return 0;
401
+ emit({ type: "submitted" });
402
+ for (const comment of held) {
403
+ const { pending: _held, ...open } = comment;
404
+ view.comments.set(comment.id, open);
405
+ emit({ type: "comment", comment: open });
406
+ }
407
+ return held.length;
408
+ };
409
+ const review = () => {
410
+ const concerns = [...view.decisions.values()];
411
+ const resolved = [...view.resolved];
412
+ const excluded = excludedOf();
413
+ return {
414
+ schema: "follow-me/review@1",
415
+ mode: view.loaded.state.mode,
416
+ planVersion: view.loaded.state.version,
417
+ planHash: view.loaded.state.planHash,
418
+ endedAt: new Date().toISOString(),
419
+ comments: [...view.comments.values()].filter((c) => c.pending !== true),
420
+ ...(concerns.length === 0 ? {} : { concerns }),
421
+ ...(resolved.length === 0 ? {} : { resolved }),
422
+ ...(excluded === null ? {} : { excluded }),
423
+ };
424
+ };
425
+ const report = (finished) => {
426
+ const { state } = view.loaded;
427
+ const units = new Map(state.units.map((u) => [u.id, u]));
428
+ return renderReport(finished, state.commits, units, {
429
+ branch: state.anchor.branch,
430
+ responses: [],
431
+ });
432
+ };
433
+ /** Writes reviews/vN.json and its report once, and removes the autosave. */
434
+ const writeReview = async (finished) => {
435
+ await writes;
436
+ await mkdir(view.loaded.reviewsDir, { recursive: true });
437
+ await writeAtomic(view.reviewFile, `${JSON.stringify(finished, null, 2)}\n`);
438
+ await writeAtomic(view.reviewFile.replace(/\.json$/, ".md"), report(finished));
439
+ await rm(view.draftFile, { force: true });
440
+ return view.reviewFile;
441
+ };
442
+ const hasWork = () => view.comments.size > 0 || view.decisions.size > 0;
260
443
  return {
261
444
  id,
262
- registration,
263
445
  done,
446
+ openedAt,
447
+ get registration() {
448
+ return view.registration;
449
+ },
264
450
  summary: () => ({
265
451
  id,
266
452
  url: sessionPath(id),
267
- repo: loaded.repo,
268
- branch: state.anchor.branch,
269
- mode: state.mode,
270
- version: state.version,
271
- comments: comments.size,
453
+ repo: view.loaded.repo,
454
+ branch: view.loaded.state.anchor.branch,
455
+ mode: view.loaded.state.mode,
456
+ version: view.loaded.state.version,
457
+ comments: view.comments.size,
458
+ openedAt: new Date(openedAt).toISOString(),
272
459
  }),
273
- snapshot: () => ({ ...state, comments: [...comments.values()] }),
460
+ snapshot: () => ({
461
+ ...view.loaded.state,
462
+ comments: [...view.comments.values()],
463
+ concerns: [...view.decisions.values()],
464
+ resolved: [...view.resolved],
465
+ excluded: { comments: [...view.excludedComments], concerns: [...view.excludedConcerns] },
466
+ seq: events.length,
467
+ previous: view.loaded.previous,
468
+ }),
469
+ /** Events after `since`, now and as they happen, until unfollowed or the review ends. */
470
+ latest: () => events.length,
471
+ follow: (since, send, stop, agent) => {
472
+ if (since > events.length) {
473
+ throw new HttpError(409, `since ${since} is ahead of this session's log (${events.length})`);
474
+ }
475
+ let last = since;
476
+ const flush = () => {
477
+ for (const event of seen(agent).filter((e) => e.seq > last)) {
478
+ send(event);
479
+ last = event.seq;
480
+ if (event.type === "ended") {
481
+ listeners.delete(flush);
482
+ stop();
483
+ return;
484
+ }
485
+ }
486
+ };
487
+ listeners.add(flush);
488
+ flush();
489
+ return () => listeners.delete(flush);
490
+ },
491
+ poll: (since, timeoutMs, signal, agent) => {
492
+ if (since > events.length) {
493
+ throw new HttpError(409, `since ${since} is ahead of this session's log (${events.length})`);
494
+ }
495
+ return waitForEvents(since, timeoutMs, signal, agent);
496
+ },
497
+ report: () => report(review()),
498
+ reportItems: () => {
499
+ const { state } = view.loaded;
500
+ const units = new Map(state.units.map((u) => [u.id, u]));
501
+ return reportItems(review(), state.commits, units, {
502
+ branch: state.anchor.branch,
503
+ responses: [],
504
+ });
505
+ },
274
506
  add: async (raw) => {
507
+ open();
275
508
  if (!isRecord(raw))
276
509
  throw new HttpError(400, "malformed comment");
277
- const commentId = mintCommentId(new Set(comments.keys()));
278
- const comment = accept(parseComment({ ...raw, id: commentId, createdAt: new Date().toISOString() }));
279
- comments.set(commentId, comment);
510
+ const commentId = mintCommentId(new Set(view.comments.keys()));
511
+ const comment = withReplies(accept(parseComment({ ...raw, id: commentId, createdAt: new Date().toISOString() })), undefined);
512
+ view.comments.set(commentId, comment);
513
+ emit({ type: "comment", comment });
280
514
  await saveDraft();
281
515
  return commentId;
282
516
  },
283
517
  put: async (target, raw) => {
284
- const comment = accept(parseComment(raw));
285
- if (comment.id !== target)
518
+ open();
519
+ const parsed = withReplies(accept(parseComment(raw)), undefined);
520
+ if (parsed.id !== target)
286
521
  throw new HttpError(400, "comment id does not match the URL");
287
- comments.set(comment.id, comment);
522
+ // Replies belong to the conversation, not to the page's copy of the comment.
523
+ const comment = withReplies(parsed, view.comments.get(target)?.replies);
524
+ view.comments.set(comment.id, comment);
525
+ emit({ type: "comment-updated", comment });
288
526
  await saveDraft();
289
527
  return comment;
290
528
  },
291
529
  remove: async (target) => {
292
- if (!comments.delete(target))
530
+ open();
531
+ const gone = view.comments.get(target);
532
+ if (gone === undefined)
533
+ throw new HttpError(404, "no such comment");
534
+ view.comments.delete(target);
535
+ view.excludedComments.delete(gone.id);
536
+ emit({ type: "comment-deleted", id: gone.id });
537
+ await saveDraft();
538
+ },
539
+ reply: async (target, raw) => {
540
+ open();
541
+ const comment = view.comments.get(target);
542
+ if (comment === undefined)
293
543
  throw new HttpError(404, "no such comment");
544
+ const parsed = parseNewReply(raw);
545
+ if (typeof parsed === "string")
546
+ throw new HttpError(400, `malformed reply: ${parsed}`);
547
+ const taken = new Set([...view.comments.values()].flatMap((c) => c.replies ?? []).map((r) => r.id));
548
+ const reply = {
549
+ id: mintReplyId(taken),
550
+ ...parsed,
551
+ createdAt: new Date().toISOString(),
552
+ };
553
+ view.comments.set(comment.id, { ...comment, replies: [...(comment.replies ?? []), reply] });
554
+ emit({ type: "reply", commentId: comment.id, reply });
555
+ await saveDraft();
556
+ return reply.id;
557
+ },
558
+ decide: async (target, raw) => {
559
+ open();
560
+ if (!isConcernId(target) || !view.concernIds.has(target)) {
561
+ throw new HttpError(404, "no such concern in this plan");
562
+ }
563
+ const parsed = parseNewDecision(target, raw);
564
+ if (typeof parsed === "string")
565
+ throw new HttpError(400, `malformed decision: ${parsed}`);
566
+ const decision = { ...parsed, decidedAt: new Date().toISOString() };
567
+ view.decisions.set(target, decision);
568
+ const { concern: concernId, ...rest } = decision;
569
+ emit({ type: "concern", concernId, ...rest });
570
+ await saveDraft();
571
+ return decision;
572
+ },
573
+ /** Leaves a comment or an agent concern out of the hand-off, or puts it back. */
574
+ exclude: async (raw) => {
575
+ open();
576
+ const body = isRecord(raw) ? raw : {};
577
+ const { comment, concern, excluded } = body;
578
+ if (typeof excluded !== "boolean" || (comment === undefined) === (concern === undefined)) {
579
+ throw new HttpError(400, "expected { comment } or { concern }, and { excluded: boolean }");
580
+ }
581
+ if (comment !== undefined) {
582
+ if (!isCommentId(comment) || !view.comments.has(comment)) {
583
+ throw new HttpError(404, "no such comment");
584
+ }
585
+ if (excluded)
586
+ view.excludedComments.add(comment);
587
+ else
588
+ view.excludedComments.delete(comment);
589
+ }
590
+ else {
591
+ if (!isConcernId(concern) || !view.concernIds.has(concern)) {
592
+ throw new HttpError(404, "no such concern in this plan");
593
+ }
594
+ if (excluded)
595
+ view.excludedConcerns.add(concern);
596
+ else
597
+ view.excludedConcerns.delete(concern);
598
+ }
294
599
  await saveDraft();
600
+ return { comments: [...view.excludedComments], concerns: [...view.excludedConcerns] };
295
601
  },
296
- /** Writes reviews/vN.json exactly once and releases the session's lock. */
602
+ /** Releases every held flag in one go; the reviewer pressed Submit. */
603
+ submit: async () => {
604
+ open();
605
+ const released = release();
606
+ await saveDraft();
607
+ return { released };
608
+ },
609
+ resolve: async (raw) => {
610
+ open();
611
+ const body = isRecord(raw) ? raw : {};
612
+ const target = body["comment"];
613
+ const asked = view.loaded.previous?.comments.find((c) => c.id === target);
614
+ if (asked === undefined || typeof body["resolved"] !== "boolean") {
615
+ throw new HttpError(400, "expected { comment: <id of a question of the previous version>, resolved }");
616
+ }
617
+ if (body["resolved"])
618
+ view.resolved.add(asked.id);
619
+ else
620
+ view.resolved.delete(asked.id);
621
+ emit({ type: "resolved", commentId: asked.id, resolved: body["resolved"] });
622
+ await saveDraft();
623
+ return [...view.resolved];
624
+ },
625
+ /**
626
+ * Moves the session to a newer frozen plan under the same id and URL. The new version loads
627
+ * first, so a plan that no longer fits leaves the old one untouched; then the old version's
628
+ * review is finalized if anything was said, and the comment set starts fresh.
629
+ */
630
+ swap: async (version, hash) => {
631
+ open();
632
+ const { state } = view.loaded;
633
+ if (version === state.version) {
634
+ if (hash === state.planHash)
635
+ return;
636
+ throw new HttpError(409, `plan v${version} is already open with another hash`);
637
+ }
638
+ if (version < state.version) {
639
+ throw new HttpError(409, `plan v${version} is older than the open v${state.version}`);
640
+ }
641
+ swapping = true;
642
+ try {
643
+ await writes;
644
+ const next = await load({ ...view.registration, version, hash, lockToken: null }, view.loaded.release);
645
+ release();
646
+ if (hasWork())
647
+ await writeReview(review());
648
+ else
649
+ await rm(view.draftFile, { force: true });
650
+ view = viewOf({ ...view.registration, version, hash }, next, await loadDraft(next.reviewsDir, version));
651
+ emit({ type: "version", version, hash });
652
+ }
653
+ finally {
654
+ swapping = false;
655
+ }
656
+ },
657
+ /** Writes reviews/vN.json and vN.md exactly once and releases the session's lock. */
297
658
  end: async () => {
298
- if (ending)
299
- throw new HttpError(409, "this review is already ending");
659
+ open();
300
660
  ending = true;
301
- await writes;
302
- const review = {
303
- schema: "follow-me/review@1",
304
- mode: state.mode,
305
- planVersion: state.version,
306
- planHash: state.planHash,
307
- endedAt: new Date().toISOString(),
308
- comments: [...comments.values()],
309
- };
310
- await mkdir(reviewsDir, { recursive: true });
311
- await writeAtomic(reviewFile, `${JSON.stringify(review, null, 2)}\n`);
312
- await rm(draftFile, { force: true });
313
- loaded.release();
314
- return { review, file: reviewFile };
661
+ release();
662
+ const finished = review();
663
+ const file = await writeReview(finished);
664
+ view.loaded.release();
665
+ emit({ type: "ended", review: file });
666
+ return { review: finished, file };
315
667
  },
316
- finish: (review) => finish(review),
668
+ finish: (finished) => finish(finished),
317
669
  abandon: () => {
318
- loaded.release();
670
+ view.loaded.release();
319
671
  finish(null);
320
672
  },
321
673
  };
@@ -329,6 +681,7 @@ function sameRegistration(a, b) {
329
681
  * itself once it has had no session for idleMs.
330
682
  */
331
683
  export function startServer(opts) {
684
+ const uiDir = opts.uiDir ?? UI_DIR;
332
685
  const registry = new Map();
333
686
  const pending = new Map();
334
687
  let boundPort = 0;
@@ -380,7 +733,9 @@ export function startServer(opts) {
380
733
  return { summary: existing.summary(), done: existing.done };
381
734
  }
382
735
  clearTimeout(idleTimer);
383
- const loading = opts.load(registration).then((loaded) => hostSession(id, registration, loaded));
736
+ const loading = opts
737
+ .load(registration, null)
738
+ .then(async (loaded) => hostSession(id, registration, loaded, await loadDraft(loaded.reviewsDir, loaded.state.version), opts.load));
384
739
  pending.set(id, loading);
385
740
  try {
386
741
  const session = await loading;
@@ -408,16 +763,30 @@ export function startServer(opts) {
408
763
  if (origin !== undefined && origin !== `http://${host}`)
409
764
  throw new HttpError(403, "bad origin");
410
765
  const method = req.method ?? "GET";
411
- const path = new URL(req.url ?? "/", "http://localhost").pathname;
412
- const asset = method === "GET" ? STATIC_FILES.get(path) : undefined;
413
- if (asset) {
414
- const bytes = await readFile(fileURLToPath(new URL(asset.file, UI_DIR)));
415
- res.writeHead(200, { "content-type": asset.type, "cache-control": "no-store" });
766
+ const url = new URL(req.url ?? "/", "http://localhost");
767
+ const path = url.pathname;
768
+ const asset = method === "GET" ? STATIC_FILE.exec(path) : null;
769
+ if (asset !== null) {
770
+ const [, name = "", ext = ""] = asset;
771
+ const bytes = await readUi(uiDir, name);
772
+ if (bytes === null)
773
+ throw new HttpError(404, "not found");
774
+ // Vite hashes every name under assets/, so those never change under the same URL.
775
+ const cache = name.startsWith("assets/") ? "max-age=31536000, immutable" : "no-store";
776
+ res.writeHead(200, {
777
+ "content-type": STATIC_TYPES.get(ext) ?? "text/plain",
778
+ "cache-control": cache,
779
+ });
416
780
  res.end(bytes);
417
781
  return;
418
782
  }
419
- if (method === "GET" && path === "/")
420
- return sendHtml(res, 200, indexPage(sessions()));
783
+ if (method === "GET" && path === "/") {
784
+ const rows = [...registry.values()].map((h) => ({
785
+ summary: h.summary(),
786
+ openedAt: h.openedAt,
787
+ }));
788
+ return sendHtml(res, 200, indexPage(rows, Date.now()));
789
+ }
421
790
  if (method === "GET" && path === "/api/health") {
422
791
  return sendJson(res, 200, { app: HEALTH_APP, pid: process.pid });
423
792
  }
@@ -440,15 +809,79 @@ export function startServer(opts) {
440
809
  res.end();
441
810
  return;
442
811
  }
443
- if (method === "GET" && rest === "/") {
812
+ if (method === "GET" && (rest === "/" || rest === "/report")) {
444
813
  if (!isSessionId(rawId) || !registry.has(rawId)) {
445
- return sendHtml(res, 404, '<!doctype html><title>follow-me</title><p>This session ended or never existed. <a href="/">All sessions</a></p>\n');
814
+ return sendHtml(res, 404, '<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="color-scheme" content="light dark" /><title>follow-me</title><link rel="stylesheet" href="/style.css" /></head><body class="index"><header class="topbar"><strong>follow-me</strong></header><main class="index-main"><section class="empty"><h1>This session has ended</h1><p>It finished, or it never existed. <a href="/">All sessions</a></p></section></main></body></html>\n');
446
815
  }
447
- return sendHtml(res, 200, await readFile(fileURLToPath(new URL("index.html", UI_DIR)), "utf8"));
816
+ const page = await readUi(uiDir, "index.html");
817
+ return page === null ? sendHtml(res, 503, NOT_BUILT) : sendHtml(res, 200, page.toString());
448
818
  }
449
819
  const session = hosted(rawId);
450
820
  if (method === "GET" && rest === "/api/state")
451
821
  return sendJson(res, 200, session.snapshot());
822
+ if (method === "GET" && rest === "/api/events/stream") {
823
+ const header = Number(req.headers["last-event-id"]);
824
+ const resume = Number.isInteger(header) && header > 0 ? header : 0;
825
+ const since = Math.max(queryInt(url, "since", 0, Number.MAX_SAFE_INTEGER, 0), resume);
826
+ if (since > session.latest())
827
+ throw new HttpError(409, "since is ahead of this session's log");
828
+ res.writeHead(200, {
829
+ "content-type": "text/event-stream; charset=utf-8",
830
+ "cache-control": "no-cache",
831
+ connection: "keep-alive",
832
+ });
833
+ res.write(": follow-me\n\n");
834
+ const ping = setInterval(() => res.write(": ping\n\n"), STREAM_PING_MS);
835
+ const unfollow = session.follow(since, (event) => res.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`), () => res.end(), url.searchParams.get("audience") === "agent");
836
+ res.on("close", () => {
837
+ clearInterval(ping);
838
+ unfollow();
839
+ });
840
+ return;
841
+ }
842
+ if (method === "GET" && rest === "/api/events") {
843
+ const abort = new AbortController();
844
+ res.on("close", () => abort.abort());
845
+ const since = queryInt(url, "since", 0, Number.MAX_SAFE_INTEGER, 0);
846
+ const timeout = queryInt(url, "timeout", 0, MAX_POLL_MS, DEFAULT_POLL_MS);
847
+ const agent = url.searchParams.get("audience") === "agent";
848
+ return sendJson(res, 200, await session.poll(since, timeout, abort.signal, agent));
849
+ }
850
+ if (method === "GET" && rest === "/api/report" && url.searchParams.get("format") === "json") {
851
+ return sendJson(res, 200, session.reportItems());
852
+ }
853
+ if (method === "GET" && rest === "/api/report") {
854
+ res.writeHead(200, { "content-type": "text/markdown; charset=utf-8" });
855
+ res.end(session.report());
856
+ return;
857
+ }
858
+ if (method === "POST" && rest === "/api/version") {
859
+ const body = await readJson(req);
860
+ const version = isRecord(body) ? body["version"] : undefined;
861
+ const hash = isRecord(body) ? body["hash"] : undefined;
862
+ if (!isPlanVersion(version) || typeof hash !== "string" || hash === "") {
863
+ throw new HttpError(400, "expected { version, hash }");
864
+ }
865
+ await session.swap(version, hash);
866
+ return sendJson(res, 200, session.summary());
867
+ }
868
+ if (method === "POST" && rest === "/api/submit") {
869
+ return sendJson(res, 200, await session.submit());
870
+ }
871
+ if (method === "POST" && rest === "/api/excluded") {
872
+ return sendJson(res, 200, await session.exclude(await readJson(req)));
873
+ }
874
+ if (method === "POST" && rest === "/api/resolved") {
875
+ return sendJson(res, 200, { resolved: await session.resolve(await readJson(req)) });
876
+ }
877
+ const replies = /^\/api\/comments\/([^/]+)\/replies$/.exec(rest ?? "")?.[1];
878
+ if (replies !== undefined && method === "POST") {
879
+ return sendJson(res, 201, { id: await session.reply(replies, await readJson(req)) });
880
+ }
881
+ const concern = /^\/api\/concerns\/([^/]+)$/.exec(rest ?? "")?.[1];
882
+ if (concern !== undefined && method === "POST") {
883
+ return sendJson(res, 200, await session.decide(concern, await readJson(req)));
884
+ }
452
885
  if (method === "POST" && rest === "/api/comments") {
453
886
  return sendJson(res, 201, { id: await session.add(await readJson(req)) });
454
887
  }