@erdemtuna/doc-review 0.12.0 → 0.13.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 (49) hide show
  1. package/README.md +23 -25
  2. package/lib/SKILL.md +65 -202
  3. package/lib/agent-handoff.js +24 -0
  4. package/lib/agent-output.js +287 -0
  5. package/lib/anchor-text.js +31 -19
  6. package/lib/chrome-api.js +23 -4
  7. package/lib/chrome.html +0 -25
  8. package/lib/cli.js +282 -104
  9. package/lib/comment-target.js +4 -0
  10. package/lib/contracts/agent.js +122 -0
  11. package/lib/contracts/feedback.js +369 -1
  12. package/lib/contracts/frame.js +81 -0
  13. package/lib/contracts/history.js +189 -1
  14. package/lib/contracts/index.js +7 -2
  15. package/lib/contracts/page-boundary.js +187 -0
  16. package/lib/contracts/validation.js +200 -0
  17. package/lib/conversation-anchor-controller.js +84 -0
  18. package/lib/conversation-capture.js +81 -0
  19. package/lib/conversation-controller.js +798 -0
  20. package/lib/conversation-save.js +175 -0
  21. package/lib/conversation-server.js +184 -0
  22. package/lib/conversation-shell.js +1013 -0
  23. package/lib/conversation-store.js +809 -0
  24. package/lib/frame-controller.js +8 -8
  25. package/lib/frame-policy.js +1 -0
  26. package/lib/history-policy.js +4 -15
  27. package/lib/history-server.js +42 -326
  28. package/lib/html-transform.js +19 -4
  29. package/lib/icons.js +323 -0
  30. package/lib/new-message-target.js +27 -0
  31. package/lib/paths.js +2 -2
  32. package/lib/poll-transport.js +109 -26
  33. package/lib/positioning.js +51 -0
  34. package/lib/references/context-and-recovery.md +130 -0
  35. package/lib/references/response-contract.md +108 -0
  36. package/lib/references/source-edits.md +47 -0
  37. package/lib/revision-store.js +1 -1
  38. package/lib/save-controller.js +28 -14
  39. package/lib/sdk.js +357 -35
  40. package/lib/server.js +73 -595
  41. package/lib/setup.js +17 -48
  42. package/lib/state.js +21 -7
  43. package/lib/thread-anchor-controller.js +56 -0
  44. package/lib/toolbar-controller.js +3 -3
  45. package/lib/ui/THIRD_PARTY_NOTICES.md +127 -8
  46. package/lib/ui/chrome.css +1079 -2782
  47. package/lib/ui/chrome.js +81 -17
  48. package/package.json +2 -2
  49. package/lib/chrome-client.js +0 -1706
package/lib/server.js CHANGED
@@ -4,16 +4,18 @@ import path from "node:path";
4
4
  import crypto from "node:crypto";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { atomicWrite, Store, resolveAsset } from "./state.js";
7
- import { normalizeCommentAnchor } from "./comment-anchor.js";
8
7
  import { injectSdk, stripSdk } from "./html-transform.js";
9
8
  import { isMarkdown, renderMarkdownPage } from "./markdown.js";
10
- import { canonicalTarget, ensureStateDir, localUrl, SERVER_PROTOCOL, serverPath, stateDir, targetKey } from "./paths.js";
9
+ import { canonicalTarget, ensureStateDir, localUrl, SERVER_PROTOCOL, serverPath } from "./paths.js";
11
10
  import { acquireServerLock, releaseServerLock, removeOwnedServerRecord } from "./server-lock.js";
12
- import { invocation, shellQuote } from "./setup.js";
13
- import { limitEditFields } from "./edit-limits.js";
14
- import { createHistoryController, HistoryRequestError, historyErrorStatus } from "./history-server.js";
11
+ import { invocation } from "./setup.js";
12
+ import { agentHandoff } from "./agent-handoff.js";
13
+ import { createConversationCapture, HistoryRequestError, historyErrorStatus } from "./history-server.js";
15
14
  import { documentExecutionPolicy, transformInteractiveHtml } from "./document-execution.js";
16
15
  import { interactiveFileCsp } from "./frame-policy.js";
16
+ import { createConversationController, conversationFailure } from "./conversation-server.js";
17
+ import { ContractError } from "./contracts/validation.js";
18
+ import { stagedRoot as conversationStagedRoot } from "./conversation-save.js";
17
19
  const here = path.dirname(fileURLToPath(import.meta.url));
18
20
  const MIME = {
19
21
  ".html": "text/html; charset=utf-8",
@@ -54,7 +56,6 @@ const MAX_LOCAL_PAGE_BYTES = 24 * 1024 * 1024;
54
56
  */
55
57
  const fileReviewCsp = (nonce) => `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`;
56
58
  const hash = (text) => crypto.createHash("sha1").update(text).digest("hex");
57
- const uid = (prefix) => `${prefix}_${crypto.randomBytes(6).toString("hex")}`;
58
59
  /** Read an HTML response with a hard size cap, since text() is unbounded. */
59
60
  async function readCapped(response, url) {
60
61
  const reader = response.body.getReader();
@@ -120,8 +121,6 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
120
121
  /** Browser windows. Ephemeral — nothing durable lives here. */
121
122
  const sessions = new Map(); // sessionId -> { id, entryKey, activeKey, generation, renderId, visited, clients:Set<res>, lastSeen }
122
123
  const renders = new Map(); // renderId -> current artifact/bootstrap record
123
- /** Agent long-polls, keyed by the entry page they were started on. */
124
- const pollers = new Map(); // entryKey -> Set<{ res, timer }>
125
124
  const sseResponses = new Map(); // res -> heartbeat timer
126
125
  const watched = new Map(); // key -> { file }
127
126
  const lastWritten = new Map(); // key -> content hash doc-review itself wrote
@@ -140,9 +139,6 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
140
139
  function sessionsForKey(key) {
141
140
  return [...sessions.values()].filter((s) => s.activeKey === key);
142
141
  }
143
- function sessionsForEntry(entryKey) {
144
- return [...sessions.values()].filter((s) => s.entryKey === entryKey);
145
- }
146
142
  function expireRender(renderId) {
147
143
  if (!renderId)
148
144
  return;
@@ -182,24 +178,6 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
182
178
  res.write(`event: ${event}\ndata: ${JSON.stringify(data || {})}\n\n`);
183
179
  }
184
180
  }
185
- /**
186
- * A pending batch only means "working" once an agent has actually taken it.
187
- * Feedback sent with nothing listening is "stranded", and the browser says so.
188
- */
189
- function agentState(entryKey) {
190
- const pending = store.batch(entryKey);
191
- if (pending?.delivery_state === "delivered")
192
- return "working";
193
- const set = pollers.get(entryKey);
194
- if (set && set.size)
195
- return "listening";
196
- return pending ? "stranded" : "idle";
197
- }
198
- function broadcastAgent(entryKey) {
199
- const state = agentState(entryKey);
200
- for (const session of sessionsForEntry(entryKey))
201
- emit(session, "agent", { state });
202
- }
203
181
  // ------------------------------------------------------------- file watch
204
182
  function watchPage(key) {
205
183
  if (watched.has(key))
@@ -229,238 +207,13 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
229
207
  }
230
208
  lastWritten.set(key, current);
231
209
  for (const session of sessionsForKey(key)) {
210
+ if (session.renderId && currentRender(session.renderId)?.sourceHash === current)
211
+ continue;
232
212
  invalidateSessionRender(session);
233
213
  emit(session, "reload", { key });
234
214
  }
235
215
  });
236
216
  }
237
- function writePage(key, html) {
238
- const page = store.page(key);
239
- if (!page)
240
- throw new Error("unknown page");
241
- if (page.kind === "url")
242
- throw new Error("localhost pages are applied through their source files");
243
- const clean = stripSdk(html);
244
- atomicWrite(page.file, clean);
245
- lastWritten.set(key, hash(clean));
246
- return clean;
247
- }
248
- // ------------------------------------------------------------------ batch
249
- function deliver(entryKey) {
250
- const set = pollers.get(entryKey);
251
- if (!set || set.size === 0)
252
- return false;
253
- const pending = store.markBatchDelivered(entryKey);
254
- if (!pending)
255
- return false;
256
- for (const poller of [...set]) {
257
- clearInterval(poller.timer);
258
- set.delete(poller);
259
- poller.res.end(JSON.stringify(pending.batch));
260
- }
261
- pollers.delete(entryKey);
262
- return true;
263
- }
264
- /** Every page you left feedback on ships in one batch, grouped by target. */
265
- function collectPages(session) {
266
- const out = [];
267
- for (const key of session.visited) {
268
- const page = store.page(key);
269
- if (!page)
270
- continue;
271
- if (!page.comments.length && !page.edits.length)
272
- continue;
273
- out.push({
274
- key,
275
- kind: page.kind === "url" ? "url" : "file",
276
- file: page.kind === "url" ? page.url : page.file,
277
- url: page.kind === "url" ? page.url : undefined,
278
- comments: page.comments.map((c) => ({
279
- id: c.id,
280
- kind: c.kind,
281
- quote: c.quote,
282
- anchor: c.anchor == null ? c.anchor : normalizeCommentAnchor(c.kind, c.anchor),
283
- feedback: c.feedback,
284
- ...(c.correction ? { correction: true, correction_of: c.correctionOf } : {}),
285
- })),
286
- edits: page.edits.map((e) => ({
287
- label: e.label,
288
- kind: e.kind,
289
- before: e.before,
290
- after: e.after,
291
- ...(e.feedback_only ? { feedback_only: true } : {}),
292
- ...(e.truncated ? { truncated: true, truncated_fields: e.truncated_fields } : {}),
293
- ...(e.before_html !== undefined && e.before_html !== e.before ? { before_html: e.before_html } : {}),
294
- ...(e.after_html !== undefined && e.after_html !== e.after ? { after_html: e.after_html } : {}),
295
- ...(Array.isArray(e.staged_assets) && e.staged_assets.length ? { staged_assets: e.staged_assets } : {}),
296
- })),
297
- });
298
- }
299
- return out;
300
- }
301
- /** Pages with feedback that are not the one on screen. */
302
- function otherPages(session) {
303
- return collectPages(session)
304
- .filter((p) => p.key !== session.activeKey)
305
- .map((p) => ({
306
- key: p.key,
307
- filename: p.kind === "url" ? new URL(p.url).pathname || p.url : path.basename(p.file),
308
- count: p.comments.length + p.edits.length,
309
- }));
310
- }
311
- function sendBatch(sessionId, note, historyInput) {
312
- const session = sessions.get(sessionId);
313
- if (!session)
314
- return { error: "unknown session" };
315
- const pages = collectPages(session);
316
- if (!pages.length && !note)
317
- return { error: "nothing to send" };
318
- const historyContext = history.prepareBaseline(session, pages, historyInput);
319
- const hasMarkdown = pages.some((p) => p.kind === "file" && isMarkdown(p.file));
320
- const hasUrl = pages.some((p) => p.kind === "url");
321
- const hasTrustedEdits = pages.some((p) => p.edits.some((edit) => edit.feedback_only));
322
- const hasCorrections = pages.some((p) => p.comments.some((c) => c.correction));
323
- const hasTruncation = pages.some((p) => p.edits.some((e) => e.truncated));
324
- const id = `b_${crypto.randomBytes(12).toString("hex")}`;
325
- const entry = store.page(session.entryKey);
326
- const pollTarget = entry?.kind === "url" ? entry.url : entry?.file;
327
- const ackCommand = `${cliInvocation} poll ${shellQuote(pollTarget)} --ack ${id} --timeout 600`;
328
- const batch = {
329
- batch_id: id,
330
- status: "feedback",
331
- pages: pages.map(({ kind, file, url, comments, edits }) => ({ kind, file, ...(url ? { url } : {}), comments, edits })),
332
- overall_note: note || "",
333
- sent_at: new Date().toISOString(),
334
- next_step: "Apply this feedback. Each entry in `pages` names the reviewed file or localhost URL. Items under `edits` are " +
335
- "changes the human already made: unless marked `truncated`, `after` is their exact new wording, so carry it across verbatim, and " +
336
- "never revert it. When an edit carries `after_html`, the human changed formatting (bold, italic, links) — " +
337
- "use the HTML version, translated into the source's own syntax. " +
338
- (hasTruncation
339
- ? "Some edits are marked `truncated`; `truncated_fields` lists incomplete fields. Never apply incomplete text or HTML " +
340
- "as a complete replacement or invent missing content. Recover the full edit only from an authoritative source, " +
341
- "or ask the user for it. Do not acknowledge this batch until all feedback is handled. "
342
- : "") +
343
- (hasMarkdown
344
- ? "Markdown pages were reviewed rendered, so quotes and `after` wording use the rendered text — apply " +
345
- "the change to the Markdown source, keeping its formatting syntax. "
346
- : "") +
347
- (hasUrl
348
- ? "Localhost pages were edited directly in the review UI. Find the matching project source (such as MDX or TSX) " +
349
- "and apply every exact edit or deletion there; never try to write the rendered HTML response back to the app. " +
350
- "When an edit includes `staged_assets`, copy each local image into the app's appropriate asset folder, replace its " +
351
- "temporary preview URL in `after_html`, and preserve the image at the user's insertion point. "
352
- : "") +
353
- (hasTrustedEdits
354
- ? "Edits marked `feedback_only` came from feedback-only documents. Apply that wording or formatting to the original source; " +
355
- "it has not been autosaved. Never replace the source with script-generated runtime markup. " +
356
- "Copy any `staged_assets` into the document's asset folder and replace their temporary references. "
357
- : "") +
358
- (hasCorrections
359
- ? "Comments marked `correction` replace their `correction_of` instruction; follow the correction and do not apply the older wording. "
360
- : "") +
361
- `When every page is updated, acknowledge only this batch and wait for more by running: ${ackCommand}`,
362
- };
363
- const record = {
364
- batch,
365
- cleanup: pages.map((p) => ({
366
- key: p.key,
367
- ids: p.comments.map((c) => c.id),
368
- staged: p.edits.flatMap((edit) => (edit.staged_assets || []).map((asset) => asset.path)),
369
- sentAt: Date.now(),
370
- })),
371
- };
372
- store.setBatch(session.entryKey, { ...record, ...(historyContext ? { history: historyContext } : {}) });
373
- deliver(session.entryKey);
374
- broadcastAgent(session.entryKey);
375
- const round = historyContext
376
- ? store.listHistory(session.entryKey).find((item) => item.batchId === id)
377
- : null;
378
- if (round)
379
- history.changed(session.entryKey, round.roundId);
380
- return {
381
- ok: true,
382
- ...(round ? { roundId: round.roundId } : {}),
383
- ...(historyContext?.unavailable.length ? { historyUnavailable: historyContext.unavailable } : {}),
384
- };
385
- }
386
- function deleteStagedAsset(file) {
387
- const stagedRoot = path.join(stateDir(), "pasted");
388
- const resolved = path.resolve(file);
389
- const relative = path.relative(stagedRoot, resolved);
390
- if (relative.startsWith("..") || path.isAbsolute(relative))
391
- return;
392
- try {
393
- fs.unlinkSync(resolved);
394
- }
395
- catch (err) {
396
- if (err.code !== "ENOENT")
397
- console.error(`Could not remove acknowledged staged asset ${resolved}: ${err.message}`);
398
- }
399
- try {
400
- fs.rmdirSync(path.dirname(resolved));
401
- }
402
- catch (err) {
403
- if (err.code !== "ENOENT" && err.code !== "ENOTEMPTY") {
404
- console.error(`Could not remove acknowledged staged asset directory ${path.dirname(resolved)}: ${err.message}`);
405
- }
406
- }
407
- }
408
- function ack(entryKey, id) {
409
- const result = store.acknowledgeBatch(entryKey, id);
410
- if (!result.acknowledged)
411
- return false;
412
- if (result.roundId)
413
- history.captureSources(entryKey, result.roundId);
414
- const acknowledgedRound = result.roundId ? store.getRound(entryKey, result.roundId) : null;
415
- // The JSON transition is already durable. Files are cleanup only and must
416
- // never disappear before the receipt and page cleanup commit succeeds.
417
- for (const file of result.staged)
418
- deleteStagedAsset(file);
419
- for (const session of sessionsForEntry(entryKey))
420
- emit(session, "refresh", {});
421
- // File targets reload through fs.watch. URL targets have no source file to
422
- // watch, so acknowledgement is the signal to fetch the rebuilt route.
423
- for (const key of new Set([...result.keys, ...(acknowledgedRound?.targets.map((target) => target.key) || [])])) {
424
- if (store.page(key)?.kind === "url") {
425
- for (const session of sessionsForKey(key)) {
426
- invalidateSessionRender(session);
427
- emit(session, "reload", { key });
428
- }
429
- }
430
- }
431
- broadcastAgent(entryKey);
432
- history.changed(entryKey, result.roundId);
433
- return true;
434
- }
435
- /**
436
- * A deliberate stop, not a tab close: the browser forgets the session and
437
- * any waiting agent is released with a clear "stop polling" answer instead
438
- * of being left to burn its timeout. Unsent feedback stays in the store.
439
- */
440
- function endSession(session) {
441
- invalidateSessionRender(session);
442
- sessions.delete(session.id);
443
- for (const res of session.clients) {
444
- res.write(`event: ended\ndata: {}\n\n`);
445
- res.end();
446
- }
447
- session.clients.clear();
448
- // Another window on the same target keeps its agent connection alive.
449
- if (sessionsForEntry(session.entryKey).length > 0)
450
- return;
451
- const set = pollers.get(session.entryKey);
452
- if (!set)
453
- return;
454
- for (const poller of [...set]) {
455
- clearInterval(poller.timer);
456
- set.delete(poller);
457
- poller.res.end(JSON.stringify({
458
- status: "closed",
459
- next_step: "The user ended this review session. Stop polling — do not run the poll command again. " +
460
- "Any unsent feedback is kept and will ship the next time this target is reviewed.",
461
- }));
462
- }
463
- }
464
217
  // ----------------------------------------------------------------- routes
465
218
  function readBody(req) {
466
219
  return new Promise((resolve, reject) => {
@@ -534,10 +287,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
534
287
  const page = store.page(key);
535
288
  if (!page)
536
289
  return null;
537
- // The entry target is what the agent polls, even after navigating elsewhere.
538
- const entry = session ? store.page(session.entryKey) : null;
539
290
  const currentTarget = page.kind === "url" ? page.url : page.file;
540
- const pollTarget = entry ? (entry.kind === "url" ? entry.url : entry.file) : currentTarget;
541
291
  const policy = sourcePolicy(page, undefined, session);
542
292
  return {
543
293
  key: page.key,
@@ -549,10 +299,12 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
549
299
  ...policy,
550
300
  ...(page.kind !== "url" && !isMarkdown(page.file)
551
301
  ? { executionPreference: session?.executionPreferences?.get(key) || "auto" } : {}),
552
- comments: page.comments,
553
- edits: page.edits,
302
+ comments: [],
303
+ edits: [],
554
304
  canRevert: policy.savePolicy === "writable" && typeof page.pristine === "string" && page.pristine.length > 0,
555
- pollCommand: `${cliInvocation} poll ${shellQuote(pollTarget)}`,
305
+ pollCommand: session?.reviewId
306
+ ? agentHandoff({ reviewId: session.reviewId, entryKey: session.entryKey }, null, cliInvocation).pollCommand
307
+ : "",
556
308
  historySupported: true,
557
309
  };
558
310
  }
@@ -561,59 +313,13 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
561
313
  const source = page.kind === "url" || markdown ? undefined : bytes ?? fs.readFileSync(page.file);
562
314
  return documentExecutionPolicy({ ...page, markdown }, source, session?.executionPreferences?.get(page.key) || "auto");
563
315
  }
564
- function feedbackOnly(key, requestedPolicy, identity) {
565
- if (requestedPolicy === "feedback-only" || sourcePolicy(store.page(key)).feedbackOnly)
566
- return true;
567
- if (identity?.renderId != null) {
568
- const render = typeof identity.renderId === "string" ? currentRender(identity.renderId) : null;
569
- // Retired frames cannot prove their edits match writable source. Preserve
570
- // their feedback and pasted images without retaining retired capabilities.
571
- return !render || render.documentState !== "served" || render.pageKey !== key ||
572
- render.sessionId !== identity.sessionId || render.generation !== identity.generation ||
573
- render.savePolicy !== "writable";
574
- }
575
- return false;
576
- }
577
- function enforceFileWrite(key, body) {
578
- if (!body || typeof body !== "object" || Array.isArray(body) ||
579
- typeof body.baseHash !== "string" || !/^[a-f0-9]{40}$/.test(body.baseHash) ||
580
- typeof body.sessionId !== "string" || !body.sessionId ||
581
- typeof body.renderId !== "string" || !body.renderId ||
582
- !Number.isSafeInteger(body.generation) || body.generation < 1) {
583
- throw new HistoryRequestError("A source hash and complete frame identity are required.", {
584
- status: 400, code: "invalid_file_write",
585
- });
586
- }
587
- const render = currentRender(body.renderId);
588
- if (!render || render.pageKey !== key || render.sessionId !== body.sessionId ||
589
- render.generation !== body.generation || render.documentState !== "served") {
590
- throw new HistoryRequestError("Reload the document before writing; this request has no current frame.", {
591
- status: 409, code: "stale_file_write",
592
- });
593
- }
594
- const page = store.page(key);
595
- const current = fs.readFileSync(page.file);
596
- if (render.savePolicy !== "writable" || sourcePolicy(page, current).savePolicy !== "writable") {
597
- throw new HistoryRequestError("This document is feedback-only. Apply edits to the original source through the agent.", {
598
- status: 409, code: "file_feedback_only",
599
- });
600
- }
601
- if (hash(stripSdk(current.toString("utf8"))) !== body.baseHash || render.sourceHash !== body.baseHash) {
602
- throw new HistoryRequestError("The file changed on disk since this edit began.", { status: 409, code: "stale_file_write" });
603
- }
604
- return render;
605
- }
606
- async function readFileWriteBody(req) {
607
- try {
608
- return await readBody(req);
609
- }
610
- catch {
611
- throw new HistoryRequestError("Invalid file write request.", { status: 400, code: "invalid_file_write" });
612
- }
613
- }
614
- const history = createHistoryController({ store, sessions, currentRender, readBody, json, emit, pageState });
615
- for (const entryKey of Object.keys(store.data.histories || {}))
616
- history.captureSources(entryKey);
316
+ const history = createConversationCapture({ store, currentRender });
317
+ const conversations = createConversationController({
318
+ store, sessions, watchPage, json, emit, currentRender, captureObservation: history.captureObservation, cliInvocation,
319
+ sourceWritten(key) {
320
+ lastWritten.set(key, store.data.conversations.writes[key]?.hash ?? null);
321
+ },
322
+ });
617
323
  const server = http.createServer(async (req, res) => {
618
324
  touch();
619
325
  const url = new URL(req.url, "http://127.0.0.1");
@@ -640,10 +346,21 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
640
346
  const expected = Buffer.from(token);
641
347
  const ok = provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
642
348
  if (!ok)
643
- return json(res, 401, { error: "missing or invalid token" });
349
+ return json(res, 401, route.startsWith("/api/conversation")
350
+ ? conversationFailure(new ContractError("UNAUTHORIZED", "Missing or invalid token."))
351
+ : { error: "missing or invalid token" });
644
352
  }
645
- if (await history.handle(req, res, url))
353
+ if (await conversations.handle(req, res, url))
646
354
  return undefined;
355
+ if (route === "/api/session" || route === "/api/poll" || route === "/api/status" ||
356
+ /^\/api\/page\/[^/]+\/(comment|edit|asset|save|revert|send)(\/|$)/.test(route) ||
357
+ /^\/api\/session\/[^/]+\/(end|navigate|history)(\/|$)/.test(route)) {
358
+ return json(res, 410, conversationFailure(new ContractError("WORKFLOW_REMOVED", "This workflow has been removed. Open a durable /r/ review with the current doc-review CLI; use /api/conversation and a complete response, never acknowledgement.")));
359
+ }
360
+ if (route.startsWith("/s/")) {
361
+ res.writeHead(410, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
362
+ return res.end("This temporary session link is obsolete. Open the target again with the current doc-review CLI.");
363
+ }
647
364
  // --- static chrome assets
648
365
  if (route === "/chrome.css")
649
366
  return serveFile(res, path.join(here, "ui", "chrome.css"));
@@ -689,6 +406,11 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
689
406
  return serveFile(res, path.join(here, "serialize.js"), opaqueModuleCors(req));
690
407
  if (route === "/frame-channel.js")
691
408
  return serveFile(res, path.join(here, "frame-channel.js"), opaqueModuleCors(req));
409
+ if (route === "/thread-anchor-controller.js")
410
+ return serveFile(res, path.join(here, "thread-anchor-controller.js"), opaqueModuleCors(req));
411
+ if (["/contracts/frame.js", "/contracts/feedback.js", "/contracts/validation.js"].includes(route)) {
412
+ return serveFile(res, path.join(here, ...route.slice(1).split("/")), opaqueModuleCors(req));
413
+ }
692
414
  if (route === "/semantic-snapshot.js")
693
415
  return serveFile(res, path.join(here, "semantic-snapshot.js"), opaqueModuleCors(req));
694
416
  if (route === "/revision-schema.js")
@@ -741,41 +463,15 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
741
463
  }
742
464
  return json(res, 200, { ok: true, page: pageState(page.key, session), reloadRequired });
743
465
  }
744
- // --- open a browser session for a file or localhost URL
745
- if (route === "/api/session" && req.method === "POST") {
746
- const body = await readBody(req);
747
- const target = canonicalTarget(body.target || body.file || "");
748
- let page;
749
- if (target.kind === "url") {
750
- // Fail during open with a useful message rather than opening a blank review.
751
- await fetchLocalPage(target.value);
752
- page = store.openUrl(target.value);
753
- }
754
- else {
755
- if (!fs.existsSync(target.value))
756
- return json(res, 404, { error: `File not found: ${target.value}` });
757
- const html = fs.readFileSync(target.value, "utf8");
758
- page = store.openPage(target.value, stripSdk(html));
759
- lastWritten.set(page.key, hash(stripSdk(html)));
760
- }
761
- watchPage(page.key);
762
- const id = uid("s");
763
- sessions.set(id, {
764
- id,
765
- entryKey: page.key,
766
- activeKey: page.key,
767
- generation: 0,
768
- renderId: null,
769
- executionPreferences: new Map(),
770
- visited: new Set([page.key]),
771
- clients: new Set(),
772
- lastSeen: Date.now(),
773
- });
774
- return json(res, 200, { sessionId: id, key: page.key, path: `/s/${id}` });
775
- }
776
466
  // --- the chrome page
777
- if (route.startsWith("/s/")) {
778
- const id = route.slice(3);
467
+ if (route.startsWith("/r/")) {
468
+ let id = route.slice(3);
469
+ if (route.startsWith("/r/")) {
470
+ const record = Object.hasOwn(store.data.conversations.reviews, id) ? store.data.conversations.reviews[id] : null;
471
+ if (!record)
472
+ return json(res, 404, conversationFailure(new ContractError("NOT_FOUND", "Unknown durable review link.")));
473
+ id = conversations.attach({ reviewId: id, entryKey: record.review.entryKey }).sessionId;
474
+ }
779
475
  if (!sessions.has(id)) {
780
476
  res.writeHead(404, { "content-type": "text/plain" });
781
477
  return res.end("This review session has ended. Run doc-review <target> again.");
@@ -783,7 +479,8 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
783
479
  seen(sessions.get(id));
784
480
  const shell = fs.readFileSync(path.join(here, "chrome.html"), "utf8");
785
481
  res.writeHead(200, { "content-type": MIME[".html"], "cache-control": "no-store" });
786
- return res.end(shell.replace("__SESSION_ID__", id).replace("__TOKEN__", token));
482
+ const session = sessions.get(id);
483
+ return res.end(shell.replace("__SESSION_ID__", id).replace("__TOKEN__", token).replace("<body", session.reviewId ? `<body data-review="${encodeURIComponent(session.reviewId)}" data-entry="${encodeURIComponent(session.entryKey)}"` : "<body"));
787
484
  }
788
485
  const renderMatch = route.match(/^\/api\/session\/(\w+)\/render$/);
789
486
  if (renderMatch && req.method === "POST") {
@@ -952,7 +649,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
952
649
  return res.end("Forbidden");
953
650
  }
954
651
  // Keep staged previews reachable across source-policy changes.
955
- return serveFile(res, path.join(stateDir(), "pasted", render.pageKey, name));
652
+ return serveFile(res, path.join(conversationStagedRoot(render.pageKey), name));
956
653
  }
957
654
  const target = resolveAsset(page.file, asset.split("?")[0]);
958
655
  if (!target) {
@@ -961,36 +658,6 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
961
658
  }
962
659
  return serveFile(res, target);
963
660
  }
964
- // --- agent status probe: is feedback waiting? is anyone listening?
965
- if (route === "/api/status" && req.method === "GET") {
966
- const entryKey = targetKey(url.searchParams.get("target") || url.searchParams.get("file") || "");
967
- const pending = store.batch(entryKey);
968
- const listening = (pollers.get(entryKey) || new Set()).size > 0;
969
- // Unsent feedback lives on every page reachable from this entry.
970
- const keys = new Set([entryKey]);
971
- for (const session of sessions.values()) {
972
- if (session.entryKey !== entryKey)
973
- continue;
974
- for (const k of session.visited)
975
- keys.add(k);
976
- }
977
- let comments = 0;
978
- let edits = 0;
979
- for (const k of keys) {
980
- const page = store.page(k);
981
- if (!page)
982
- continue;
983
- comments += page.comments.length;
984
- edits += page.edits.length;
985
- }
986
- return json(res, 200, {
987
- status: pending ? "feedback-waiting" : "idle",
988
- feedback_waiting: !!pending,
989
- agent_listening: listening,
990
- server_running: true,
991
- unsent: { comments, edits },
992
- });
993
- }
994
661
  // --- page data
995
662
  const pageMatch = route.match(/^\/api\/page\/([a-f0-9]+)(?:\/(\w+))?(?:\/(.+))?$/);
996
663
  if (pageMatch) {
@@ -1003,7 +670,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1003
670
  seen(session);
1004
671
  const body = pageState(key, session);
1005
672
  if (session)
1006
- body.others = otherPages(session);
673
+ body.others = [];
1007
674
  return json(res, 200, body);
1008
675
  }
1009
676
  // The file as it sits on disk, so the SDK can tell whether the page's
@@ -1024,175 +691,6 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1024
691
  // version it was based on, or it loses to a concurrent rewrite.
1025
692
  return json(res, 200, { html: clean, hash: hash(clean) });
1026
693
  }
1027
- if (action === "comment" && req.method === "POST") {
1028
- const body = await readBody(req);
1029
- const kind = body.kind === "element" ? "element" : "selection";
1030
- const feedback = String(body.feedback || "").trim();
1031
- const comment = {
1032
- id: uid("c"),
1033
- kind,
1034
- quote: String(body.quote || ""),
1035
- anchor: normalizeCommentAnchor(kind, body.anchor || (kind === "selection" ? { quote: String(body.quote || "") } : null)),
1036
- feedback,
1037
- createdAt: Date.now(),
1038
- };
1039
- if (!comment.feedback)
1040
- return json(res, 400, { error: "empty feedback" });
1041
- if (!comment.anchor)
1042
- return json(res, 400, { error: "invalid comment anchor" });
1043
- store.addComment(key, comment);
1044
- return json(res, 200, { comment, page: pageState(key) });
1045
- }
1046
- if (action === "comment" && req.method === "DELETE") {
1047
- store.removeComment(key, tail);
1048
- return json(res, 200, { page: pageState(key) });
1049
- }
1050
- if (action === "comment" && req.method === "PATCH" && tail) {
1051
- const body = await readBody(req);
1052
- const feedback = String(body.feedback || "").trim();
1053
- if (!feedback)
1054
- return json(res, 400, { error: "empty feedback" });
1055
- const existing = store.page(key).comments.find((comment) => comment.id === tail);
1056
- if (!existing)
1057
- return json(res, 404, { error: "unknown comment" });
1058
- const revised = store.reviseComment(key, tail, feedback, { replacementId: uid("c") });
1059
- return json(res, 200, { delivery: revised.delivery, page: pageState(key) });
1060
- }
1061
- if (action === "edit" && req.method === "POST") {
1062
- const body = await readBody(req);
1063
- const label = String(body.label || "Document");
1064
- const kind = body.kind === "deleted" ? "deleted" : body.kind === "moved" ? "moved" : "edited";
1065
- const limited = limitEditFields({
1066
- before: body.before, after: body.after, before_html: body.before_html, after_html: body.after_html,
1067
- ...(kind === "moved" ? { moved_after: body.moved_after, moved_before: body.moved_before } : {}),
1068
- });
1069
- const fields = limited.fields;
1070
- const stagedRoot = path.join(stateDir(), "pasted", key);
1071
- const stagedAssets = Array.isArray(body.staged_assets)
1072
- ? body.staged_assets
1073
- .slice(0, 20)
1074
- .map((asset) => {
1075
- const id = String(asset?.id || "");
1076
- return {
1077
- id,
1078
- path: path.join(stagedRoot, id),
1079
- preview_src: String(asset?.preview_src || ""),
1080
- };
1081
- })
1082
- .filter((asset) => {
1083
- const relative = path.relative(stagedRoot, path.resolve(asset.path));
1084
- return asset.id && path.basename(asset.id) === asset.id && !relative.startsWith("..") && !path.isAbsolute(relative) && fs.existsSync(asset.path);
1085
- })
1086
- .map(({ path: assetPath, preview_src }) => ({ path: assetPath, preview_src }))
1087
- : [];
1088
- const extra = {
1089
- truncated: limited.truncated,
1090
- truncated_fields: limited.truncated_fields,
1091
- ...(kind === "moved" ? { moved_after: fields.moved_after || "", moved_before: fields.moved_before || "" } : {}),
1092
- ...(stagedAssets.length ? { staged_assets: stagedAssets } : {}),
1093
- ...(feedbackOnly(key, body.savePolicy, body) ? { feedback_only: true } : {}),
1094
- };
1095
- store.addEdit(key, label, kind, fields.before, fields.after, fields.before_html, fields.after_html, extra);
1096
- return json(res, 200, { page: pageState(key, sessions.get(body.sessionId)) });
1097
- }
1098
- // File reviews keep pasted images beside the document. Localhost
1099
- // reviews stage them privately until the agent moves them into source.
1100
- if (action === "asset" && req.method === "POST") {
1101
- const page = store.page(key);
1102
- const type = String(url.searchParams.get("type") || "");
1103
- const ext = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" }[type];
1104
- if (!ext)
1105
- return json(res, 400, { error: `unsupported image type: ${type || "unknown"}` });
1106
- const bytes = await readRawBody(req);
1107
- if (!bytes.length)
1108
- return json(res, 400, { error: "empty image" });
1109
- const staged = feedbackOnly(key, url.searchParams.get("savePolicy"), {
1110
- sessionId: url.searchParams.get("sessionId"),
1111
- renderId: url.searchParams.get("renderId"),
1112
- generation: Number(url.searchParams.get("generation")),
1113
- });
1114
- const dir = staged ? path.join(stateDir(), "pasted", key) : path.join(path.dirname(page.file), "assets");
1115
- fs.mkdirSync(dir, { recursive: true });
1116
- const base = staged
1117
- ? "localhost"
1118
- : path
1119
- .basename(page.file)
1120
- .replace(/\.[^.]+$/, "")
1121
- .replace(/[^\w-]+/g, "-");
1122
- let name = "";
1123
- for (let n = 1;; n += 1) {
1124
- name = `${base}-paste-${n}.${ext}`;
1125
- if (!fs.existsSync(path.join(dir, name)))
1126
- break;
1127
- }
1128
- const saved = path.join(dir, name);
1129
- fs.writeFileSync(saved, bytes);
1130
- return json(res, 200, {
1131
- src: staged ? `__doc_review_paste__/${name}` : `assets/${name}`,
1132
- ...(staged ? { stagedId: name } : {}),
1133
- });
1134
- }
1135
- if (action === "save" && req.method === "POST") {
1136
- const page = store.page(key);
1137
- // Rendered sources must never be overwritten with serialized browser HTML.
1138
- if (page.kind === "url" || isMarkdown(page.file)) {
1139
- return json(res, 400, { error: page.kind === "url" ? "localhost edits must be applied to app source" : "markdown pages are feedback-only" });
1140
- }
1141
- const body = await readFileWriteBody(req);
1142
- if (typeof body?.html !== "string" || !body.html.trim()) {
1143
- return json(res, 400, { error: "empty html" });
1144
- }
1145
- const savingRender = enforceFileWrite(key, body);
1146
- const editedPolicy = sourcePolicy(page, stripSdk(body.html));
1147
- try {
1148
- const clean = writePage(key, body.html);
1149
- savingRender.sourceHash = hash(clean);
1150
- savingRender.sourceCapturedAt = new Date().toISOString();
1151
- if (editedPolicy.savePolicy === "feedback-only") {
1152
- for (const session of sessionsForKey(key)) {
1153
- invalidateSessionRender(session);
1154
- emit(session, "reload", { key, reason: "source-execution-changed" });
1155
- }
1156
- }
1157
- return json(res, 200, { savedAt: Date.now(), hash: hash(clean) });
1158
- }
1159
- catch (err) {
1160
- return json(res, 500, { error: String(err.message || err) });
1161
- }
1162
- }
1163
- if (action === "revert" && req.method === "POST") {
1164
- const page = store.page(key);
1165
- if (page.kind === "url" || isMarkdown(page.file))
1166
- return json(res, 400, { error: "feedback-only pages have no directly writable HTML to revert" });
1167
- const body = await readFileWriteBody(req);
1168
- enforceFileWrite(key, body);
1169
- if (!page.pristine)
1170
- return json(res, 400, { error: "nothing to revert to" });
1171
- sourcePolicy(page, stripSdk(page.pristine));
1172
- writePage(key, page.pristine);
1173
- store.clearEdits(key);
1174
- for (const session of sessionsForKey(key)) {
1175
- invalidateSessionRender(session);
1176
- emit(session, "reload", { key });
1177
- }
1178
- return json(res, 200, { page: pageState(key, sessions.get(body.sessionId)) });
1179
- }
1180
- if (action === "send" && req.method === "POST") {
1181
- const body = await readBody(req);
1182
- const result = sendBatch(body.sessionId, body.note, body.history);
1183
- if (result.error)
1184
- return json(res, 400, result);
1185
- return json(res, 200, { ...result, page: pageState(key, sessions.get(body.sessionId)) });
1186
- }
1187
- }
1188
- // --- the user is done: stop the review, release the agent
1189
- const endMatch = route.match(/^\/api\/session\/(\w+)\/end$/);
1190
- if (endMatch && req.method === "POST") {
1191
- const session = sessions.get(endMatch[1]);
1192
- if (!session)
1193
- return json(res, 404, { error: "unknown session" });
1194
- endSession(session);
1195
- return json(res, 200, { ok: true });
1196
694
  }
1197
695
  // --- which page a window is currently showing
1198
696
  const bootMatch = route.match(/^\/api\/session\/(\w+)\/page$/);
@@ -1205,7 +703,10 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1205
703
  key: session.activeKey,
1206
704
  generation: session.generation,
1207
705
  page: pageState(session.activeKey, session),
1208
- others: otherPages(session),
706
+ others: [],
707
+ ...(session.reviewId ? { review: store.conversations.read({
708
+ operation: "read-review", reviewId: session.reviewId, entryKey: session.entryKey,
709
+ }) } : {}),
1209
710
  });
1210
711
  }
1211
712
  // --- jump straight to a page already in this window
@@ -1216,6 +717,9 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1216
717
  return json(res, 404, { error: "unknown session" });
1217
718
  seen(session);
1218
719
  const body = await readBody(req);
720
+ if (session.reviewId && !Object.hasOwn(store.data.conversations.reviews[session.reviewId].pages, body.key)) {
721
+ throw new ContractError("SCOPE_MISMATCH", "Join the page through the versioned review before navigating.");
722
+ }
1219
723
  if (!store.page(body.key))
1220
724
  return json(res, 404, { error: "unknown page" });
1221
725
  invalidateSessionRender(session);
@@ -1224,13 +728,19 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1224
728
  return json(res, 200, { key: body.key });
1225
729
  }
1226
730
  // --- navigation between local files or localhost routes in one window
1227
- const navMatch = route.match(/^\/api\/session\/(\w+)\/navigate$/);
731
+ const navMatch = route.match(/^\/api\/session\/(\w+)\/(navigate|resolve-target)$/);
1228
732
  if (navMatch && req.method === "POST") {
1229
733
  const session = sessions.get(navMatch[1]);
1230
734
  if (!session)
1231
735
  return json(res, 404, { error: "unknown session" });
736
+ const resolveOnly = navMatch[2] === "resolve-target";
737
+ if (session.reviewId && !resolveOnly)
738
+ throw new ContractError("INVALID_INPUT", "Use versioned join-page followed by goto.");
1232
739
  seen(session);
1233
740
  const body = await readBody(req);
741
+ if (resolveOnly && (typeof body.href !== "string" || Object.keys(body).some((key) => key !== "href"))) {
742
+ throw new ContractError("INVALID_INPUT", "Navigation resolution requires only href.");
743
+ }
1234
744
  const from = store.page(session.activeKey);
1235
745
  if (!from)
1236
746
  return json(res, 404, { error: "unknown page" });
@@ -1239,6 +749,8 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1239
749
  const target = canonicalTarget(nextUrl);
1240
750
  if (target.kind !== "url")
1241
751
  return json(res, 400, { error: "not a localhost route" });
752
+ if (resolveOnly)
753
+ return json(res, 200, { target: target.value });
1242
754
  await fetchLocalPage(target.value);
1243
755
  const page = store.openUrl(target.value);
1244
756
  invalidateSessionRender(session);
@@ -1250,6 +762,8 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1250
762
  if (!targetFile || !fs.existsSync(targetFile) || !/\.(x?html?|md|markdown)$/i.test(targetFile)) {
1251
763
  return json(res, 400, { error: "not a local html or markdown page" });
1252
764
  }
765
+ if (resolveOnly)
766
+ return json(res, 200, { target: targetFile });
1253
767
  const html = fs.readFileSync(targetFile, "utf8");
1254
768
  const page = store.openPage(targetFile, stripSdk(html));
1255
769
  lastWritten.set(page.key, hash(stripSdk(html)));
@@ -1274,7 +788,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1274
788
  res.write(": open\n\n");
1275
789
  session.clients.add(res);
1276
790
  seen(session);
1277
- emit(session, "agent", { state: agentState(session.entryKey) });
791
+ emit(session, "invalidate", { reviewId: session.reviewId });
1278
792
  const beat = setInterval(() => res.write(": beat\n\n"), POLL_HEARTBEAT_MS);
1279
793
  sseResponses.set(res, beat);
1280
794
  req.on("close", () => {
@@ -1285,40 +799,12 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1285
799
  });
1286
800
  return undefined;
1287
801
  }
1288
- // --- the agent long-poll
1289
- if (route === "/api/poll") {
1290
- const target = url.searchParams.get("target") || url.searchParams.get("file") || "";
1291
- const entryKey = targetKey(target);
1292
- const ackId = url.searchParams.get("ack");
1293
- if (ackId !== null)
1294
- ack(entryKey, ackId);
1295
- const pending = store.batch(entryKey);
1296
- if (pending) {
1297
- const delivered = store.markBatchDelivered(entryKey);
1298
- broadcastAgent(entryKey);
1299
- return json(res, 200, delivered.batch);
1300
- }
1301
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1302
- res.write(" ");
1303
- const set = pollers.get(entryKey) || new Set();
1304
- pollers.set(entryKey, set);
1305
- const poller = {
1306
- res,
1307
- timer: setInterval(() => res.write(" "), POLL_HEARTBEAT_MS),
1308
- };
1309
- set.add(poller);
1310
- broadcastAgent(entryKey);
1311
- req.on("close", () => {
1312
- clearInterval(poller.timer);
1313
- set.delete(poller);
1314
- broadcastAgent(entryKey);
1315
- });
1316
- return undefined;
1317
- }
1318
802
  res.writeHead(404, { "content-type": "text/plain" });
1319
803
  return res.end("Not found");
1320
804
  }
1321
805
  catch (err) {
806
+ if (err instanceof ContractError)
807
+ return json(res, err.status, conversationFailure(err));
1322
808
  if (err instanceof HistoryRequestError) {
1323
809
  return json(res, err.status, { error: err.message, code: err.code, targets: err.targets });
1324
810
  }
@@ -1376,7 +862,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1376
862
  }
1377
863
  // Busy means a connected browser or a listening agent — a session record
1378
864
  // alone must not keep the process alive forever.
1379
- const busy = [...sessions.values()].some((s) => s.clients.size > 0) || [...pollers.values()].some((s) => s.size > 0);
865
+ const busy = [...sessions.values()].some((s) => s.clients.size > 0);
1380
866
  if (!busy && now - lastActivity > IDLE_SHUTDOWN_MS) {
1381
867
  void dispose().catch((err) => {
1382
868
  console.error(`doc-review server shutdown failed: ${err.message}`);
@@ -1395,14 +881,6 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1395
881
  fs.unwatchFile(entry.file);
1396
882
  watched.clear();
1397
883
  lastWritten.clear();
1398
- for (const set of pollers.values()) {
1399
- for (const poller of set) {
1400
- clearInterval(poller.timer);
1401
- if (!poller.res.writableEnded)
1402
- poller.res.end();
1403
- }
1404
- }
1405
- pollers.clear();
1406
884
  for (const [res, timer] of sseResponses) {
1407
885
  clearInterval(timer);
1408
886
  if (!res.writableEnded)