dsh-diff-approval 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -896,7 +896,7 @@ function touchedBy(entry) {
896
896
  function sameEntry(left, right) {
897
897
  const lIds = touchedBy(left);
898
898
  const rIds = touchedBy(right);
899
- return left.path === right.path && left.kind === right.kind && left.oldText === right.oldText && left.newText === right.newText && left.updatedAt === right.updatedAt && left.sessionId === right.sessionId && lIds.length === rIds.length && lIds.every((id, index) => id === rIds[index]);
899
+ return left.path === right.path && left.earlierVersion === right.earlierVersion && left.oldText === right.oldText && left.newText === right.newText && left.updatedAt === right.updatedAt && left.sessionId === right.sessionId && lIds.length === rIds.length && lIds.every((id, index) => id === rIds[index]);
900
900
  }
901
901
  /**
902
902
  * The pending-diff store: one entry per file path across all sessions. Keep /
@@ -951,21 +951,24 @@ var PendingDiffStore = class {
951
951
  return true;
952
952
  }
953
953
  /**
954
- * Combine a stored entry and a new capture. `oldText` comes from the earlier
955
- * capture (the file's original basis), `newText` from the later (latest
956
- * content), and the session set is the union — so the entry always spans the
957
- * whole pending change regardless of which session contributed which part.
954
+ * Combine a stored entry and a new capture. `oldText` comes from the earlier capture (the file's
955
+ * original basis) — except where that capture was the file's creation and this one edits it, when
956
+ * the creation's own content becomes the basis — and `newText` from the later (latest content).
957
+ * The session set is the union, so the entry always spans the whole pending change regardless of
958
+ * which session contributed which part.
958
959
  */
959
960
  mergeEntry(a, b) {
960
961
  const earlier = a.updatedAt <= b.updatedAt ? a : b;
961
962
  const later = earlier === a ? b : a;
962
963
  const sessionIds = [.../* @__PURE__ */ new Set([...touchedBy(a), b.sessionId])];
964
+ const onlyCreations = earlier.earlierVersion === "none" && later.earlierVersion === "none";
963
965
  return {
964
966
  ...later,
965
967
  id: later.path,
966
- oldText: earlier.oldText,
967
- kind: earlier.kind === "create" || later.kind === "create" ? "create" : "edit",
968
- sessionIds
968
+ oldText: onlyCreations ? "" : earlier.earlierVersion === "none" ? earlier.newText : earlier.oldText,
969
+ earlierVersion: onlyCreations ? "none" : "file",
970
+ sessionIds,
971
+ unseen: earlier.unseen === true || later.unseen === true
969
972
  };
970
973
  }
971
974
  /**
@@ -1006,6 +1009,15 @@ var PendingDiffStore = class {
1006
1009
  /**
1007
1010
  * Advance one entry's tracked content after a block-level keep/revert. The
1008
1011
  * entry keeps its path; only the given side's text and capture time move.
1012
+ *
1013
+ * A keep that advances `oldText` also ends the entry's life as a creation. `earlierVersion` is the fact
1014
+ * "this file did not exist before", and once part of the file has been accepted there IS an earlier
1015
+ * version to restore — what the reader kept. The whole-file action must therefore become 回退, not 删除:
1016
+ * deleting would throw away the part they just accepted. (Leaving the field alone kept a partly-kept new
1017
+ * file offering to delete itself, which is what sent the reader here.)
1018
+ *
1019
+ * Every caller of this is a reader action — a block keep or revert, a refresh from version control — so it
1020
+ * also takes the unseen dot down: the reader has just handled this file.
1009
1021
  * @param path - the file path.
1010
1022
  * @param patch - the side to advance (`oldText` for keep, `newText` for revert).
1011
1023
  * @returns whether the entry changed.
@@ -1013,9 +1025,12 @@ var PendingDiffStore = class {
1013
1025
  update(path, patch) {
1014
1026
  const entry = this.entries.get(pathKeyOf(path));
1015
1027
  if (entry === void 0) return false;
1028
+ const kept = patch.oldText !== void 0;
1016
1029
  const next = {
1017
1030
  ...entry,
1018
1031
  ...patch,
1032
+ ...kept ? { earlierVersion: "file" } : {},
1033
+ unseen: false,
1019
1034
  updatedAt: Date.now()
1020
1035
  };
1021
1036
  this.entries.set(pathKeyOf(path), next);
@@ -1023,6 +1038,50 @@ var PendingDiffStore = class {
1023
1038
  return true;
1024
1039
  }
1025
1040
  /**
1041
+ * Mark paths the reader has now seen — the file was opened, or its row was acted on. The dot is about
1042
+ * attention rather than content, so this and the entry leaving the list are the only ways it goes out, and
1043
+ * nothing brings it back except a new capture.
1044
+ * @param paths - one path, or several.
1045
+ * @returns whether anything changed.
1046
+ */
1047
+ markSeen(paths) {
1048
+ const list = typeof paths === "string" ? [paths] : paths;
1049
+ let changed = false;
1050
+ for (const path of list) {
1051
+ const key = pathKeyOf(path);
1052
+ const entry = this.entries.get(key);
1053
+ if (entry === void 0 || entry.unseen !== true) continue;
1054
+ this.entries.set(key, {
1055
+ ...entry,
1056
+ unseen: false
1057
+ });
1058
+ changed = true;
1059
+ }
1060
+ return changed;
1061
+ }
1062
+ /**
1063
+ * Raise the dot for paths whose content moved WITHOUT the reader asking — a writer outside the panel, a
1064
+ * second process. The capture sites set the field on the entry they fold; this is for the changes the
1065
+ * plugin notices by itself while refreshing, which do not come through a capture.
1066
+ * @param paths - one path, or several.
1067
+ * @returns whether anything changed.
1068
+ */
1069
+ markUnseen(paths) {
1070
+ const list = typeof paths === "string" ? [paths] : paths;
1071
+ let changed = false;
1072
+ for (const path of list) {
1073
+ const key = pathKeyOf(path);
1074
+ const entry = this.entries.get(key);
1075
+ if (entry === void 0 || entry.unseen === true) continue;
1076
+ this.entries.set(key, {
1077
+ ...entry,
1078
+ unseen: true
1079
+ });
1080
+ changed = true;
1081
+ }
1082
+ return changed;
1083
+ }
1084
+ /**
1026
1085
  * Restore one entry exactly as given (an undo/redo replays a snapshot). The
1027
1086
  * entry is inserted or replaced by path.
1028
1087
  * @param entry - the entry state to restore.
@@ -1032,7 +1091,10 @@ var PendingDiffStore = class {
1032
1091
  const key = pathKeyOf(entry.path);
1033
1092
  const existing = this.entries.get(key);
1034
1093
  if (existing !== void 0 && sameEntry(existing, entry)) return false;
1035
- this.entries.set(key, { ...entry });
1094
+ this.entries.set(key, {
1095
+ ...entry,
1096
+ unseen: existing?.unseen === true
1097
+ });
1036
1098
  if (existing === void 0 || existing.newText !== entry.newText) this.bumpContent(entry.path);
1037
1099
  return true;
1038
1100
  }
@@ -1173,13 +1235,43 @@ function defaultStorageDir() {
1173
1235
  const FILE_VERSION = 3;
1174
1236
  /** Legacy per-workspace-file version, accepted only for migration. */
1175
1237
  const LEGACY_FILE_VERSION = 2;
1238
+ /** The pre-rename spelling of the entry's `earlierVersion`, or undefined for anything else. */
1239
+ function legacyEarlierVersion(kind) {
1240
+ if (kind === "edit") return "file";
1241
+ if (kind === "create") return "none";
1242
+ }
1243
+ /**
1244
+ * The lineage one persisted row carries, or `undefined` when it carries none worth keeping.
1245
+ *
1246
+ * Every part is read independently and dropped when it is not the shape this record writes: a row from
1247
+ * an older build has no `lineage` key at all (that is the `undefined` value here, not an error), and a
1248
+ * hand-edited or truncated one may carry anything. A row whose parts are all missing is stored WITHOUT
1249
+ * a lineage rather than as an empty object, so "not known" has exactly one spelling on disk.
1250
+ * @param value - the row's `lineage` field, as parsed JSON.
1251
+ * @returns the lineage to keep, or `undefined`.
1252
+ */
1253
+ function lineageOfRow(value) {
1254
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
1255
+ const record = value;
1256
+ const parent = record.parentSessionId;
1257
+ const depth = record.delegationDepth;
1258
+ const lineage = {
1259
+ parentSessionId: typeof parent === "string" && parent.length > 0 ? parent : void 0,
1260
+ origin: typeof record.origin === "string" && record.origin.length > 0 ? record.origin : void 0,
1261
+ delegationDepth: typeof depth === "number" && Number.isFinite(depth) ? depth : void 0,
1262
+ cwd: typeof record.cwd === "string" && record.cwd.length > 0 ? record.cwd : void 0
1263
+ };
1264
+ return lineage.parentSessionId !== void 0 || lineage.origin !== void 0 || lineage.delegationDepth !== void 0 || lineage.cwd !== void 0 ? lineage : void 0;
1265
+ }
1176
1266
  /** Narrow one JSON value to a pending entry; malformed rows are skipped. */
1177
1267
  function pendingEntryOf(value) {
1178
1268
  if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
1179
- const { id, path, kind, oldText, newText, updatedAt, sessionId, sessionIds } = value;
1269
+ const record = value;
1270
+ const { id, path, oldText, newText, updatedAt, sessionId, sessionIds } = record;
1271
+ const earlierVersion = record.earlierVersion ?? legacyEarlierVersion(record.kind);
1180
1272
  if (typeof id !== "string" || id.length === 0) return void 0;
1181
1273
  if (typeof path !== "string" || path.length === 0) return void 0;
1182
- if (kind !== "edit" && kind !== "create") return void 0;
1274
+ if (earlierVersion !== "file" && earlierVersion !== "none") return void 0;
1183
1275
  if (typeof oldText !== "string" || typeof newText !== "string") return void 0;
1184
1276
  if (typeof updatedAt !== "number") return void 0;
1185
1277
  if (typeof sessionId !== "string" || sessionId.length === 0) return void 0;
@@ -1187,12 +1279,14 @@ function pendingEntryOf(value) {
1187
1279
  return {
1188
1280
  id: path,
1189
1281
  path,
1190
- kind,
1282
+ earlierVersion,
1191
1283
  oldText,
1192
1284
  newText,
1193
1285
  updatedAt,
1286
+ unseen: record.unseen === true ? true : void 0,
1194
1287
  sessionId,
1195
- sessionIds: ids.length > 0 ? ids : [sessionId]
1288
+ sessionIds: ids.length > 0 ? ids : [sessionId],
1289
+ lineage: lineageOfRow(record.lineage)
1196
1290
  };
1197
1291
  }
1198
1292
  /** Validate a parsed global file. */
@@ -1371,6 +1465,19 @@ function commentsDirFor(storageRoot) {
1371
1465
  function fileOf(root, sessionId) {
1372
1466
  return join(root, `${encodeURIComponent(String(sessionId))}.json`);
1373
1467
  }
1468
+ /**
1469
+ * One answer map as a comment record keeps it: keys that name a question, values that are the text
1470
+ * read for it. Narrowed here like every other stored field, so a file that has drifted (or been
1471
+ * hand-edited) cannot put a non-string where an answer's text is expected.
1472
+ * @param value - the stored value.
1473
+ * @returns the map, or undefined when there is nothing usable in it.
1474
+ */
1475
+ function answersOf(value) {
1476
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
1477
+ const map = {};
1478
+ for (const [requestId, text] of Object.entries(value)) if (typeof text === "string") map[requestId] = text;
1479
+ return Object.keys(map).length === 0 ? void 0 : map;
1480
+ }
1374
1481
  /** Narrow one JSON value to a comment record; malformed rows are skipped. */
1375
1482
  function commentOf(value) {
1376
1483
  if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
@@ -1389,6 +1496,10 @@ function commentOf(value) {
1389
1496
  const context = row.quoteContext;
1390
1497
  const quoteLines = Array.isArray(row.quoteLines) ? row.quoteLines.filter((entry) => typeof entry === "object" && entry !== null) : [];
1391
1498
  const author = row.author === "agent" ? "agent" : void 0;
1499
+ const category = typeof row.category === "string" ? row.category.trim() : "";
1500
+ const asks = asksOf(row.asks);
1501
+ const answerSeen = answersOf(row.answerSeen);
1502
+ const answerNow = answersOf(row.answerNow);
1392
1503
  return {
1393
1504
  id,
1394
1505
  sessionId,
@@ -1402,10 +1513,14 @@ function commentOf(value) {
1402
1513
  text,
1403
1514
  createdAt,
1404
1515
  updatedAt,
1516
+ ...row.unseen === true ? { unseen: true } : {},
1405
1517
  ...author === void 0 ? {} : { author },
1518
+ ...category === "" ? {} : { category },
1406
1519
  ...typeof context === "string" && context !== "" ? { quoteContext: context } : {},
1407
1520
  ...quoteLines.length > 0 ? { quoteLines } : {},
1408
- ...asksOf(row.asks).length > 0 ? { asks: asksOf(row.asks) } : {}
1521
+ ...asks.length > 0 ? { asks } : {},
1522
+ ...answerSeen === void 0 ? {} : { answerSeen },
1523
+ ...answerNow === void 0 ? {} : { answerNow }
1409
1524
  };
1410
1525
  }
1411
1526
  /** Narrow a stored thread's questions; malformed rows are skipped. */
@@ -1419,8 +1534,10 @@ function asksOf(value) {
1419
1534
  if (typeof requestId !== "string" || requestId.length === 0) continue;
1420
1535
  const turn = record.turn;
1421
1536
  const text = record.text;
1537
+ const sessionId = record.sessionId;
1422
1538
  asks.push({
1423
1539
  requestId,
1540
+ ...typeof sessionId === "string" && sessionId !== "" ? { sessionId } : {},
1424
1541
  ...typeof text === "string" && text !== "" ? { text } : {},
1425
1542
  ...typeof turn === "number" ? { turn } : {},
1426
1543
  ...record.dropped === true ? { dropped: true } : {},
@@ -1433,6 +1550,98 @@ function asksOf(value) {
1433
1550
  function hasAsk(comment, requestId, wanted) {
1434
1551
  return (comment.asks ?? []).some((ask) => ask.requestId === requestId && wanted(ask));
1435
1552
  }
1553
+ /**
1554
+ * The transcript one question's answer will be written in.
1555
+ *
1556
+ * `ask.sessionId` when the question recorded one — every question asked since a lineage could read more
1557
+ * than one seat's threads does — otherwise the comment's own session, which is where a question asked
1558
+ * before that field existed necessarily went: the only session that could ask it was the one that wrote
1559
+ * the thread. ONE definition, shared by the answer fold, the turn-end match and the asker's grouping,
1560
+ * so the three cannot disagree about which transcript a question belongs to.
1561
+ * @param ask - the question.
1562
+ * @param comment - the thread it was asked in.
1563
+ * @returns the session whose transcript holds its answer.
1564
+ */
1565
+ function askTranscript(ask, comment) {
1566
+ return ask.sessionId ?? comment.sessionId;
1567
+ }
1568
+ /** Whether two answer maps say the same thing, key for key and text for text. */
1569
+ function sameAnswers(left, right) {
1570
+ const one = Object.entries(left ?? {});
1571
+ const two = right ?? {};
1572
+ if (one.length !== Object.keys(two).length) return false;
1573
+ return one.every(([requestId, text]) => two[requestId] === text);
1574
+ }
1575
+ /**
1576
+ * What ONE transcript currently says about the questions it owns, and which questions those are.
1577
+ *
1578
+ * Only the questions asked in that transcript are taken: a read is one session's, and a thread that
1579
+ * stored every other transcript's answers would be a copy of the conversation rather than its own
1580
+ * state. A question the read found no answer for is left out rather than stored empty, so a turn that
1581
+ * has not written yet cannot look like an answer that changed.
1582
+ * @param comment - the thread, for the questions it asked.
1583
+ * @param sessionId - the transcript being read.
1584
+ * @param answers - that transcript's answer text per question id.
1585
+ * @returns the thread's own question ids there, and the answers it holds for them.
1586
+ */
1587
+ function answersInTranscript(comment, sessionId, answers) {
1588
+ const own = [];
1589
+ const now = {};
1590
+ for (const ask of comment.asks ?? []) {
1591
+ if (askTranscript(ask, comment) !== sessionId) continue;
1592
+ own.push(ask.requestId);
1593
+ const text = answers[ask.requestId]?.answer;
1594
+ if (text !== void 0) now[ask.requestId] = text;
1595
+ }
1596
+ return {
1597
+ own,
1598
+ now
1599
+ };
1600
+ }
1601
+ /**
1602
+ * One thread as a read of ONE transcript leaves it: its questions' current answers, and the dot up if
1603
+ * those say something the reader has not been told about.
1604
+ *
1605
+ * "New" is a DIFFERENCE, not a presence: the transcript answers a question once and hands back the
1606
+ * same text on every read afterwards, so "is there an answer" would leave the dot up forever, and
1607
+ * "did it change since the last read" would lose the answer entirely on the restart that forgot the
1608
+ * previous read. The comparison is against `answerSeen` — the baseline the reader's own visit writes
1609
+ * down — and it is STICKY: an answer that arrived while the dot was already up does not need to raise
1610
+ * it again. A question the transcript shows no answer for is absent here, so a log this process cannot
1611
+ * read, or a turn that has not written yet, leaves the thread exactly as it was.
1612
+ *
1613
+ * A thread may hold questions asked in MORE THAN ONE transcript (one card, two seats, each asking from
1614
+ * its own conversation). This fold owns only its own transcript's entries: it updates those, clears the
1615
+ * ones that transcript no longer shows an answer for, and leaves every other group exactly as that
1616
+ * group's own fold left it. Replacing the map wholesale would erase a second transcript's answers on
1617
+ * the next poll — the reader would watch an answer appear and then vanish.
1618
+ * @param comment - the thread as it stands.
1619
+ * @param sessionId - the transcript being read.
1620
+ * @param answers - that transcript's answer text per question id.
1621
+ * @returns what the record should hold after this read.
1622
+ */
1623
+ function answerStateOf(comment, sessionId, answers) {
1624
+ const { own, now } = answersInTranscript(comment, sessionId, answers);
1625
+ const answerNow = { ...comment.answerNow ?? {} };
1626
+ for (const requestId of own) {
1627
+ const text = now[requestId];
1628
+ if (text === void 0) delete answerNow[requestId];
1629
+ else answerNow[requestId] = text;
1630
+ }
1631
+ const seen = comment.answerSeen ?? {};
1632
+ const lit = comment.unseen === true || Object.entries(answerNow).some(([requestId, text]) => seen[requestId] !== text);
1633
+ const next = {
1634
+ ...comment,
1635
+ answerNow,
1636
+ unseen: lit
1637
+ };
1638
+ if (!lit) delete next.unseen;
1639
+ return next;
1640
+ }
1641
+ /** Whether a patch would leave a comment exactly as it stands (the check that keeps a read from saving). */
1642
+ function sameRecord(before, after) {
1643
+ return before.unseen === after.unseen && sameAnswers(before.answerNow, after.answerNow);
1644
+ }
1436
1645
  /** Read one file's JSON; `undefined` when absent (the normal empty state). */
1437
1646
  async function readJson(file) {
1438
1647
  let raw;
@@ -1593,8 +1802,24 @@ var CommentStore = class {
1593
1802
  }
1594
1803
  this.options.onLoadSkipped?.(file, error);
1595
1804
  }
1596
- /** One session's comments, oldest first (the order threads are read in). */
1597
- list(sessionId) {
1805
+ /**
1806
+ * The comments one SCOPE may read, oldest first (the order threads are read in).
1807
+ *
1808
+ * The scope is the caller's lineage rule (`CommentScope`), so a seat reads every thread of the seats
1809
+ * it shares a lineage root with — not only the ones it wrote. The files do not move for that: each
1810
+ * comment still lives in its author's file (see `save`), and the fan-out happens here, in memory,
1811
+ * over the one map `loadAll` filled from every file. That is what keeps a teammate's already-written
1812
+ * threads visible, with no migration.
1813
+ * @param scope - which comment authors this caller may read.
1814
+ * @returns the comments those authors wrote, oldest first.
1815
+ */
1816
+ list(scope) {
1817
+ const listed = [];
1818
+ for (const comment of this.byId.values()) if (scope(comment.sessionId)) listed.push(comment);
1819
+ return listed.sort((left, right) => left.createdAt - right.createdAt);
1820
+ }
1821
+ /** One author's own comments, oldest first: the WRITE-SET of that author's file (see `save`). */
1822
+ authoredBy(sessionId) {
1598
1823
  const listed = [];
1599
1824
  for (const comment of this.byId.values()) if (comment.sessionId === sessionId) listed.push(comment);
1600
1825
  return listed.sort((left, right) => left.createdAt - right.createdAt);
@@ -1612,6 +1837,63 @@ var CommentStore = class {
1612
1837
  return this.revision;
1613
1838
  }
1614
1839
  /**
1840
+ * Fold what ONE transcript now says into the threads that asked there, so a thread whose answer has
1841
+ * arrived (or been rewritten) wears the dot.
1842
+ *
1843
+ * The answers are DERIVED on every list read and never trusted from the file (see
1844
+ * `CommentRecord.answerNow`): the read is the only moment the host knows what the agent has said, and
1845
+ * the comparison it makes is against a baseline that survives a restart — a restart must not light a
1846
+ * dot the reader already cleared, and a read that changed nothing must not light one at all. That is
1847
+ * what `answerSeen` is, and why this writes a baseline only when the text actually moved.
1848
+ *
1849
+ * A question the transcript shows no answer for goes back to ABSENT rather than looking like an
1850
+ * empty answer: a log this process cannot read (or a turn that has not written yet) then leaves the
1851
+ * dot exactly as it was, instead of raising it on every poll.
1852
+ *
1853
+ * The patch's own key is the TRANSCRIPT, not the comment's author: a thread written in one seat can
1854
+ * hold a question asked from another (see `askTranscript`), and this read is that other seat's log.
1855
+ * Only the questions whose answer lives in `sessionId` are folded; a thread that also holds questions
1856
+ * asked elsewhere keeps those exactly as that transcript's own fold left them.
1857
+ *
1858
+ * @param sessionId - the transcript being folded, which owns the questions it is handed.
1859
+ * @param answers - the answer text per question id, as that transcript read it.
1860
+ * @returns whether any thread changed.
1861
+ */
1862
+ syncAnswers(sessionId, answers) {
1863
+ const lit = (comment) => {
1864
+ if (!(comment.asks ?? []).some((ask) => askTranscript(ask, comment) === sessionId)) return void 0;
1865
+ const next = answerStateOf(comment, sessionId, answers);
1866
+ return sameRecord(comment, next) ? void 0 : next;
1867
+ };
1868
+ return this.patch((comment) => lit(comment) !== void 0, (comment) => lit(comment) ?? comment) > 0;
1869
+ }
1870
+ /**
1871
+ * Mark one comment as looked at: the dot goes out, and the answers it currently shows become the
1872
+ * baseline a later rewrite is measured against — otherwise the next read would find the same answer
1873
+ * "new" again and put the dot straight back up.
1874
+ *
1875
+ * Deliberately its own seam rather than something a read does: only a reader action (the card coming
1876
+ * into view) may clear attention, and a read that cleared it would make the dot impossible to see.
1877
+ *
1878
+ * @param id - the comment the reader has in front of them.
1879
+ * @returns whether the comment existed.
1880
+ */
1881
+ markSeen(id) {
1882
+ const before = this.byId.get(id);
1883
+ if (before === void 0) return false;
1884
+ const seen = before.answerNow ?? {};
1885
+ const next = {
1886
+ ...before,
1887
+ answerSeen: { ...seen }
1888
+ };
1889
+ delete next.unseen;
1890
+ if (before.unseen !== true && sameAnswers(before.answerSeen, next.answerSeen)) return false;
1891
+ this.byId.set(id, next);
1892
+ this.revision += 1;
1893
+ this.save(before.sessionId);
1894
+ return true;
1895
+ }
1896
+ /**
1615
1897
  * Record one annotation.
1616
1898
  *
1617
1899
  * Writing a comment that is already here is the SAME comment written again — a client
@@ -1678,50 +1960,62 @@ var CommentStore = class {
1678
1960
  */
1679
1961
  mergeOf(record) {
1680
1962
  const before = this.byId.get(record.id);
1681
- return before === void 0 ? record : {
1963
+ if (before === void 0) return record;
1964
+ const merged = {
1682
1965
  ...record,
1683
1966
  createdAt: before.createdAt,
1684
- ...before.asks === void 0 ? {} : { asks: before.asks }
1967
+ ...before.asks === void 0 ? {} : { asks: before.asks },
1968
+ ...record.answerSeen === void 0 && before.answerSeen !== void 0 ? { answerSeen: before.answerSeen } : {}
1685
1969
  };
1970
+ delete merged.unseen;
1971
+ if (before.unseen === true) merged.unseen = true;
1972
+ return merged;
1686
1973
  }
1687
1974
  /**
1688
- * Drop one comment.
1689
- * @param sessionId - the session the caller believes the comment belongs to.
1975
+ * Drop one comment the caller's scope covers.
1976
+ * @param scope - which comment authors the caller may act on.
1690
1977
  * @param id - the comment to drop.
1691
- * @returns whether it was there (and belonged to that session).
1978
+ * @returns whether it was there (and was in scope).
1692
1979
  */
1693
- remove(sessionId, id) {
1694
- return this.removeMany(sessionId, [id]).length === 1;
1980
+ remove(scope, id) {
1981
+ return this.removeMany(scope, [id]).length === 1;
1695
1982
  }
1696
1983
  /**
1697
- * Drop several comments of one session in ONE write.
1984
+ * Drop several comments in one write PER FILE that holds them.
1698
1985
  *
1699
1986
  * This is what a batch action needs and what a loop of `remove` calls cannot give it: each
1700
- * `remove` saves the session's file, so ending ten comments wrote that file ten times — ten
1701
- * chances for a transient failure to leave the store and the disk disagreeing about an action
1702
- * the reader asked for once. The save happens only when something was actually dropped, so a
1703
- * batch that matches nothing does not rewrite the file.
1987
+ * `remove` saves the file, so ending ten comments wrote that file ten times — ten chances for a
1988
+ * transient failure to leave the store and the disk disagreeing about an action the reader asked
1989
+ * for once. The save happens only when something was actually dropped, so a batch that matches
1990
+ * nothing does not rewrite a file.
1991
+ *
1992
+ * Each record is removed from the file that HOLDS it, which is its author's (`save`), not the
1993
+ * caller's: once a lineage reads every seat's threads, a batch can name comments written by several
1994
+ * seats, and routing them all to the caller's file would leave the real files holding records the
1995
+ * store no longer has — the next load would resurrect every one of them.
1704
1996
  *
1705
- * An id of another session, or one that is not here at all, is skipped rather than refused:
1997
+ * An id outside the scope, or one that is not here at all, is skipped rather than refused:
1706
1998
  * a batch is one request and the comments it names are the ones it can act on, and a comment
1707
1999
  * another client already ended is the state the caller was asking for anyway.
1708
2000
  *
1709
- * @param sessionId - the session the caller believes the comments belong to.
2001
+ * @param scope - which comment authors the caller may act on.
1710
2002
  * @param ids - the comments to drop; a repeated id is one comment.
1711
2003
  * @returns the ids that were dropped, in the order given.
1712
2004
  */
1713
- removeMany(sessionId, ids) {
2005
+ removeMany(scope, ids) {
1714
2006
  const removed = [];
2007
+ const sessions = /* @__PURE__ */ new Set();
1715
2008
  for (const id of ids) {
1716
2009
  if (removed.includes(id)) continue;
1717
2010
  const comment = this.byId.get(id);
1718
- if (comment === void 0 || comment.sessionId !== sessionId) continue;
2011
+ if (comment === void 0 || !scope(comment.sessionId)) continue;
1719
2012
  this.byId.delete(id);
1720
2013
  removed.push(id);
2014
+ sessions.add(comment.sessionId);
1721
2015
  }
1722
2016
  if (removed.length === 0) return removed;
1723
2017
  this.revision += 1;
1724
- this.save(sessionId);
2018
+ for (const sessionId of sessions) this.save(sessionId);
1725
2019
  return removed;
1726
2020
  }
1727
2021
  /**
@@ -1762,31 +2056,38 @@ var CommentStore = class {
1762
2056
  return removed;
1763
2057
  }
1764
2058
  /**
1765
- * The orphan sweep: drop every comment whose entry is not in `entryIds`.
2059
+ * The orphan sweep: prune every comment whose entry is not in `entryIds`, in MEMORY ONLY.
2060
+ *
2061
+ * The explicit `removeForEntry` is the primary path and the ONLY one that erases; this is the view
2062
+ * filter that guarantees no read is ever handed a comment naming a file the list does not carry. Run it
2063
+ * when the store loads and on every list read.
2064
+ *
2065
+ * WHY IT DOES NOT SAVE (2026-10-06, a data loss measured on the live host). The sweep used to write the
2066
+ * pruned state to the files, on the assumption that an entry absent here had been removed while the host
2067
+ * was running. That is only one of two ways an entry can be missing; the other is that its ADD never
2068
+ * reached `pending.json` — a pending entry is folded in memory and persisted on a later flush — and then
2069
+ * the sweep deleted the comments hanging on it AND saved the deletion. Three real annotations were gone
2070
+ * from the disk after a restart, not merely hidden: the reader's cards were written first and their
2071
+ * entries never landed. An entry that exists but is not on disk yet is not an orphan.
2072
+ *
2073
+ * A write would not even help the case it was added for. A second client must never be handed a comment
2074
+ * naming a file the same read no longer lists — that is a VIEW requirement, and the prune satisfies it.
2075
+ * Erasure belongs to the paths where a removal was actually ASKED FOR: `removeForEntry` (what the panel's
2076
+ * own drop calls, and it removes the comments in the same tick), and `remove`/`removeMany` for one
2077
+ * thread. A genuine orphan therefore stays on the disk, hidden, until the next boot that sees the entry
2078
+ * again — a row nobody reads is a smaller price than a reader's annotation nobody can recover.
1766
2079
  *
1767
- * The explicit `removeForEntry` is the primary path; this is the backstop that
1768
- * catches a comment left behind by a crash between the two writes. Run it when
1769
- * the store loads and on every list read, and the one race it could lose — an
1770
- * entry re-added after its comments were already swept — cannot happen: entries
1771
- * only leave through this host, so an entry absent at load time was removed
1772
- * while the host was running, and the sweep at load runs before anything can
1773
- * add it back.
1774
2080
  * @param entryIds - every entry id the pending store currently holds.
1775
- * @returns how many orphaned comments were dropped.
2081
+ * @returns how many comments the view dropped.
1776
2082
  */
1777
2083
  retain(entryIds) {
1778
- const sessions = /* @__PURE__ */ new Set();
1779
2084
  let removed = 0;
1780
2085
  for (const comment of [...this.byId.values()]) {
1781
2086
  if (entryIds.has(comment.entryId)) continue;
1782
2087
  this.byId.delete(comment.id);
1783
- sessions.add(comment.sessionId);
1784
2088
  removed += 1;
1785
2089
  }
1786
- if (removed > 0) {
1787
- this.revision += 1;
1788
- for (const sessionId of sessions) this.save(sessionId);
1789
- }
2090
+ if (removed > 0) this.revision += 1;
1790
2091
  return removed;
1791
2092
  }
1792
2093
  /**
@@ -1800,7 +2101,14 @@ var CommentStore = class {
1800
2101
  * marker, the reference and the rules, and a thread that showed the prompt would show
1801
2102
  * the scaffolding rather than the question.
1802
2103
  *
1803
- * @param sessionId - the session the comment belongs to.
2104
+ * The ASKING session is recorded on the question, not taken from the comment: a thread another
2105
+ * seat wrote can be asked from this one, and the prompt then goes into the seat the human is
2106
+ * using, so that session's transcript is where the answer will appear (see `askTranscript`).
2107
+ *
2108
+ * Matched by comment ID alone: the request id is minted here and is globally unique, and the
2109
+ * caller has already checked that the thread is in its scope.
2110
+ *
2111
+ * @param sessionId - the transcript the question was submitted into.
1804
2112
  * @param id - the comment that was asked in.
1805
2113
  * @param requestId - the identity the submission carries into the transcript.
1806
2114
  * @param turn - the turn that claimed it, when that is already known.
@@ -1808,10 +2116,11 @@ var CommentStore = class {
1808
2116
  * @returns whether the comment existed.
1809
2117
  */
1810
2118
  recordAsk(sessionId, id, requestId, turn, text) {
1811
- return this.patch((comment) => comment.sessionId === sessionId && comment.id === id, (comment) => ({
2119
+ return this.patch((comment) => comment.id === id, (comment) => ({
1812
2120
  ...comment,
1813
2121
  asks: [...comment.asks ?? [], {
1814
2122
  requestId,
2123
+ sessionId,
1815
2124
  text,
1816
2125
  ...turn === void 0 ? {} : { turn }
1817
2126
  }],
@@ -1822,13 +2131,16 @@ var CommentStore = class {
1822
2131
  * Record the turn that claimed one question, as the agent's inbox reported it. Two
1823
2132
  * questions claimed by the same turn were answered together, which is a fact the
1824
2133
  * panel can state instead of guessing from transcript positions.
1825
- * @param sessionId - the session the submission belongs to.
2134
+ *
2135
+ * Matched by REQUEST ID alone: it is minted per submission and globally unique, so it names the
2136
+ * question on its own — and a thread another seat wrote can hold a question asked from this seat,
2137
+ * which the old `comment.sessionId === sessionId` guard would refuse to write down.
1826
2138
  * @param requestId - the request identity the inbox reported back.
1827
2139
  * @param turn - the turn that claimed it.
1828
2140
  * @returns how many threads the patch touched.
1829
2141
  */
1830
- recordTurnForRequest(sessionId, requestId, turn) {
1831
- return this.patch((comment) => comment.sessionId === sessionId && hasAsk(comment, requestId, (ask) => ask.turn !== turn), (comment) => ({
2142
+ recordTurnForRequest(requestId, turn) {
2143
+ return this.patch((comment) => hasAsk(comment, requestId, (ask) => ask.turn !== turn), (comment) => ({
1832
2144
  ...comment,
1833
2145
  asks: (comment.asks ?? []).map((ask) => ask.requestId === requestId ? {
1834
2146
  ...ask,
@@ -1838,14 +2150,15 @@ var CommentStore = class {
1838
2150
  }));
1839
2151
  }
1840
2152
  /**
1841
- * Record that the session dropped one question before a turn claimed it: nothing is
2153
+ * Record that a session dropped one question before a turn claimed it: nothing is
1842
2154
  * coming, and the reader is told rather than left waiting.
1843
- * @param sessionId - the session the submission belongs to.
2155
+ *
2156
+ * Matched by REQUEST ID alone, for the same reason as `recordTurnForRequest`.
1844
2157
  * @param requestId - the request identity the inbox reported back.
1845
2158
  * @returns how many threads the patch touched.
1846
2159
  */
1847
- markDroppedForRequest(sessionId, requestId) {
1848
- return this.patch((comment) => comment.sessionId === sessionId && hasAsk(comment, requestId, (ask) => ask.dropped !== true), (comment) => ({
2160
+ markDroppedForRequest(requestId) {
2161
+ return this.patch((comment) => hasAsk(comment, requestId, (ask) => ask.dropped !== true), (comment) => ({
1849
2162
  ...comment,
1850
2163
  asks: (comment.asks ?? []).map((ask) => ask.requestId === requestId ? {
1851
2164
  ...ask,
@@ -1865,14 +2178,19 @@ var CommentStore = class {
1865
2178
  * that: whether an answer exists is read from the transcript on each list read, so a
1866
2179
  * turn that stopped AFTER writing its answer keeps showing the answer.
1867
2180
  *
1868
- * @param sessionId - the session whose turn ended.
2181
+ * Matched by TURN **within one transcript** (`askTranscript`), never by the thread's author: turn
2182
+ * numbers are only unique inside a session, so two sessions can both have a turn 7, and a thread that
2183
+ * holds questions from both would otherwise have one session's ending mark the other's questions
2184
+ * over — the reader would be told a question was cut off while its answer was still coming.
2185
+ * @param sessionId - the transcript whose turn ended.
1869
2186
  * @param turn - the turn that stopped.
1870
2187
  * @returns how many threads the patch touched.
1871
2188
  */
1872
2189
  markTurnEnded(sessionId, turn) {
1873
- return this.patch((comment) => comment.sessionId === sessionId && (comment.asks ?? []).some((ask) => ask.turn === turn && ask.ended !== true), (comment) => ({
2190
+ const endedHere = (ask, comment) => askTranscript(ask, comment) === sessionId && ask.turn === turn;
2191
+ return this.patch((comment) => (comment.asks ?? []).some((ask) => endedHere(ask, comment) && ask.ended !== true), (comment) => ({
1874
2192
  ...comment,
1875
- asks: (comment.asks ?? []).map((ask) => ask.turn === turn ? {
2193
+ asks: (comment.asks ?? []).map((ask) => endedHere(ask, comment) ? {
1876
2194
  ...ask,
1877
2195
  ended: true
1878
2196
  } : ask),
@@ -1929,7 +2247,12 @@ var CommentStore = class {
1929
2247
  * memory-only view would TRUNCATE that copy — turning a read failure into a permanent
1930
2248
  * loss — so the save is refused and reported instead, which is what tells the reader
1931
2249
  * those comments are memory-only.
1932
- * @param sessionId - the session whose file to rewrite.
2250
+ *
2251
+ * The write-set is the AUTHOR's own comments (`authoredBy`), never the wider read scope
2252
+ * (`list`): a read spans the lineage, but a file must hold exactly the comments whose
2253
+ * author it is named for — writing the scope's comments into one seat's file would
2254
+ * duplicate every other seat's threads on disk and, on the next load, in memory.
2255
+ * @param sessionId - the author whose file to rewrite.
1933
2256
  */
1934
2257
  save(sessionId) {
1935
2258
  const file = fileOf(this.root, sessionId);
@@ -1938,7 +2261,7 @@ var CommentStore = class {
1938
2261
  this.reportPersist(file, `'${file}' ${refused}, so this session's comments are kept in memory rather than written over it`);
1939
2262
  return;
1940
2263
  }
1941
- const comments = this.list(sessionId);
2264
+ const comments = this.authoredBy(sessionId);
1942
2265
  const task = async () => {
1943
2266
  await writeJsonAtomic(file, {
1944
2267
  version: COMMENT_FILE_VERSION,
@@ -2166,16 +2489,19 @@ var CommentAsker = class {
2166
2489
  * arrives while the submission is still in flight already finds its comment. A
2167
2490
  * submission that rejects is marked dropped rather than left looking pending.
2168
2491
  *
2169
- * @param sessionId - the session the comment belongs to.
2492
+ * @param sessionId - the session the question is asked FROM: the prompt goes into it, so its
2493
+ * transcript is where the answer will appear.
2170
2494
  * @param commentId - the comment to ask.
2171
2495
  * @param prompt - the prompt text to send verbatim.
2172
2496
  * @param text - the reader's own words, which the prompt wraps: the thread keeps these.
2173
2497
  * @param signal - aborts the submission.
2498
+ * @param inScope - which comment authors the caller's lineage covers, so a thread another seat
2499
+ * wrote can be asked from this one.
2174
2500
  * @returns what the request did.
2175
2501
  */
2176
- async ask(sessionId, commentId, prompt, text, signal) {
2502
+ async ask(sessionId, commentId, prompt, text, signal, inScope) {
2177
2503
  const comment = this.comments.get(commentId);
2178
- if (comment === void 0 || comment.sessionId !== sessionId) return { outcome: "missing" };
2504
+ if (comment === void 0 || !inScope(comment.sessionId)) return { outcome: "missing" };
2179
2505
  const controller = this.ctx.get("sessionController");
2180
2506
  if (typeof controller?.prompt !== "function") {
2181
2507
  this.ctx.logger.warn("diff-approval: no sessionController.prompt in this host, so a comment cannot be asked");
@@ -2205,7 +2531,7 @@ var CommentAsker = class {
2205
2531
  } catch (error) {
2206
2532
  const reason = rejectionText(error);
2207
2533
  this.ctx.logger.warn(`diff-approval: asking a comment failed: ${reason}`);
2208
- this.comments.markDroppedForRequest(sessionId, requestId);
2534
+ this.comments.markDroppedForRequest(requestId);
2209
2535
  return {
2210
2536
  outcome: "failed",
2211
2537
  message: reason
@@ -2213,25 +2539,44 @@ var CommentAsker = class {
2213
2539
  }
2214
2540
  }
2215
2541
  /**
2216
- * Every question of one session and what the transcript says about it, keyed by the
2542
+ * One scope's questions, grouped by THE TRANSCRIPT each was submitted into.
2543
+ *
2544
+ * A lineage's threads may hold questions asked from several seats, and an answer lives in the
2545
+ * transcript of the session that was asked — so the read is per transcript, not per thread. Grouping
2546
+ * here (rather than reading each thread separately) is what keeps a poll to ONE log walk per
2547
+ * transcript, however many threads asked there.
2548
+ *
2549
+ * The legacy case is why `askTranscript` exists: a question asked before the asking session was
2550
+ * recorded has only the comment's own session to go on, which is where it went.
2551
+ * @param visible - the comments this read may see (already scoped by the caller).
2552
+ * @returns the request ids per transcript; empty when nothing was ever asked.
2553
+ */
2554
+ answerGroups(visible) {
2555
+ const groups = /* @__PURE__ */ new Map();
2556
+ for (const comment of visible) for (const ask of comment.asks ?? []) {
2557
+ const transcript = askTranscript(ask, comment);
2558
+ const ids = groups.get(transcript);
2559
+ if (ids === void 0) groups.set(transcript, [ask.requestId]);
2560
+ else ids.push(ask.requestId);
2561
+ }
2562
+ return groups;
2563
+ }
2564
+ /**
2565
+ * Every question submitted into ONE transcript and what it says about them, keyed by the
2217
2566
  * question's request id (which is what `CommentAsk` records and what a client matches
2218
- * against). Derived from the session's own event log on every call and never stored:
2219
- * the log is the one source of truth for what the agent said, so a stored copy could
2220
- * only ever disagree with it.
2221
- * @param sessionId - the session whose comments to read.
2567
+ * against). Derived from that session's event log on every call and never stored: the log
2568
+ * is the one source of truth for what the agent said, so a stored copy could only ever
2569
+ * disagree with it.
2570
+ * @param sessionId - the transcript to read.
2571
+ * @param requestIds - the questions that were submitted into it.
2572
+ * @param cache - this request's already-read logs, so the answer fold and the turn-end read
2573
+ * below share ONE walk of a log that can hold >100k events.
2222
2574
  * @returns the read per question; empty when the session holds no questions.
2223
2575
  */
2224
- answers(sessionId) {
2576
+ answersFor(sessionId, requestIds, cache) {
2225
2577
  this.watch(sessionId);
2226
- const requestIds = [];
2227
- for (const comment of this.comments.list(sessionId)) for (const ask of comment.asks ?? []) requestIds.push(ask.requestId);
2228
2578
  if (requestIds.length === 0) return {};
2229
- let events;
2230
- try {
2231
- events = this.sessionEvents(sessionId);
2232
- } catch {
2233
- events = void 0;
2234
- }
2579
+ const events = this.sessionEvents(sessionId, cache);
2235
2580
  if (events === void 0) return {};
2236
2581
  return answersIn(events, requestIds);
2237
2582
  }
@@ -2244,16 +2589,12 @@ var CommentAsker = class {
2244
2589
  * a client that connected afterwards, a deployment whose agent events cannot be
2245
2590
  * subscribed at all — because `turn/end` is durable and the event is not.
2246
2591
  *
2247
- * @param sessionId - the session whose turns to check.
2592
+ * @param sessionId - the transcript whose turns to check.
2593
+ * @param cache - this request's already-read logs (see `answersFor`).
2248
2594
  * @returns the turns the log closed, or empty when the log cannot be read.
2249
2595
  */
2250
- endedTurns(sessionId) {
2251
- let events;
2252
- try {
2253
- events = this.sessionEvents(sessionId);
2254
- } catch {
2255
- events = void 0;
2256
- }
2596
+ endedTurns(sessionId, cache) {
2597
+ const events = this.sessionEvents(sessionId, cache);
2257
2598
  if (events === void 0) return [];
2258
2599
  const turns = [];
2259
2600
  for (const event of events) {
@@ -2272,11 +2613,24 @@ var CommentAsker = class {
2272
2613
  * and `snapshotEvents` hands back a frozen, sequence-ordered copy of it, so walking it
2273
2614
  * is a plain read that cannot disturb the session.
2274
2615
  *
2616
+ * The cache is per REQUEST, not per session: one list read asks this twice for every transcript
2617
+ * (the answer fold and the turn-end read), and the copy is the expensive part of a long session's
2618
+ * log. `has` rather than a truthy check, so "cannot be read" is cached as such instead of being
2619
+ * asked again.
2275
2620
  * @param sessionId - the session to read.
2621
+ * @param cache - this request's already-read logs, when the caller is doing several reads.
2276
2622
  * @returns its events, or undefined when no live session can be read.
2277
2623
  */
2278
- sessionEvents(sessionId) {
2279
- return this.ctx.sessions.get(sessionId)?.snapshotEvents?.();
2624
+ sessionEvents(sessionId, cache) {
2625
+ if (cache !== void 0 && cache.has(sessionId)) return cache.get(sessionId);
2626
+ let events;
2627
+ try {
2628
+ events = this.ctx.sessions.get(sessionId)?.snapshotEvents?.();
2629
+ } catch {
2630
+ events = void 0;
2631
+ }
2632
+ cache?.set(sessionId, events);
2633
+ return events;
2280
2634
  }
2281
2635
  /** The live agent for one session, or `undefined` when this host drives none. */
2282
2636
  agentOf(sessionId) {
@@ -2300,7 +2654,7 @@ var CommentAsker = class {
2300
2654
  * released first, so re-arming is not a leak: one set of handlers per live agent,
2301
2655
  * not one more per identity change.
2302
2656
  *
2303
- * Called on every ask AND on every poll read (`answers`), because the agent can be
2657
+ * Called on every ask AND on every poll read (`answersFor`), because the agent can be
2304
2658
  * replaced while a question is still pending — the case where nothing asks again.
2305
2659
  * @param sessionId - the session whose agent to watch.
2306
2660
  */
@@ -2329,11 +2683,11 @@ var CommentAsker = class {
2329
2683
  if (requestId === void 0) return;
2330
2684
  const turn = typeof payload.turn === "number" ? payload.turn : void 0;
2331
2685
  if (turn === void 0) return;
2332
- this.comments.recordTurnForRequest(sessionId, requestId, turn);
2686
+ this.comments.recordTurnForRequest(requestId, turn);
2333
2687
  });
2334
2688
  listen("agent/inbox/discarded", (payload) => {
2335
2689
  const requestId = requestIdOf(payload);
2336
- if (requestId !== void 0) this.comments.markDroppedForRequest(sessionId, requestId);
2690
+ if (requestId !== void 0) this.comments.markDroppedForRequest(requestId);
2337
2691
  });
2338
2692
  listen("agent/turn-stopping", (payload) => {
2339
2693
  const turn = payload.turn;
@@ -2611,6 +2965,9 @@ const ANNOTATE_SKILL = {
2611
2965
  - \`startLine\` / \`endLine\`:**现在**的行号(1 起,含两端;省略 \`endLine\` 就是一行)。先读文件再写号,别凭记忆。
2612
2966
  - \`note\`:卡片上写的话。要短——卡片是**按行**排版、行高按行算的(上限 1000 字符,超了会被拒)。讲流程时,把这一步的序号写在 note 的**最前面**(\`1. …\`、\`2. …\`)。
2613
2967
  - \`quote\`(可选,建议给):你读到的那几行原文(换行连接)。文件在你读完之后又变了的话,会当场被拒并把现在的原文给你,避免批注挂在错误的行上。
2968
+ - \`category\`(可选):**给"一类批注"起一个 id**。同一类(同一轮、同一主题、同一次讲解)每次调用都传**同一个** id,换成另一类就换一个 id。面板会按这个 id 给评论列表里的那条评论画一个**彩色圆点**,所以**同一类的批注是同一个颜色**,两类之间可以靠颜色区分。**不传 \`category\` 就完全不画圆点**——它是可选的,圆点只为"有类别"的批注而画。
2969
+ - 颜色只有 12 种,id 是散列到颜色上的,所以**两个不同的 id 可能撞成同一个颜色**,那时这两类看起来一样:这不是"分了几类一目了然",只是"同一类颜色一致"。想让人分得清,就**有意识地复用同一个 id**,不要给同一类换新 id。
2970
+ - id 本身不解析:随便给(uuid 也行)。颜色由它算出来;你写的 id 只出现在圆点的 **\`title\` / \`aria-label\`** 里(鼠标悬停、或读屏软件),**不是屏幕上的文字**。会去掉两端空白;最多 64 个字符,超了**截断**(不会因此拒掉整条批注)。
2614
2971
 
2615
2972
  ## 讲流程就在正文开头写序号
2616
2973
 
@@ -2649,6 +3006,22 @@ const ANNOTATE_SKILL = {
2649
3006
  */
2650
3007
  const MAX_ANNOTATION_CHARS = 1e3;
2651
3008
  /**
3009
+ * The category id as it is stored: trimmed, and cut to {@link MAX_ANNOTATION_CATEGORY_CHARS}.
3010
+ *
3011
+ * TRUNCATED rather than refused: the id is only ever hashed into a colour and echoed in the mark's label,
3012
+ * so an over-long one still groups its annotations consistently — every call of that class is cut in the
3013
+ * same place — while refusing would fail an entire annotation over a cosmetic detail. Absent, empty and
3014
+ * whitespace-only all mean the same thing: no class, so no dot (see `CommentRecord.category`).
3015
+ *
3016
+ * @param value - the id the caller passed, if any.
3017
+ * @returns the id to store, or `undefined` when there is none.
3018
+ */
3019
+ function normalizeCategory(value) {
3020
+ const trimmed = value?.trim() ?? "";
3021
+ if (trimmed === "") return void 0;
3022
+ return trimmed.slice(0, 64);
3023
+ }
3024
+ /**
2652
3025
  * Whether two line ranges share a line. Ranges are inclusive on both ends, which is how the panel's own
2653
3026
  * selection rule reads them (`discussionOverlapping`).
2654
3027
  *
@@ -2751,6 +3124,7 @@ function annotateLines(request, existing) {
2751
3124
  };
2752
3125
  }
2753
3126
  const now = request.now ?? Date.now();
3127
+ const category = normalizeCategory(request.category);
2754
3128
  return {
2755
3129
  outcome: "annotated",
2756
3130
  comment: {
@@ -2766,6 +3140,7 @@ function annotateLines(request, existing) {
2766
3140
  createdAt: now,
2767
3141
  updatedAt: now,
2768
3142
  author: "agent",
3143
+ ...category === void 0 ? {} : { category },
2769
3144
  ...annotationQuote(lines, start, end)
2770
3145
  }
2771
3146
  };
@@ -2825,7 +3200,7 @@ const ANNOTATE_TOOL_NAME = "diff_approval_annotate";
2825
3200
  * The routing line the model always sees, which has to carry the two things that decide whether it calls
2826
3201
  * this at all: what appears for the reader, and that the lines must be free.
2827
3202
  */
2828
- const ANNOTATE_TOOL_DESCRIPTION = "Annotate a few lines of a file in the review panel: the note becomes a card the user sees on those exact lines, and they can reply to it there. Use it to explain code the user is reading (a call chain is one card per step), to point at a piece of code while discussing it, or to record a review finding on the lines it is about. A file the panel is not showing yet is added to it, so this works whether or not anything in that file has changed. The card shows exactly the text you write, so how the note is worded — including any numbering the user needs to read it in order — is yours. The lines must not already be inside an existing card: that case is refused, and the refusal names the card holding them, so say it in your reply instead of annotating those lines again.";
3203
+ const ANNOTATE_TOOL_DESCRIPTION = "Annotate a few lines of a file in the review panel: the note becomes a card the user sees on those exact lines, and they can reply to it there. Use it to explain code the user is reading (a call chain is one card per step), to point at a piece of code while discussing it, or to record a review finding on the lines it is about. A file the panel is not showing yet is added to it, so this works whether or not anything in that file has changed. The card shows exactly the text you write, so how the note is worded — including any numbering the user needs to read it in order — is yours. Give every annotation of one kind the same `category` id, and a different id for another kind (one pass, one topic, one round of review): the panel paints a coloured round dot beside each of those comments from it, which is how one class of annotation is told from another. Omit `category` and no dot is drawn at all. The lines must not already be inside an existing card: that case is refused, and the refusal names the card holding them, so say it in your reply instead of annotating those lines again.";
2829
3204
  /**
2830
3205
  * The argument schema, as raw JSON Schema.
2831
3206
  *
@@ -2861,6 +3236,10 @@ const ANNOTATE_TOOL_PARAMETERS = {
2861
3236
  quote: {
2862
3237
  type: "string",
2863
3238
  description: "Optional guard: the exact text you believe those lines hold (newline-joined). A mismatch is refused with the text that is actually there, which catches a file that moved between your read and this call."
3239
+ },
3240
+ category: {
3241
+ type: "string",
3242
+ description: "Optional class for this annotation. Pass the SAME short id for every annotation that belongs to one kind (one pass, one topic, one review round) and a DIFFERENT one for the next: the panel paints a coloured round dot beside these comments from it, so two classes can be told apart. OMIT this argument and no dot is drawn at all — it is optional, and the mark exists only for a class. Opaque: a uuid is fine, it is hashed for the colour and shown to the user in the mark's own label (hover, or a screen reader) as you wrote it. Trimmed, and an id longer than 64 characters is cut to 64 rather than refused."
2864
3243
  }
2865
3244
  }
2866
3245
  };
@@ -3018,7 +3397,8 @@ function annotateRun(deps) {
3018
3397
  startLine: args.startLine,
3019
3398
  endLine: args.endLine,
3020
3399
  note: args.note,
3021
- quote: args.quote
3400
+ quote: args.quote,
3401
+ category: args.category
3022
3402
  }, deps.commentsOf(sessionId));
3023
3403
  if (result.outcome !== "annotated") {
3024
3404
  deps.log?.(`annotate: ${refusalSummary(result)} (${entry.path})`);
@@ -3277,9 +3657,27 @@ function parseGitPorcelainZ(output) {
3277
3657
  * a rename reads as its two halves (a delete and an add) rather than a row the review cannot
3278
3658
  * express. */
3279
3659
  const GIT_STATUS_COMMAND = "git -c status.renames=false status --porcelain=v1 -z --untracked-files=all";
3660
+ /** The message a failed command carries; `runShell` puts the command's own stderr into it. */
3661
+ function errorMessage$1(error) {
3662
+ return error instanceof Error ? error.message : String(error);
3663
+ }
3664
+ /**
3665
+ * git's wording for a path a revision does not carry, and ONLY that.
3666
+ *
3667
+ * Measured against git 2.x: `path 'x' does not exist in 'HEAD'`, and `Not a valid object name
3668
+ * HEAD:x` when HEAD is unborn (a repository with no commits yet — there every file is genuinely
3669
+ * new). A failure that is none of these — a corrupt object, a refused spawn, a timeout — is not
3670
+ * absence, and must never be read as one.
3671
+ */
3672
+ const ABSENT_AT_REVISION = /does not exist in|exists on disk, but not in|not a valid object name|invalid object name|unknown revision|bad revision/i;
3280
3673
  /**
3281
- * Read one file's content at a revision (`<rev>:<path>`), or undefined when that revision has no
3282
- * such path (a new file, or a repo whose HEAD is unborn).
3674
+ * Read one file's content at a revision (`<rev>:<path>`), and say so when that revision has no such
3675
+ * path.
3676
+ *
3677
+ * The two answers must stay distinguishable. Absence is what makes a file a creation, and a
3678
+ * creation's revert DELETES the file — so reading a FAILED read as absence offers to delete a file
3679
+ * that is committed and fine. Only git's own words decide (see {@link ABSENT_AT_REVISION}); anything
3680
+ * else is thrown with the path named, because a reader can act on that and cannot act on a guess.
3283
3681
  *
3284
3682
  * The blob is read straight off `git show` with a per-call stdout budget raised to the blob's own
3285
3683
  * size the way `git cat-file -s` reports it, so the executor cannot truncate a large baseline.
@@ -3290,15 +3688,20 @@ const GIT_STATUS_COMMAND = "git -c status.renames=false status --porcelain=v1 -z
3290
3688
  * @param rel - the repository-relative path.
3291
3689
  * @param shell - the deployment's shell executor.
3292
3690
  * @param signal - the caller's abort signal.
3293
- * @returns the file's content at that revision, or undefined when it has none.
3691
+ * @returns the file's content at that revision, or `{ found: false }` when it has none.
3692
+ * @throws when the read failed for any reason other than absence.
3294
3693
  */
3295
3694
  async function readGitBlob(root, revision, rel, shell, signal) {
3296
3695
  try {
3297
3696
  const reported = Number.parseInt((await runShell(shell, `git cat-file -s ${revision}:${shq(rel)}`, root, signal)).trim(), 10);
3298
3697
  const size = Number.isFinite(reported) ? reported : 0;
3299
- return await runShell(shell, `git show ${revision}:${shq(rel)}`, root, signal, size + GIT_BLOB_STDOUT_SLACK);
3300
- } catch {
3301
- return;
3698
+ return {
3699
+ found: true,
3700
+ text: await runShell(shell, `git show ${revision}:${shq(rel)}`, root, signal, size + GIT_BLOB_STDOUT_SLACK)
3701
+ };
3702
+ } catch (error) {
3703
+ if (ABSENT_AT_REVISION.test(errorMessage$1(error))) return { found: false };
3704
+ throw new Error(`could not read ${rel} at ${revision}: ${errorMessage$1(error)}`);
3302
3705
  }
3303
3706
  }
3304
3707
  /** Enumerate the workspace's local changes in a git checkout. */
@@ -3315,7 +3718,7 @@ async function gitChanges(input) {
3315
3718
  const newText = await readText(absolute) ?? "";
3316
3719
  changes.push({
3317
3720
  path: absolute,
3318
- kind: "create",
3721
+ earlierVersion: "none",
3319
3722
  oldText: "",
3320
3723
  newText
3321
3724
  });
@@ -3323,12 +3726,12 @@ async function gitChanges(input) {
3323
3726
  }
3324
3727
  const baseline = await readGitBlob(root, "HEAD", rel, shell, signal);
3325
3728
  const worktree = xy[1] ?? " ";
3326
- const oldText = baseline ?? "";
3729
+ const oldText = baseline.found ? baseline.text : "";
3327
3730
  const newText = worktree === "D" ? "" : await readText(absolute) ?? "";
3328
3731
  if (oldText === "" && newText === "") continue;
3329
3732
  changes.push({
3330
3733
  path: absolute,
3331
- kind: baseline === void 0 ? "create" : "edit",
3734
+ earlierVersion: baseline.found ? "file" : "none",
3332
3735
  oldText,
3333
3736
  newText
3334
3737
  });
@@ -3353,17 +3756,17 @@ async function svnChanges(input) {
3353
3756
  const absolute = resolve(root, rel);
3354
3757
  if (!inScanScope(absolute, roots)) continue;
3355
3758
  if (item === "modified" || item === "deleted") {
3356
- let oldText = "";
3759
+ let oldText;
3357
3760
  try {
3358
3761
  oldText = await runShell(shell, `svn cat -r BASE ${shq(rel)}`, root, signal);
3359
- } catch {
3360
- oldText = "";
3762
+ } catch (error) {
3763
+ throw new Error(`could not read ${rel} at BASE: ${errorMessage$1(error)}`);
3361
3764
  }
3362
3765
  const newText = item === "deleted" ? "" : await readText(absolute) ?? "";
3363
3766
  if (oldText === "" && newText === "") continue;
3364
3767
  changes.push({
3365
3768
  path: absolute,
3366
- kind: "edit",
3769
+ earlierVersion: "file",
3367
3770
  oldText,
3368
3771
  newText
3369
3772
  });
@@ -3371,7 +3774,7 @@ async function svnChanges(input) {
3371
3774
  const newText = await readText(absolute) ?? "";
3372
3775
  changes.push({
3373
3776
  path: absolute,
3374
- kind: "create",
3777
+ earlierVersion: "none",
3375
3778
  oldText: "",
3376
3779
  newText
3377
3780
  });
@@ -3379,7 +3782,7 @@ async function svnChanges(input) {
3379
3782
  const newText = await readText(absolute) ?? "";
3380
3783
  changes.push({
3381
3784
  path: absolute,
3382
- kind: "create",
3785
+ earlierVersion: "none",
3383
3786
  oldText: "",
3384
3787
  newText
3385
3788
  });
@@ -3424,13 +3827,13 @@ async function p4Changes(input) {
3424
3827
  let oldText = "";
3425
3828
  if (!created) try {
3426
3829
  oldText = await runShell(shell, `p4 print -q ${shq(opened.depot)}#have`, root, signal);
3427
- } catch {
3428
- oldText = "";
3830
+ } catch (error) {
3831
+ throw new Error(`could not read ${opened.depot}#have: ${errorMessage$1(error)}`);
3429
3832
  }
3430
3833
  if (oldText === "" && newText === "") continue;
3431
3834
  changes.push({
3432
3835
  path: absolute,
3433
- kind: created ? "create" : "edit",
3836
+ earlierVersion: created ? "none" : "file",
3434
3837
  oldText,
3435
3838
  newText
3436
3839
  });
@@ -3451,6 +3854,182 @@ async function listVcsChanges(input) {
3451
3854
  case "p4": return p4Changes(input);
3452
3855
  }
3453
3856
  }
3857
+ /**
3858
+ * Parse `major.minor.patch`, and NOTHING else.
3859
+ *
3860
+ * Deliberately strict, because the alternative is a bogus "newer": a pre-release
3861
+ * (`0.31.0-rc.1`), a range (`^0.31.0`), a dist-tag (`latest`) or a stray suffix all
3862
+ * answer `undefined`, and an unparsable side of a comparison is never allowed to look
3863
+ * newer. A leading `v` is tolerated because tags carry one.
3864
+ *
3865
+ * @param text - the candidate, from a registry document or a `package.json`.
3866
+ * @returns the three numbers, or `undefined` when the text is not exactly that shape.
3867
+ */
3868
+ function parseVersion(text) {
3869
+ if (typeof text !== "string") return void 0;
3870
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/u.exec(text.trim());
3871
+ if (match === null) return void 0;
3872
+ const parts = [
3873
+ Number(match[1]),
3874
+ Number(match[2]),
3875
+ Number(match[3])
3876
+ ];
3877
+ if (parts.some((part) => !Number.isSafeInteger(part))) return void 0;
3878
+ return parts;
3879
+ }
3880
+ /**
3881
+ * Is `latest` strictly newer than `current`?
3882
+ *
3883
+ * Strictly: equal versions are NOT newer, and neither side parsing means NOT newer —
3884
+ * the rule the code above exists to make safe. Comparison is component-wise on the
3885
+ * three numbers, so `0.9.0` precedes `0.10.0` (string comparison would get that wrong).
3886
+ *
3887
+ * @param latest - the published version.
3888
+ * @param current - the installed version.
3889
+ * @returns whether a notice is warranted.
3890
+ */
3891
+ function isNewerVersion(latest, current) {
3892
+ const next = parseVersion(latest);
3893
+ const here = parseVersion(current);
3894
+ if (next === void 0 || here === void 0) return false;
3895
+ return compareVersions(next, here) > 0;
3896
+ }
3897
+ /**
3898
+ * Compare two already-parsed versions.
3899
+ *
3900
+ * Component-wise, because a string comparison gets `0.9.0` vs `0.10.0` wrong — the reason both
3901
+ * callers go through here rather than comparing something textual.
3902
+ *
3903
+ * @param left - one version.
3904
+ * @param right - the other.
3905
+ * @returns negative, zero or positive, as a comparator should.
3906
+ */
3907
+ function compareVersions(left, right) {
3908
+ for (let index = 0; index < 3; index += 1) if (left[index] !== right[index]) return left[index] - right[index];
3909
+ return 0;
3910
+ }
3911
+ /**
3912
+ * The version installed in this package, read from the `package.json` beside the built
3913
+ * `lib/` — the only authority on what the reader is running, and the value the registry
3914
+ * answer is compared against.
3915
+ *
3916
+ * Resolved relative to this module, so it holds for the bundled `lib/index.js`
3917
+ * (`../package.json` is the package root) and for the source under test (`../package.json`
3918
+ * is the repository root) alike.
3919
+ *
3920
+ * @param manifestUrl - the manifest to read; the package's own `package.json` unless a caller
3921
+ * says otherwise. It is a parameter because a test runner can serve modules over a non-file
3922
+ * URL, where the default cannot be resolved — the production path is the default, and the
3923
+ * end-to-end case is what proves the default itself.
3924
+ * @returns the version, or `undefined` when the file is missing or has none.
3925
+ */
3926
+ async function installedVersion(manifestUrl = new URL("../package.json", import.meta.url)) {
3927
+ try {
3928
+ const text = await readFile(manifestUrl, "utf8");
3929
+ const document = JSON.parse(text);
3930
+ return typeof document.version === "string" && document.version.length > 0 ? document.version : void 0;
3931
+ } catch {
3932
+ return;
3933
+ }
3934
+ }
3935
+ /**
3936
+ * Ask the registry for the published version.
3937
+ *
3938
+ * @param probe - where to ask and how; every field defaults (see {@link UpdateProbe}).
3939
+ * @returns the version, or `undefined` on any failure — including an answer that is not
3940
+ * a version, which must never become a notice.
3941
+ */
3942
+ async function fetchLatestVersion(probe = {}) {
3943
+ const body = await fetchText(probe.registryUrl ?? "https://registry.npmjs.org/dsh-diff-approval/latest", probe);
3944
+ if (body === void 0) return void 0;
3945
+ try {
3946
+ const document = JSON.parse(body);
3947
+ return parseVersion(document.version) === void 0 ? void 0 : String(document.version);
3948
+ } catch {
3949
+ return;
3950
+ }
3951
+ }
3952
+ /**
3953
+ * Build the answer behind the `update-check` endpoint: one function a host calls per request.
3954
+ *
3955
+ * WHAT IS CACHED, AND FOR HOW LONG — the whole point of this factory:
3956
+ *
3957
+ * - The REMOTE answer (the registry's latest version) is cached, because that is the network cost. It is held
3958
+ * for {@link UPDATE_CACHE_TTL_MS} and then fetched again, so a release published while the host is running
3959
+ * still reaches the reader without a restart. A FAILED fetch is not cached at all: remembering a failure
3960
+ * would hide the notice for the whole TTL after one offline moment, and there is no network cost to save.
3961
+ * - The INSTALLED version is read from the manifest on EVERY call. It is a file read, not a request, and it is
3962
+ * the side that changes when the reader upgrades — so a verdict computed before that upgrade must never be
3963
+ * replayed. Caching it was a real defect: a host that answered while an older version was installed kept
3964
+ * announcing "a newer release" to every page until it was restarted.
3965
+ *
3966
+ * `newer` is therefore decided afresh on every call, from today's manifest against the cached registry answer.
3967
+ * NEVER THROWS: every failure — no manifest, an unreachable registry, a nonsense answer — ends as "nothing to
3968
+ * say" (`newer: false`), so a network problem can never become an error in the reader's status bar.
3969
+ *
3970
+ * @param options - where to read and how; every field defaults (see {@link UpdateCheckOptions}).
3971
+ * @returns the check itself; call it as often as you like.
3972
+ */
3973
+ function createUpdateCheck(options = {}) {
3974
+ const ttl = options.ttlMs ?? 6e5;
3975
+ const now = options.now ?? (() => Date.now());
3976
+ const probe = {
3977
+ ...options.registryUrl === void 0 ? {} : { registryUrl: options.registryUrl },
3978
+ ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
3979
+ ...options.fetchImpl === void 0 ? {} : { fetchImpl: options.fetchImpl }
3980
+ };
3981
+ const manifestUrl = options.manifestUrl ?? new URL("../package.json", import.meta.url);
3982
+ /** The cached REMOTE answer, with the moment it was fetched. */
3983
+ let remote;
3984
+ return async () => {
3985
+ const current = await installedVersion(manifestUrl);
3986
+ if (current === void 0) return {
3987
+ current: "",
3988
+ newer: false
3989
+ };
3990
+ const at = now();
3991
+ if (remote === void 0 || at - remote.at >= ttl) {
3992
+ const fetched = await fetchLatestVersion(probe);
3993
+ if (fetched !== void 0) remote = {
3994
+ version: fetched,
3995
+ at
3996
+ };
3997
+ }
3998
+ const latest = remote?.version;
3999
+ if (latest === void 0 || !isNewerVersion(latest, current)) return {
4000
+ current,
4001
+ ...latest === void 0 ? {} : { latest },
4002
+ newer: false
4003
+ };
4004
+ return {
4005
+ current,
4006
+ latest,
4007
+ newer: true
4008
+ };
4009
+ };
4010
+ }
4011
+ /** One `GET`, with the module's timeout, mapping every failure to `undefined`. */
4012
+ async function fetchText(url, probe) {
4013
+ const implementation = probe.fetchImpl ?? globalThis.fetch;
4014
+ if (implementation === void 0) return void 0;
4015
+ const controller = new AbortController();
4016
+ const timer = setTimeout(() => {
4017
+ controller.abort();
4018
+ }, probe.timeoutMs ?? 3e3);
4019
+ timer.unref?.();
4020
+ try {
4021
+ const response = await implementation(url, {
4022
+ signal: controller.signal,
4023
+ headers: { accept: "application/json, text/plain, */*" }
4024
+ });
4025
+ if (!response.ok) return void 0;
4026
+ return await response.text();
4027
+ } catch {
4028
+ return;
4029
+ } finally {
4030
+ clearTimeout(timer);
4031
+ }
4032
+ }
3454
4033
  //#endregion
3455
4034
  //#region lib/types/index.js
3456
4035
  /**
@@ -3667,7 +4246,7 @@ function editOutcomeOf(value) {
3667
4246
  if (typeof before !== "string" || typeof after !== "string") return void 0;
3668
4247
  return {
3669
4248
  path,
3670
- kind: "edit",
4249
+ earlierVersion: "file",
3671
4250
  oldText: before,
3672
4251
  newText: after
3673
4252
  };
@@ -3675,10 +4254,11 @@ function editOutcomeOf(value) {
3675
4254
  /**
3676
4255
  * Narrow a successful `write` result value to an operation outcome. The write
3677
4256
  * tool's output schema declares `{ path, operation, before, after }`;
3678
- * `operation: 'create'` becomes a `create` entry (revert removes the file),
3679
- * `operation: 'update'` becomes an `edit` entry. An update whose `before` is
3680
- * null carried no contextual basis, so it is skipped rather than tracked as
3681
- * an un-revertable overwrite.
4257
+ * `operation: 'create'` becomes an entry with `earlierVersion: 'none'` (there is
4258
+ * no earlier version, so the whole-file action removes the file),
4259
+ * `operation: 'update'` one with `earlierVersion: 'file'`. An update whose
4260
+ * `before` is null carried no contextual basis, so it is skipped rather than
4261
+ * tracked as an un-revertable overwrite.
3682
4262
  * @param value - the successful result's JSON value.
3683
4263
  * @returns the outcome, or `undefined` when the value is not a trackable write.
3684
4264
  */
@@ -3690,14 +4270,14 @@ function writeOutcomeOf(value) {
3690
4270
  if (typeof after !== "string") return void 0;
3691
4271
  if (operation === "create") return {
3692
4272
  path,
3693
- kind: "create",
4273
+ earlierVersion: "none",
3694
4274
  oldText: "",
3695
4275
  newText: after
3696
4276
  };
3697
4277
  if (typeof before !== "string") return void 0;
3698
4278
  return {
3699
4279
  path,
3700
- kind: "edit",
4280
+ earlierVersion: "file",
3701
4281
  oldText: before,
3702
4282
  newText: after
3703
4283
  };
@@ -3802,6 +4382,31 @@ function rpcError(message) {
3802
4382
  }
3803
4383
  };
3804
4384
  }
4385
+ /** The process's one update check: see {@link checkForUpdate}. */
4386
+ let updateCheck;
4387
+ /**
4388
+ * Whether a newer release of this package is published — the answer behind the channel's
4389
+ * `update-check` endpoint.
4390
+ *
4391
+ * ONE CHECK PER PROCESS, and what it caches is the REMOTE half only (`createUpdateCheck` in `version.ts`):
4392
+ * the registry's latest version, held for a short TTL, while the INSTALLED version is read from the manifest
4393
+ * on every call. So an upgrade, a reinstall or a downgrade under a running host is reflected immediately,
4394
+ * and a release published while the host runs appears within the TTL rather than at the next restart.
4395
+ *
4396
+ * NEVER THROWS and never rejects: the endpoint must not be able to turn a network
4397
+ * problem into an error in the reader's status bar. A failure to read the version at
4398
+ * all (a stripped install with no `package.json`) answers `newer: false`.
4399
+ *
4400
+ * The registry URL can be overridden by environment — `DSH_DIFF_APPROVAL_UPDATE_REGISTRY_URL` — because
4401
+ * the end-to-end case must not depend on the public internet. It is read HERE, on the first call, which is
4402
+ * after a test has set it and before the host answers anything. It is the ONLY such override.
4403
+ *
4404
+ * @returns what the panel needs to decide about its notice.
4405
+ */
4406
+ function checkForUpdate() {
4407
+ updateCheck ??= createUpdateCheck({ ...process.env.DSH_DIFF_APPROVAL_UPDATE_REGISTRY_URL === void 0 ? {} : { registryUrl: process.env.DSH_DIFF_APPROVAL_UPDATE_REGISTRY_URL } });
4408
+ return updateCheck();
4409
+ }
3805
4410
  /**
3806
4411
  * Mount the pending-edit review surface.
3807
4412
  * @param ctx - Cordis context carrying the filesystem, connection, and workspace registry services.
@@ -3845,12 +4450,14 @@ function apply(ctx, config) {
3845
4450
  * A comment whose quote is gone from the content is ABSENT from the map rather than guessed at:
3846
4451
  * the caller then falls back to `record.anchor`, the line the comment was written on, which is
3847
4452
  * the same answer an outdated thread shows.
3848
- * @param sessionId - the session whose comments to resolve.
4453
+ * @param listed - the comments this read is handing over: the caller took ONE scoped list and reuses it
4454
+ * here, because resolving lines is derived from a record's own fields (`quote`, `anchor`, `entryId`),
4455
+ * none of which a fold touches — so the pre-fold and post-fold lists resolve identically.
3849
4456
  * @returns the resolved lines per comment id; comments that do not resolve are left out.
3850
4457
  */
3851
- function resolvedCommentLines(sessionId) {
4458
+ function resolvedCommentLines(listed) {
3852
4459
  const resolved = {};
3853
- for (const comment of comments.list(sessionId)) {
4460
+ for (const comment of listed) {
3854
4461
  const entry = store.get(comment.entryId);
3855
4462
  if (entry === void 0) continue;
3856
4463
  const version = store.contentVersion(comment.entryId);
@@ -3909,10 +4516,16 @@ function apply(ctx, config) {
3909
4516
  };
3910
4517
  registerAnnotateTool(ctx, {
3911
4518
  ready: ensureLoaded,
3912
- entriesOf: (sessionId) => store.list(sessionId),
3913
- commentsOf: (sessionId) => comments.list(sessionId),
4519
+ entriesOf: (sessionId) => {
4520
+ const view = lineageView();
4521
+ return store.all().filter((entry) => view.sees(sessionId, entry));
4522
+ },
4523
+ commentsOf: (sessionId) => comments.list(commentScopeOf(sessionId, lineageView())),
3914
4524
  addComment: (record) => {
3915
- comments.add(record);
4525
+ comments.add(record.author === "agent" ? {
4526
+ ...record,
4527
+ unseen: true
4528
+ } : record);
3916
4529
  },
3917
4530
  listFile: (sessionId, asked, signal) => listFileForAnnotation(sessionId, asked, signal),
3918
4531
  log: (message) => ctx.logger.info(`diff-approval: ${message}`)
@@ -3954,11 +4567,12 @@ function apply(ctx, config) {
3954
4567
  id: absolute,
3955
4568
  sessionId,
3956
4569
  path: absolute,
3957
- kind: "edit",
4570
+ earlierVersion: "file",
3958
4571
  oldText: content,
3959
4572
  newText: content,
3960
4573
  updatedAt: Date.now(),
3961
- sessionIds: [sessionId]
4574
+ sessionIds: [sessionId],
4575
+ unseen: true
3962
4576
  }], true);
3963
4577
  const landed = store.list(sessionId).find((entry) => pathIdentity(entry.path) === pathIdentity(absolute));
3964
4578
  return landed === void 0 ? { kind: "missing" } : {
@@ -3987,28 +4601,30 @@ function apply(ctx, config) {
3987
4601
  const removed = store.remove(id);
3988
4602
  comments.removeForEntry(id);
3989
4603
  forgetCommentLinesForEntry(id);
4604
+ liveObservations.delete(id);
3990
4605
  return removed;
3991
4606
  }
3992
4607
  /** Pre-write bases captured at the intent seams, keyed by the tool call id. */
3993
4608
  const editorIntents = /* @__PURE__ */ new Map();
3994
4609
  /**
3995
- * Snapshot one str_replace_editor mutation's basis at its intent seam. A
3996
- * `create` has an empty basis; an edit reads the pre-write content. Any
3997
- * failure tracks nothing — the settle-side pairing then sees no basis.
4610
+ * Snapshot one str_replace_editor mutation's basis at its intent seam. A file
4611
+ * creation has no earlier version, so its basis is empty; an edit reads the
4612
+ * pre-write content. Any failure tracks nothing — the settle-side pairing then
4613
+ * sees no basis.
3998
4614
  * @param target - the resolved target about to be written.
3999
4615
  * @param actor - the tool execution running the mutation.
4000
- * @param kind - whether the mutation creates or edits the file.
4616
+ * @param earlierVersion - whether the mutation creates the file (`'none'`: no earlier version) or edits it (`'file'`).
4001
4617
  */
4002
- async function stashEditorIntent(target, actor, kind) {
4618
+ async function stashEditorIntent(target, actor, earlierVersion) {
4003
4619
  const shaped = actorOf(actor);
4004
4620
  if (shaped === void 0 || shaped.name !== "str_replace_editor") return;
4005
4621
  if (typeof shaped.callId !== "string") return;
4006
4622
  const sessionId = sessionOfAgent(shaped.agent);
4007
4623
  if (sessionId === void 0) return;
4008
- if (kind === "create") {
4624
+ if (earlierVersion === "none") {
4009
4625
  editorIntents.set(shaped.callId, {
4010
4626
  target,
4011
- kind,
4627
+ earlierVersion,
4012
4628
  before: "",
4013
4629
  sessionId
4014
4630
  });
@@ -4018,7 +4634,7 @@ function apply(ctx, config) {
4018
4634
  const before = await ctx.fs.readText(target, void 0) ?? "";
4019
4635
  editorIntents.set(shaped.callId, {
4020
4636
  target,
4021
- kind,
4637
+ earlierVersion,
4022
4638
  before,
4023
4639
  sessionId
4024
4640
  });
@@ -4037,29 +4653,43 @@ function apply(ctx, config) {
4037
4653
  if (basis === void 0 || basis.sessionId === void 0 || result.isError) return;
4038
4654
  const argumentsValue = exec.arguments;
4039
4655
  const command = typeof argumentsValue === "object" && argumentsValue !== null ? argumentsValue.command : void 0;
4040
- if (!(basis.kind === "create" ? command === "create" : command === "str_replace" || command === "insert")) return;
4656
+ if (!(basis.earlierVersion === "none" ? command === "create" : command === "str_replace" || command === "insert")) return;
4041
4657
  let after;
4042
4658
  try {
4043
4659
  after = await ctx.fs.readText(basis.target, void 0) ?? "";
4044
4660
  } catch {
4045
4661
  return;
4046
4662
  }
4047
- if (basis.kind === "edit" && after === basis.before) return;
4663
+ if (basis.earlierVersion === "file" && after === basis.before) return;
4048
4664
  const sessionId = basis.sessionId;
4049
4665
  const path = basis.target.displayPath;
4050
4666
  const entry = {
4051
4667
  id: path,
4052
4668
  sessionId,
4053
4669
  path,
4054
- kind: basis.kind,
4670
+ earlierVersion: basis.earlierVersion,
4055
4671
  oldText: basis.before,
4056
4672
  newText: after,
4057
4673
  updatedAt: Date.now(),
4058
- sessionIds: [sessionId]
4674
+ sessionIds: [sessionId],
4675
+ lineage: lineageOf(sessionId, exec.agent),
4676
+ unseen: true
4059
4677
  };
4060
4678
  await ensureLoaded();
4061
4679
  if (store.fold(entry)) persistSession();
4062
4680
  }
4681
+ /**
4682
+ * What was last READ from one tracked path, and the freshness token that read was taken at.
4683
+ *
4684
+ * `ctx.fs.stat` returns no `mtimeMs` (see `FsStat`): its freshness signal is `version`, an opaque token
4685
+ * the backend documents as "the freshness token a write/edit guards against" and derives from the file's
4686
+ * identity and times — the local backend builds it as `dev:ino:size:mtimeNs:ctimeNs`, so it moves when
4687
+ * the size or either timestamp does, which is exactly the `mtimeMs`+`size` signal wanted here. `size` is
4688
+ * carried beside it as a second, plain-text field.
4689
+ *
4690
+ * In memory only, one entry per tracked path: a restart has read nothing, so it observes afresh.
4691
+ */
4692
+ const liveObservations = /* @__PURE__ */ new Map();
4063
4693
  /** The stable `FsError.code`, when the thrown value carries one. */
4064
4694
  function fsErrorCodeOf(error) {
4065
4695
  if (typeof error !== "object" || error === null) return void 0;
@@ -4067,13 +4697,16 @@ function apply(ctx, config) {
4067
4697
  return typeof code === "string" ? code : void 0;
4068
4698
  }
4069
4699
  /**
4070
- * Read one path's live state. The only existence test is `stat`: it returns
4071
- * `undefined` for an absent target (gone), so a deleted file never falls into
4072
- * the unreadable bucket. A file that exists but cannot be read is `unavailable`.
4700
+ * Ask the backend where one tracked path is and what it is, reading NOTHING.
4701
+ *
4702
+ * This is the whole liveness test, shared by the full read (`liveStateOf`) and the light count
4703
+ * (`list-count`): `resolve` + `stat`, with `undefined` from `stat` meaning the target is absent, and a
4704
+ * thrown `FS_NOT_FOUND` meaning the same one step earlier. `version`/`size` come back as `undefined` when
4705
+ * the backend does not report them, which is what tells `liveStateOf` it has no freshness evidence.
4073
4706
  * @param path - backend display path to probe through `ctx.fs`.
4074
- * @returns the live state.
4707
+ * @returns what the backend says, and the target a caller may then read.
4075
4708
  */
4076
- async function liveStateOf(path) {
4709
+ async function probePath(path) {
4077
4710
  let target;
4078
4711
  try {
4079
4712
  target = await ctx.fs.resolve(path, {});
@@ -4087,12 +4720,55 @@ function apply(ctx, config) {
4087
4720
  return fsErrorCodeOf(error) === "FS_NOT_FOUND" ? { kind: "deleted" } : { kind: "unavailable" };
4088
4721
  }
4089
4722
  if (info === void 0) return { kind: "deleted" };
4723
+ return {
4724
+ kind: "present",
4725
+ target,
4726
+ version: typeof info.version === "string" && info.version.length > 0 ? info.version : void 0,
4727
+ size: typeof info.size === "number" ? info.size : void 0
4728
+ };
4729
+ }
4730
+ /**
4731
+ * Read one path's live state. The only existence test is `stat`: it returns
4732
+ * `undefined` for an absent target (gone), so a deleted file never falls into
4733
+ * the unreadable bucket. A file that exists but cannot be read is `unavailable`.
4734
+ *
4735
+ * The CONTENT is not re-read when the backend's own freshness evidence says the file has not moved since
4736
+ * the last observation: an identical `version` AND an identical numeric `size`, both reported. The stat
4737
+ * still runs — it is the cheap half, and it is what notices a file that has gone — so a deleted file is
4738
+ * still `deleted`, a moved one is read again, and `diverged`/`missing` are computed from real content
4739
+ * either way. The skip needs BOTH fields: a stat that reports no size is a weaker observation than the
4740
+ * contract allows, so it falls back to reading rather than trusting a token alone.
4741
+ * @param path - backend display path to probe through `ctx.fs`.
4742
+ * @returns the live state.
4743
+ */
4744
+ async function liveStateOf(path) {
4745
+ const probe = await probePath(path);
4746
+ if (probe.kind !== "present") {
4747
+ liveObservations.delete(path);
4748
+ return probe;
4749
+ }
4750
+ const { target, version, size } = probe;
4751
+ const trustable = version !== void 0 && size !== void 0;
4752
+ if (trustable) {
4753
+ const observed = liveObservations.get(path);
4754
+ if (observed !== void 0 && observed.version === version && observed.size === size) return {
4755
+ kind: "present",
4756
+ content: observed.content
4757
+ };
4758
+ }
4090
4759
  try {
4760
+ const content = await ctx.fs.readText(target, void 0);
4761
+ if (trustable) liveObservations.set(path, {
4762
+ version,
4763
+ size,
4764
+ content
4765
+ });
4091
4766
  return {
4092
4767
  kind: "present",
4093
- content: await ctx.fs.readText(target, void 0)
4768
+ content
4094
4769
  };
4095
4770
  } catch (error) {
4771
+ liveObservations.delete(path);
4096
4772
  return fsErrorCodeOf(error) === "FS_NOT_FOUND" ? { kind: "deleted" } : { kind: "unavailable" };
4097
4773
  }
4098
4774
  }
@@ -4124,12 +4800,17 @@ function apply(ctx, config) {
4124
4800
  * list and has its undo/redo records dropped, and externally changed content
4125
4801
  * is adopted as the new baseline with its own checkpoint.
4126
4802
  * @param sessionId - the session being listed (its session-scoped view).
4803
+ * @param view - the request's lineage view: the caller builds it once and hands it here, because the
4804
+ * list read needs the same view for the comment scope, the answer fold and the undo keys, and a
4805
+ * second `lineageView()` would walk the store again for nothing.
4806
+ * @param entries - that request's ONE `store.all()` snapshot (see `lineageView`).
4127
4807
  * @returns the listed entries plus whether an external change cleared redo.
4128
4808
  */
4129
- async function listWithState(sessionId) {
4809
+ async function listWithState(sessionId, view, entries) {
4130
4810
  const listed = [];
4131
4811
  let redoCleared = false;
4132
- for (const entry of store.list(sessionId)) {
4812
+ for (const entry of entries) {
4813
+ if (!view.sees(sessionId, entry)) continue;
4133
4814
  const live = await liveStateOf(entry.path);
4134
4815
  if (live.kind === "deleted") {
4135
4816
  const checkpoint = droppingComments({
@@ -4144,7 +4825,7 @@ function apply(ctx, config) {
4144
4825
  path: entry.path,
4145
4826
  entry: void 0,
4146
4827
  fileText: void 0
4147
- });
4828
+ }, view);
4148
4829
  persistSession();
4149
4830
  continue;
4150
4831
  }
@@ -4162,6 +4843,7 @@ function apply(ctx, config) {
4162
4843
  const beforeText = entry.newText;
4163
4844
  const redoWasPresent = redoStackOf(sessionId).length > 0;
4164
4845
  store.update(entry.path, { newText: content });
4846
+ store.markUnseen(entry.path);
4165
4847
  pushUndo(sessionId, {
4166
4848
  id: entry.path,
4167
4849
  path: entry.path,
@@ -4176,7 +4858,7 @@ function apply(ctx, config) {
4176
4858
  updatedAt: Date.now()
4177
4859
  },
4178
4860
  fileText: content
4179
- });
4861
+ }, view);
4180
4862
  persistSession();
4181
4863
  if (redoWasPresent) redoCleared = true;
4182
4864
  }
@@ -4184,10 +4866,16 @@ function apply(ctx, config) {
4184
4866
  missing: false,
4185
4867
  diverged: hasContent ? content !== adopted : true
4186
4868
  };
4869
+ const viaLineage = ownersOf(entry).includes(sessionId) ? void 0 : true;
4870
+ const hasChildContribution = ownersOf(entry).some((owner) => owner !== sessionId && view.sameRoot(owner, sessionId));
4871
+ const lineageDirection = view.directionOf(sessionId, entry);
4187
4872
  listed.push({
4188
4873
  ...entry,
4189
4874
  newText: adopted,
4190
- ...state
4875
+ ...state,
4876
+ viaLineage,
4877
+ hasChildContribution,
4878
+ lineageDirection
4191
4879
  });
4192
4880
  }
4193
4881
  return {
@@ -4217,7 +4905,7 @@ function apply(ctx, config) {
4217
4905
  changedPaths.push(entry.path);
4218
4906
  }
4219
4907
  if (folded === 0) return 0;
4220
- persistSession(true);
4908
+ await persistSession(true);
4221
4909
  const after = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
4222
4910
  const batchBefore = [];
4223
4911
  const batchAfter = [];
@@ -4326,6 +5014,47 @@ function apply(ctx, config) {
4326
5014
  for (const workspace of ctx.workspaceRegistry.list()) if (workspace.sessionIds.includes(sessionId)) return workspace;
4327
5015
  }
4328
5016
  /**
5017
+ * The identity one session's workspace is compared by, or `undefined` when this
5018
+ * deployment does not account the session.
5019
+ *
5020
+ * The merged view needs it because the pending list is NOT scoped by workspace: the store is one map
5021
+ * keyed by absolute path (`pending.ts`) persisted to one `pending.json` under the storage root
5022
+ * (`persist.ts`), and that root comes from the plugin's `storageDir` alone — the `workspaces/`
5023
+ * component of the default path is the LEGACY per-workspace layout whose files are folded in and
5024
+ * deleted on load. Nothing else in the list path consults the workspace, so a row recorded by a
5025
+ * session in another one would otherwise be listed under this session's view.
5026
+ *
5027
+ * `resolve` folds separators and `.`/`..`; Windows additionally compares case-insensitively, the same
5028
+ * way `withinSandboxRoot` and the backend's own containment do (`C:\Repo` and `c:\repo` are one
5029
+ * directory there). An ABSENT workspace, or one the registry records with no path, answers
5030
+ * `undefined` — "not known", never a guess: the caller keeps the pre-workspace behaviour for it
5031
+ * rather than refusing a row it cannot show to be elsewhere.
5032
+ * @param sessionId - the session whose workspace is wanted.
5033
+ * @returns the comparison key, or `undefined` when it is not known.
5034
+ */
5035
+ function workspaceKeyOf(sessionId) {
5036
+ const path = workspaceOf(sessionId)?.path;
5037
+ if (typeof path !== "string" || path.length === 0) return void 0;
5038
+ const resolved = resolve(path);
5039
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
5040
+ }
5041
+ /**
5042
+ * Whether two workspaces, already reduced to comparison keys by {@link workspaceKeyOf}, are the same
5043
+ * one as far as this deployment can say.
5044
+ *
5045
+ * TRUE when either side is unknown, which is the whole point: an unknown workspace must not turn into
5046
+ * a refusal. The pending list is not scoped by workspace (see `workspaceKeyOf`), so a session the
5047
+ * registry does not account — a memory-only session, a row from a host that named no workspace, a
5048
+ * deployment whose registry the plugin cannot read — would otherwise lose rows it has always shown.
5049
+ * The check is for the case this code can actually see: two KNOWN roots that differ.
5050
+ * @param left - one session's key, or `undefined` when unknown.
5051
+ * @param right - the other's.
5052
+ * @returns whether they can be shown to share a workspace.
5053
+ */
5054
+ function sameWorkspaceKey(left, right) {
5055
+ return left === void 0 || right === void 0 || left === right;
5056
+ }
5057
+ /**
4329
5058
  * Resolve the file-sandbox policy for one session's Revert write, or
4330
5059
  * undefined when no confining backend is mounted. A live session carries
4331
5060
  * its workspace root through `ctx.sessions`; a persisted entry from a
@@ -4346,6 +5075,326 @@ function apply(ctx, config) {
4346
5075
  };
4347
5076
  }
4348
5077
  /**
5078
+ * The session header entry lineage is read from.
5079
+ *
5080
+ * Two routes, in the order a capture can answer them. The live one is the tool execution's own agent:
5081
+ * `Agent` is AUGMENTED at run time with `session` (dsh-agent's `runtime-types`), and the DSH's own
5082
+ * bundled plugins read exactly that on a `tools/*` execution — `dsh-tool-present` uses
5083
+ * `exec.agent.session.header.cwd` and `dsh-experimental-agent-team` reads
5084
+ * `agent.session.header.parentSession` for precisely the lineage question this records. `Agent` is typed
5085
+ * `{ id }` in the types this package compiles against (the augmentation is not among them), so the field
5086
+ * is read structurally — the same way `sessionOfAgent` reads `id` off the same object.
5087
+ *
5088
+ * The fallback is this plugin's own session registry, which `sandboxPolicyOf` already consults
5089
+ * (`ctx.sessions.get(id)`); it is what answers when the agent carries no `session`, and it is also the
5090
+ * only route at a capture site that has no execution in hand (the editor-intent seam, version control,
5091
+ * a hand-added path). It is read through `ctx.get`, the way this file reads every optional service
5092
+ * (`sandboxPolicy`, `shell`): the capture must never fail because the lineage could not be asked for,
5093
+ * and a context that was never handed a session service throws on the plain property instead.
5094
+ * @param sessionId - the session whose header is wanted.
5095
+ * @param agent - the live agent of a tool execution, when the caller has one.
5096
+ * @returns the header, or `undefined` when neither route has one.
5097
+ */
5098
+ function headerOf(sessionId, agent) {
5099
+ if (typeof agent === "object" && agent !== null) {
5100
+ const session = agent.session;
5101
+ if (typeof session === "object" && session !== null) {
5102
+ const header = session.header;
5103
+ if (typeof header === "object" && header !== null) return header;
5104
+ }
5105
+ }
5106
+ return sessionRegistry()?.get(sessionId)?.header;
5107
+ }
5108
+ /**
5109
+ * The lineage one entry records for its session, read off that session's own header.
5110
+ *
5111
+ * Every part is taken on its own and only when it is there, and NO lineage is recorded when the header
5112
+ * held none of it. Absence is the honest value: an ordinary root session has no parent by design, and a
5113
+ * host older than these header fields reports none of them — while a merged view has to be able to tell
5114
+ * "this session is a root" from "this host could not say", and guessing a root would file a session
5115
+ * under the wrong one.
5116
+ * @param sessionId - the session the entry belongs to.
5117
+ * @param agent - the live agent, when the capture has one (see `headerOf`).
5118
+ * @returns the lineage to store, or `undefined` when the header named none of it.
5119
+ */
5120
+ function lineageOf(sessionId, agent) {
5121
+ const header = headerOf(sessionId, agent);
5122
+ if (header === void 0) return void 0;
5123
+ const parent = header.parentSession;
5124
+ const depth = header.delegationDepth;
5125
+ const lineage = {
5126
+ parentSessionId: typeof parent === "string" && parent.length > 0 ? SessionId(parent) : void 0,
5127
+ origin: typeof header.origin === "string" && header.origin.length > 0 ? header.origin : void 0,
5128
+ delegationDepth: typeof depth === "number" && Number.isFinite(depth) ? depth : void 0,
5129
+ cwd: typeof header.cwd === "string" && header.cwd.length > 0 ? header.cwd : void 0
5130
+ };
5131
+ return lineage.parentSessionId !== void 0 || lineage.origin !== void 0 || lineage.delegationDepth !== void 0 || lineage.cwd !== void 0 ? lineage : void 0;
5132
+ }
5133
+ /** The session registry, read the way this file reads every optional service (see `headerOf`). */
5134
+ function sessionRegistry() {
5135
+ return ctx.get("sessions");
5136
+ }
5137
+ /** The most lineage hops one walk will take: a bound, not a depth anyone should reach. */
5138
+ const MAX_LINEAGE_DEPTH = 16;
5139
+ /** Every session that touched an entry (its own fallback, the way the store reads an old row).
5140
+ *
5141
+ * PROVENANCE, and the merge's basis: it is who WROTE the row, which is what the row's mark and its
5142
+ * `viaLineage` flag are made of. It is deliberately NOT the write policy any more — an action's write
5143
+ * runs under the REQUESTING session (see `DiffApprovalUndoPair.policySession` and the
5144
+ * `revertEntryContent` call sites), so nothing here decides who may write.
5145
+ */
5146
+ function ownersOf(entry) {
5147
+ return Array.isArray(entry.sessionIds) && entry.sessionIds.length > 0 ? entry.sessionIds : [entry.sessionId];
5148
+ }
5149
+ /**
5150
+ * What one session is known by when a lineage is walked: the lineage STEP 1 recorded for it, and
5151
+ * otherwise the live header.
5152
+ *
5153
+ * Recorded first is deliberate: it is what the row itself says, it survives a session that is no longer
5154
+ * live, and it is the same answer the entry carries — so the walk and the row cannot disagree. The
5155
+ * registry is the fallback for a session that recorded nothing (a row written before this step, or a
5156
+ * session whose entries have all been settled away).
5157
+ *
5158
+ * `subagent` is the only field the walk trusts for a LINK: `parentSession` alone is not one. The header
5159
+ * documents it as "the session this one was forked from (seed lineage)", so a host fork or a seeded
5160
+ * continuation carries it while being an INDEPENDENT ROOT — the Team package reads exactly that
5161
+ * distinction (`dsh-experimental-agent-team/lib/types/roster.js:76-81`: "Ordinary host forks are
5162
+ * independent roots"). `origin: 'subagent'` is the header's own marker for a session "created as a
5163
+ * subagent child", and the one function that builds a child's meta sets it together with `parentSession`
5164
+ * and `delegationDepth` (`dsh-subagent/lib/types/child-agent.js:111-125`).
5165
+ * @param sessionId - the session being asked about.
5166
+ * @param recorded - the lineages step 1 recorded, read once per request.
5167
+ * @returns whether the session is a subagent child, and the parent it names when it is.
5168
+ */
5169
+ function knownLineageOf(sessionId, recorded) {
5170
+ const entry = recorded.get(sessionId);
5171
+ if (entry !== void 0) return {
5172
+ parent: entry.parentSessionId,
5173
+ subagent: entry.origin === "subagent"
5174
+ };
5175
+ const header = sessionRegistry()?.get(sessionId)?.header;
5176
+ const parent = header?.parentSession;
5177
+ return {
5178
+ parent: typeof parent === "string" && parent.length > 0 ? SessionId(parent) : void 0,
5179
+ subagent: header?.origin === "subagent"
5180
+ };
5181
+ }
5182
+ /**
5183
+ * The lineage view one request is answered from — which sessions' entries the requester may SEE, and
5184
+ * therefore act on.
5185
+ *
5186
+ * The rule is ROOT-EQUAL **AND SAME-WORKSPACE**: an entry is visible when any session that touched it
5187
+ * walks to the same lineage root as the requester AND can be shown to sit in the requester's workspace.
5188
+ * Walking is `knownLineageOf` links only, so a teammate's rows appear in the lead's list (both walk to
5189
+ * the lead) and the lead's appear in the teammate's — while a host fork, which names a parent but is not
5190
+ * a subagent child, stays an independent root and shares nothing.
5191
+ *
5192
+ * The workspace half is not redundant with the lineage half, because NOTHING ELSE IN THE LIST PATH
5193
+ * SCOPES BY WORKSPACE: the store is one map keyed by absolute path persisted to one `pending.json` under
5194
+ * the storage root (`pending.ts`, `persist.ts` — the `workspaces/` component of the default path is the
5195
+ * legacy per-workspace layout, folded in and deleted on load). So without this a row recorded by a
5196
+ * session in ANOTHER workspace would be listed here whenever the two share a lineage root — which is a
5197
+ * real shape: this view merges every subagent child (see the KNOWN LIMIT below), and the harness's
5198
+ * out-of-process shape lets a child run under a configured `cwd` of its own, its cwd being documented as
5199
+ * the child's "workspace identity" (`dsh-subagent/lib/types/out-of-process.js`:
5200
+ * `resolveChildCwd`/`assertUsableCwd`). An in-process child copies its parent's cwd verbatim
5201
+ * (`…/child-agent.js:childSessionMeta`), so the two agree there; this rule is what makes the plugin's
5202
+ * answer independent of which of those two shapes it is handed.
5203
+ *
5204
+ * A session the registry does not account is NOT refused: `sameWorkspace` answers true when either
5205
+ * side's workspace is unknown, so an unaccounted or memory-only session keeps exactly the view it had
5206
+ * before this term existed. UNKNOWN DEGRADES TO SELF on the lineage half and TO THE OLD ANSWER here —
5207
+ * never to a guess, and never to a refusal the reader cannot see the reason for.
5208
+ *
5209
+ * UNKNOWN DEGRADES TO SELF, never to a guess: with no recorded lineage and no header facts every
5210
+ * session is its own root, so only the requester matches — byte-for-byte today's behaviour. A cycle
5211
+ * (which no shell should produce) stops at the first repeat, and the walk is bounded by
5212
+ * {@link MAX_LINEAGE_DEPTH}.
5213
+ *
5214
+ * TWO THINGS A MERGED VIEW DOES NOT MERGE, so the next reader does not expect them:
5215
+ * comments stay per-session (`comments` is read with the requester's id, so a merged list shows the
5216
+ * requester's threads only), and undo stacks stay per-lineage — every ACTION records its pair under the
5217
+ * canonical session of the seat that PRESSED it, so the seats of one lineage share one history and a
5218
+ * different lineage cannot reach into it (`undoStackOf`/`popOwnPair` are keyed by that root). The
5219
+ * session-scoped thing a merged press does NOT take from the ROW is the write policy: an action's file
5220
+ * write runs under the REQUESTING session's own workspace and sandbox (see
5221
+ * `DiffApprovalUndoPair.policySession`), never the owner's.
5222
+ *
5223
+ * THE MARK (`hasChildContribution`) IS A DIFFERENT QUESTION and keeps asking only the lineage half: it
5224
+ * claims an owner other than the requester WROTE part of the row, which a child in another workspace
5225
+ * really did. Adding the workspace term there would make that copy lie, so it deliberately does not ask
5226
+ * it (see `sameRoot` below).
5227
+ *
5228
+ * KNOWN LIMIT: a teammate child cannot be told from ANY OTHER subagent child by its header. Both are
5229
+ * built by the one `childSessionMeta` above, and the Team spawner adds nothing to the child — it passes
5230
+ * only `childId`/`provider`/`label` into `ctx.subagents.startContinuable`
5231
+ * (`dsh-experimental-agent-team/lib/types/roster.js:253-262`) and records membership in the LEAD's own
5232
+ * journal (`:249`), which this plugin cannot read. So this view merges every subagent child, not only
5233
+ * teammates; narrowing it to the roster would need a service the plugin does not inject.
5234
+ * @param entries - the store's entries, when the caller already took ONE snapshot for the whole request
5235
+ * (`store.all()` rebuilds and re-sorts the array, so a read takes it once and hands it down). Defaults
5236
+ * to a fresh snapshot for the callers that only need the walk.
5237
+ * @returns the view: `sees` answers the visibility question for one entry, `sameRoot` answers whether two
5238
+ * sessions share a lineage root (the question the row's mark asks about its other owners), `directionOf`
5239
+ * says WHICH WAY that other owner stands for the sentence the mark wears, and `rootOf` is the CANONICAL
5240
+ * session — the lineage root, or the session itself when its lineage is unknown — that every
5241
+ * session-scoped store is keyed by (see `canonicalOf`).
5242
+ */
5243
+ function lineageView(entries = store.all()) {
5244
+ const recorded = /* @__PURE__ */ new Map();
5245
+ for (const entry of entries) if (entry.lineage !== void 0 && !recorded.has(entry.sessionId)) recorded.set(entry.sessionId, entry.lineage);
5246
+ const facts = /* @__PURE__ */ new Map();
5247
+ /**
5248
+ * One session's UPWARD CHAIN: itself, then each recorded subagent parent above it, in order.
5249
+ *
5250
+ * This is the one walk the whole view is made of. `rootOf` is its last element, and the mark's
5251
+ * DIRECTION is a membership question over two of these chains — so the merge, the root and the
5252
+ * direction can never be computed from different links. The links are `knownLineageOf`'s, which reads
5253
+ * the lineage the entry RECORDED before it asks the live header (see that function), and only an
5254
+ * `origin: 'subagent'` link is followed: a host fork names a parent while being an independent root.
5255
+ *
5256
+ * Memoized per view like `roots` was, because the list read asks this per entry per owner on a path
5257
+ * every client repeats once a second. The walk stops at a repeat (`walked`) and at
5258
+ * {@link MAX_LINEAGE_DEPTH}, so a malformed cycle cannot hang it — the same two guards the root walk
5259
+ * has always had.
5260
+ */
5261
+ const chains = /* @__PURE__ */ new Map();
5262
+ /**
5263
+ * Each session's workspace key, resolved once per view.
5264
+ *
5265
+ * Memoized for the same reason `chains` is: this runs inside `sees`, which the list read calls once per
5266
+ * entry per owner (`listWithState`), on a path every client repeats once a second — and the registry
5267
+ * lookup walks the deployment's workspace list. `has` rather than a truthy check so "known to be
5268
+ * unknown" is cached as such instead of being asked again for an unaccounted session.
5269
+ */
5270
+ const workspaceKeys = /* @__PURE__ */ new Map();
5271
+ const workspaceKey = (sessionId) => {
5272
+ if (workspaceKeys.has(sessionId)) return workspaceKeys.get(sessionId);
5273
+ const key = workspaceKeyOf(sessionId);
5274
+ workspaceKeys.set(sessionId, key);
5275
+ return key;
5276
+ };
5277
+ const chainOf = (sessionId) => {
5278
+ const known = chains.get(sessionId);
5279
+ if (known !== void 0) return known;
5280
+ const chain = [sessionId];
5281
+ const walked = /* @__PURE__ */ new Set([sessionId]);
5282
+ let current = sessionId;
5283
+ for (let depth = 0; depth < MAX_LINEAGE_DEPTH; depth += 1) {
5284
+ let fact = facts.get(current);
5285
+ if (fact === void 0) {
5286
+ fact = knownLineageOf(current, recorded);
5287
+ facts.set(current, fact);
5288
+ }
5289
+ if (!fact.subagent || fact.parent === void 0 || walked.has(fact.parent)) break;
5290
+ walked.add(fact.parent);
5291
+ chain.push(fact.parent);
5292
+ current = fact.parent;
5293
+ }
5294
+ chains.set(sessionId, chain);
5295
+ return chain;
5296
+ };
5297
+ /** The top of one session's chain: the lineage root both `sees` and `sameRoot` compare. */
5298
+ const rootOf = (sessionId) => {
5299
+ const chain = chainOf(sessionId);
5300
+ return chain[chain.length - 1];
5301
+ };
5302
+ return {
5303
+ sees: (sessionId, entry) => {
5304
+ const root = rootOf(sessionId);
5305
+ const mine = workspaceKey(sessionId);
5306
+ return ownersOf(entry).some((owner) => {
5307
+ if (rootOf(owner) !== root) return false;
5308
+ if (owner === sessionId) return true;
5309
+ return sameWorkspaceKey(workspaceKey(owner), mine);
5310
+ });
5311
+ },
5312
+ sameRoot: (a, b) => rootOf(a) === rootOf(b),
5313
+ rootOf,
5314
+ /**
5315
+ * Which way the row's other contributors stand, for the marker's sentence.
5316
+ *
5317
+ * Read off the SAME recorded links `rootOf` walks, never guessed and never inferred from a name: an
5318
+ * owner is the requester's CHILD when the requester sits on that owner's upward chain, its PARENT
5319
+ * when that owner sits on the requester's chain, and a SIBLING when the two merely share a root.
5320
+ * `parent` therefore means ANCESTOR, however many hops up — which is exactly what the Chinese 上级
5321
+ * says and what the English copy is written to mean.
5322
+ *
5323
+ * Owners of DIFFERENT directions answer `mixed`: the row carries two different relationships and
5324
+ * picking one would state a fact about the row that is only true of part of it. The set is
5325
+ * unresolvable in the honest direction too — "another session" is true of every member — which is
5326
+ * why `mixed` is a real answer and not a failure. `undefined` when no other same-root owner
5327
+ * contributed, i.e. when there is no mark to word.
5328
+ *
5329
+ * The criterion is `hasChildContribution`'s exactly (non-requester, same root) — the workspace term
5330
+ * belongs to visibility, not to this claim (see `sameRoot`).
5331
+ * @param sessionId - the session listing the row.
5332
+ * @param entry - the row being listed.
5333
+ * @returns the direction, or `undefined` when nothing but the lister touched the row.
5334
+ */
5335
+ directionOf: (sessionId, entry) => {
5336
+ const root = rootOf(sessionId);
5337
+ const mine = chainOf(sessionId);
5338
+ const seen = /* @__PURE__ */ new Set();
5339
+ for (const owner of ownersOf(entry)) {
5340
+ if (owner === sessionId) continue;
5341
+ if (rootOf(owner) !== root) continue;
5342
+ if (chainOf(owner).includes(sessionId)) seen.add("child");
5343
+ else if (mine.includes(owner)) seen.add("parent");
5344
+ else seen.add("sibling");
5345
+ }
5346
+ if (seen.size === 0) return void 0;
5347
+ return seen.size === 1 ? [...seen][0] : "mixed";
5348
+ }
5349
+ };
5350
+ }
5351
+ /**
5352
+ * THE CANONICALIZATION SEAM: the one session-scoped state of a request belongs to.
5353
+ *
5354
+ * The lineage ROOT, because the seats of one lineage read one list — so the undo history they share and
5355
+ * the comment threads they read must be keyed by the lineage, not by whichever seat is looking. A
5356
+ * session whose lineage is unknown (no recorded link, no header facts) is its own root, which is
5357
+ * byte-for-byte the behaviour before any of this existed.
5358
+ *
5359
+ * Every session-scoped seam goes through this function, and the endpoints that already hold a view pass
5360
+ * it so the walk is done once per request (`view` is optional only so a push deep inside a helper cannot
5361
+ * forget to canonicalize; it never means "do not canonicalize").
5362
+ * @param sessionId - the session the request came from.
5363
+ * @param view - the request's lineage view, when the caller already built one.
5364
+ * @returns the canonical session: the lineage root, or `sessionId` itself when it has none.
5365
+ */
5366
+ function canonicalOf(sessionId, view) {
5367
+ return (view ?? lineageView()).rootOf(sessionId);
5368
+ }
5369
+ /**
5370
+ * The comment scope of one request: which comment AUTHORS this lineage may read and act on.
5371
+ *
5372
+ * `sameRoot` is the view's own question, so the scope and the merge cannot disagree about who is kin —
5373
+ * and it is the same predicate for reading, removing, marking seen and asking, so no verb can widen or
5374
+ * narrow the lineage on its own.
5375
+ * @param sessionId - the session the request came from.
5376
+ * @param view - the request's lineage view.
5377
+ * @returns the predicate the comment store's `CommentScope` takes.
5378
+ */
5379
+ function commentScopeOf(sessionId, view) {
5380
+ return (author) => view.sameRoot(author, sessionId);
5381
+ }
5382
+ /**
5383
+ * The entry one id names, when the requesting session may act on it; `undefined` otherwise.
5384
+ *
5385
+ * Every action path resolves its entry through this, so an id outside the requester's lineage is
5386
+ * answered exactly like an id that names nothing — `missing` — and no action can reach a row the
5387
+ * requester cannot see.
5388
+ * @param view - the request's lineage view.
5389
+ * @param sessionId - the requesting session.
5390
+ * @param id - the entry id (its path).
5391
+ * @returns the entry, or `undefined` when it is absent or outside the view.
5392
+ */
5393
+ function actionableEntryOf(view, sessionId, id) {
5394
+ const entry = store.get(id);
5395
+ return entry !== void 0 && view.sees(sessionId, entry) ? entry : void 0;
5396
+ }
5397
+ /**
4349
5398
  * Write a Revert through `ctx.fs`, carrying the session's sandbox policy
4350
5399
  * only when a confining backend is mounted (so the plain 4-arg call shape is
4351
5400
  * preserved for the unsandboxed composition).
@@ -4379,7 +5428,7 @@ function apply(ctx, config) {
4379
5428
  async function removeRevert(target, sessionId, signal) {
4380
5429
  const policy = sandboxPolicyOf(sessionId);
4381
5430
  const osPath = processPathOf(target);
4382
- if (policy !== void 0 && !withinSandboxRoot(osPath, policy)) throw new Error(`file access denied under ${policy.mode} mode: '${osPath}' is outside the sandbox workspace root '${policy.workspaceRoot}'`);
5431
+ if (policy !== void 0 && !withinSandboxRoot(osPath, policy)) throw new Error(sandboxDenial(osPath, policy));
4383
5432
  const fs = ctx.fs;
4384
5433
  if (policy !== void 0 && typeof fs.remove === "function") {
4385
5434
  await fs.remove(target, policy, signal);
@@ -4410,10 +5459,157 @@ function apply(ctx, config) {
4410
5459
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
4411
5460
  }
4412
5461
  /**
5462
+ * The sentence a confining policy uses when it refuses one path, wherever it is produced.
5463
+ *
5464
+ * ONE producer, so the check a delete performs, the preflight a restore runs and the message a reader is
5465
+ * handed can never describe the refusal differently.
5466
+ * @param path - the OS path the policy refused.
5467
+ * @param policy - the session's resolved policy.
5468
+ * @returns the refusal sentence.
5469
+ */
5470
+ function sandboxDenial(path, policy) {
5471
+ return `file access denied under ${policy.mode} mode: '${path}' is outside the sandbox workspace root '${policy.workspaceRoot}'`;
5472
+ }
5473
+ /**
5474
+ * Whether a thrown write error is the session's own AUTHORITY refusing it, rather than the file being
5475
+ * gone, changed, or unreadable.
5476
+ *
5477
+ * Exactly two shapes qualify, and nothing else does: a confining backend raises `FS_SANDBOX_DENIED` (the
5478
+ * `FsErrorCode` the fs package owns for "the policy layer said no"), and this file's own containment
5479
+ * check in `removeRevert` raises a plain Error saying the path is outside the sandbox workspace root.
5480
+ * Everything else keeps its own answer: `FS_NOT_FOUND` is a missing file, the divergence guard's "changed
5481
+ * outside the review" is a diverged one, `FS_PERMISSION_DENIED` is the FILE SYSTEM refusing (a fact about
5482
+ * the file, not about this session's grant) and the rest are IO or aborted faults. The predicate is
5483
+ * deliberately this narrow: widening it is how a reader loses the difference between "I may not write
5484
+ * there" and "that file is gone".
5485
+ * @param error - the value a write path threw.
5486
+ * @returns whether the sandbox/policy refused the write.
5487
+ */
5488
+ function isPolicyRefusal(error) {
5489
+ if (fsErrorCodeOf(error) === "FS_SANDBOX_DENIED") return true;
5490
+ return /outside the sandbox workspace root/i.test(errorMessage(error));
5491
+ }
5492
+ /**
5493
+ * The answer for a write the session's authority refused: a sentence that NAMES THE PATH and says, in
5494
+ * words, that this session may not write there, carrying the sandbox's own reason with it.
5495
+ *
5496
+ * Why TEXT and not a code: the RPC error vocabulary this channel answers in is closed and owned by the
5497
+ * vendor (`RpcErrorDetailsMap` in `@deepseek-ai/dsh-host-apiproxy` — `internal` is its catch-all, and a
5498
+ * new code needs a row in that table), so this package cannot mint a `permission` code of its own. The
5499
+ * reader still gets the sentence: the port folds a failed call into `Error(code: message)` and the
5500
+ * panel's failed-row notice prints that message verbatim, so the refusal arrives as words on the path the
5501
+ * panel already has for a failed action. A LOCALIZED route would need a client-side classifier over the
5502
+ * message plus new locale keys — a copy change across the host/client seam — for no difference the reader
5503
+ * can see, since every other message on this channel is host English too.
5504
+ * @param path - the file the refused write was about (the entry's own path).
5505
+ * @param error - what the write threw.
5506
+ * @returns the error branch to answer with.
5507
+ */
5508
+ function policyRefusal(path, error) {
5509
+ return rpcError(refusalText(path, error));
5510
+ }
5511
+ /** The refusal sentence itself, shared by every path that answers with one. */
5512
+ function refusalText(path, error) {
5513
+ return `'${path}' is outside this session's write authority: the workspace this session may write does not cover it. The sandbox refused the write: ${errorMessage(error)}`;
5514
+ }
5515
+ /**
5516
+ * Every file write a restore will make, in the order it will make them.
5517
+ *
5518
+ * Mirrors `restoreState`'s own walk, index-aligned with the counterpart side, so the preflight below and
5519
+ * the writer can never disagree about WHICH files a restore touches. A state with no `fileText` writes
5520
+ * nothing: it puts an entry back in the list (or takes it out) and never opens a file.
5521
+ * @param state - the side being installed.
5522
+ * @param expectedFile - the other side, whose per-index `fileText` is the divergence snapshot.
5523
+ * @returns the writes, in order.
5524
+ */
5525
+ function restoreWritesOf(state, expectedFile) {
5526
+ const writes = [];
5527
+ if (state.batch !== void 0) {
5528
+ const counterpart = expectedFile?.batch;
5529
+ for (const [index, item] of state.batch.entries()) writes.push(...restoreWritesOf({
5530
+ ...item,
5531
+ batch: void 0
5532
+ }, counterpart?.[index]));
5533
+ return writes;
5534
+ }
5535
+ if (state.fileText !== void 0) writes.push({
5536
+ path: state.path,
5537
+ expected: expectedFile?.fileText
5538
+ });
5539
+ return writes;
5540
+ }
5541
+ /**
5542
+ * Check EVERY write a restore will make before it makes the first one, and refuse the whole restore if any
5543
+ * of them would be refused.
5544
+ *
5545
+ * WHAT IS GUARANTEED, and what is not (this is the honest half of the design):
5546
+ *
5547
+ * - AUTHORITY is guaranteed all-or-nothing. Each target is checked with the SAME predicate the write path
5548
+ * enforces (`withinSandboxRoot`, the one `removeRevert` refuses with and the policy `writeRevert` hands
5549
+ * the backend), under `pair.policySession` — the seat the action ran under. A refusal anywhere means no
5550
+ * file is written at all, so an undo can never take back half of a decision. This is a check made before
5551
+ * the first byte, so it is a guarantee rather than a hope.
5552
+ * - DIVERGENCE is also preflighted, because it is cheap and detectable up front: a target whose
5553
+ * counterpart snapshot no longer matches the file refuses the whole restore rather than stopping
5554
+ * half-way through it.
5555
+ * - A RUNTIME failure is NOT preflightable and is not promised away: a file that vanishes between this
5556
+ * check and the write, or an IO fault on the third of five files, can still leave the earlier writes
5557
+ * applied. When that happens the caller reports exactly how far the restore got (`RestoreProgress`)
5558
+ * instead of claiming nothing happened. Do not read this function as an atomicity promise; it is a
5559
+ * permission and staleness promise, and the mid-way case is reported as the partial state it is.
5560
+ * @param writes - the plan, from `restoreWritesOf`.
5561
+ * @param policySession - the session whose policy the write runs under (the pair's recorded seat).
5562
+ * @param signal - aborts the checks too.
5563
+ * @returns the first refusal (its path and the reason to report), or undefined when the plan may run.
5564
+ */
5565
+ async function preflightRestore(writes, policySession, signal) {
5566
+ const policy = sandboxPolicyOf(policySession);
5567
+ for (const write of writes) {
5568
+ let resolved;
5569
+ try {
5570
+ resolved = await ctx.fs.resolve(write.path, { signal });
5571
+ } catch (error) {
5572
+ return {
5573
+ path: write.path,
5574
+ error
5575
+ };
5576
+ }
5577
+ if (policy !== void 0 && !withinSandboxRoot(processPathOf(resolved), policy)) return {
5578
+ path: write.path,
5579
+ error: new Error(sandboxDenial(processPathOf(resolved), policy))
5580
+ };
5581
+ if (write.expected !== void 0) {
5582
+ if (await ctx.fs.readText(resolved, void 0) !== write.expected) return {
5583
+ path: write.path,
5584
+ error: /* @__PURE__ */ new Error("the file changed outside the review after the action; undo is unavailable")
5585
+ };
5586
+ }
5587
+ }
5588
+ }
5589
+ /**
5590
+ * The answer for a restore that did not finish: the refusal sentence when the sandbox was the reason, the
5591
+ * ordinary `undo failed:`/`redo failed:` text otherwise — and in EVERY case, when the restore had already
5592
+ * written something, how far it got. "Never that nothing happened": a partial restore is reported as the
5593
+ * partial state it is.
5594
+ * @param action - which direction was running.
5595
+ * @param path - the file the failure is about.
5596
+ * @param error - what the restore threw.
5597
+ * @param progress - the writes it completed, and how many it had to make.
5598
+ * @returns the error branch to answer with.
5599
+ */
5600
+ function restoreFailure(action, path, error, progress) {
5601
+ if (isPolicyRefusal(error)) {
5602
+ const far = progress.written === 0 ? "" : ` The ${action} had already written ${progress.written} of ${progress.total} files before that.`;
5603
+ return rpcError(refusalText(path, error) + far);
5604
+ }
5605
+ return rpcError(`${action} failed${progress.written === 0 ? "" : ` after writing ${progress.written} of ${progress.total} files`}: ${errorMessage(error)}`);
5606
+ }
5607
+ /**
4413
5608
  * Revert one entry's file back to its baseline (the shared per-entry logic
4414
- * behind both the single `revert` endpoint and the bulk `revert-all`). A
4415
- * created file's revert deletes it (not undoable: the file is gone), an edit
4416
- * writes the baseline back and yields the before/after undo snapshot.
5609
+ * behind both the single `revert` endpoint and the bulk `revert-all`). An
5610
+ * entry with no earlier version (`earlierVersion: 'none'`) has its file
5611
+ * deleted (not undoable: the file is gone); one that has an earlier version
5612
+ * (`'file'`) writes it back and yields the before/after undo snapshot.
4417
5613
  * @param entry - the entry to revert.
4418
5614
  * @param sessionId - the entry's session (for the per-session write policy).
4419
5615
  * @param signal - aborts before atomic publication takes effect.
@@ -4421,7 +5617,7 @@ function apply(ctx, config) {
4421
5617
  */
4422
5618
  async function revertEntryContent(entry, sessionId, signal) {
4423
5619
  const resolved = await ctx.fs.resolve(entry.path, { signal });
4424
- if (entry.kind === "create") {
5620
+ if (entry.earlierVersion === "none") {
4425
5621
  await removeRevert(resolved, sessionId, signal);
4426
5622
  return;
4427
5623
  }
@@ -4445,33 +5641,91 @@ function apply(ctx, config) {
4445
5641
  }
4446
5642
  const undoStacks = /* @__PURE__ */ new Map();
4447
5643
  const redoStacks = /* @__PURE__ */ new Map();
4448
- /** The undo stack of one session, created on first use. */
4449
- function undoStackOf(sessionId) {
4450
- let stack = undoStacks.get(sessionId);
5644
+ /** The undo stack of one lineage root, created on first use. Its key is a CANONICAL session. */
5645
+ function undoStackOf(root) {
5646
+ let stack = undoStacks.get(root);
4451
5647
  if (stack === void 0) {
4452
5648
  stack = [];
4453
- undoStacks.set(sessionId, stack);
5649
+ undoStacks.set(root, stack);
4454
5650
  }
4455
5651
  return stack;
4456
5652
  }
4457
- /** The redo stack of one session, created on first use. */
4458
- function redoStackOf(sessionId) {
4459
- let stack = redoStacks.get(sessionId);
5653
+ /** The redo stack of one lineage root, created on first use. Its key is a CANONICAL session. */
5654
+ function redoStackOf(root) {
5655
+ let stack = redoStacks.get(root);
4460
5656
  if (stack === void 0) {
4461
5657
  stack = [];
4462
- redoStacks.set(sessionId, stack);
5658
+ redoStacks.set(root, stack);
4463
5659
  }
4464
5660
  return stack;
4465
5661
  }
4466
- /** Record one undoable action in its own session's history. A fresh action
4467
- * invalidates that session's redo history (and only that session's). */
4468
- function pushUndo(sessionId, before, after) {
4469
- undoStackOf(sessionId).push({
4470
- sessionId,
5662
+ /**
5663
+ * One entry as an undo target sees it, for the "did this really change?" comparison.
5664
+ *
5665
+ * `updatedAt` is deliberately NOT in the key: every action stamps it with `Date.now()`, so including it
5666
+ * would make every recorded target look changed — the exact false positive the comparison exists to stop.
5667
+ */
5668
+ function entryKeyOf(entry) {
5669
+ if (entry === void 0) return "(no entry)";
5670
+ return JSON.stringify(entry, (key, value) => key === "updatedAt" ? void 0 : value);
5671
+ }
5672
+ /** One side's comment records as a comparable key (a pair that carries threads changes when they do). */
5673
+ function commentsKeyOf(comments) {
5674
+ return (comments ?? []).map((comment) => `${comment.id}@${comment.anchor.startLine}-${comment.anchor.endLine}:${comment.text}`).join("|");
5675
+ }
5676
+ /**
5677
+ * Whether one target of a forward action really changed anything.
5678
+ *
5679
+ * THE RULE the reader asked for: a pair records only what changed. `before` is what the action found and
5680
+ * `after` is what it left — which is also what the file and the entry hold NOW, since the action has just
5681
+ * finished — so comparing the two sides is comparing the target against the world. Three things can
5682
+ * differ and each counts on its own: the file bytes (`fileText`, with `undefined` meaning "no write"), the
5683
+ * entry (its content-bearing fields; see `entryKeyOf`), and a comment pair's records. A target where none
5684
+ * of them differs is not a change and is not recorded, so Ctrl+Z cannot walk a file the action never
5685
+ * moved.
5686
+ *
5687
+ * A BATCH state is not compared here: its members are filtered one by one in `pushBatchUndo`, which is
5688
+ * where the index alignment lives.
5689
+ * @param before - the state the action found.
5690
+ * @param after - the state it left.
5691
+ * @returns whether recording this target would give the reader something to take back.
5692
+ */
5693
+ function stateChanged(before, after) {
5694
+ if (before.batch !== void 0 || after.batch !== void 0) return true;
5695
+ if (before.kind === "comments" || after.kind === "comments") return commentsKeyOf(before.comments) !== commentsKeyOf(after.comments);
5696
+ if (before.fileText !== after.fileText) return true;
5697
+ return entryKeyOf(before.entry) !== entryKeyOf(after.entry);
5698
+ }
5699
+ /**
5700
+ * Record one undoable action in its lineage's history. A fresh action invalidates that lineage's redo
5701
+ * history (and only that lineage's).
5702
+ *
5703
+ * THE RULE: the pair is filed under the CANONICAL session, so every seat of one lineage shares one
5704
+ * history and any of them can take the action back; the session that pressed is remembered on the pair
5705
+ * (`pressedBy` for attribution, and `policySession` for the write the RESTORE runs) — and the write
5706
+ * policy is recorded rather than re-derived, because the seat that POPS a pair need not be the seat that
5707
+ * made it. Canonicalizing HERE rather than at the call site is what makes the rule unforgettable: every
5708
+ * push in this file goes through this function.
5709
+ *
5710
+ * `policySession` is the presser because the forward action's own write is the requester's (see the
5711
+ * `revertEntryContent` call sites) — so every push here records the seat whose authority the action ran
5712
+ * under, and the restore replays exactly that.
5713
+ * @param sessionId - the session that pressed the action.
5714
+ * @param before - the state the action found, and what a restore puts back.
5715
+ * @param after - the state the action left, and what a redo re-applies.
5716
+ * @param view - the request's lineage view, when the caller already built one.
5717
+ */
5718
+ function pushUndo(sessionId, before, after, view) {
5719
+ if (!stateChanged(before, after)) return;
5720
+ const root = canonicalOf(sessionId, view);
5721
+ undoStackOf(root).push({
5722
+ root,
5723
+ pressedBy: sessionId,
5724
+ policySession: sessionId,
4471
5725
  before,
4472
5726
  after
4473
5727
  });
4474
- redoStackOf(sessionId).length = 0;
5728
+ redoStackOf(root).length = 0;
4475
5729
  }
4476
5730
  /**
4477
5731
  * Record several entries' worth of ONE decision as a single undo step.
@@ -4480,36 +5734,45 @@ function apply(ctx, config) {
4480
5734
  * take the whole thing back. A loop of single actions would push one step per entry instead, and undo
4481
5735
  * would then peel that one decision apart file by file.
4482
5736
  *
4483
- * @param sessionId - the session whose history this belongs to.
5737
+ * @param sessionId - the session that pressed.
4484
5738
  * @param before - one state per entry, in the order the decision touched them.
4485
5739
  * @param after - the same entries as the decision left them; the two arrays are index-aligned, which
4486
5740
  * is also what the divergence guard on a restore reads (see `restoreState`).
5741
+ * @param view - the request's lineage view, when the caller already built one.
4487
5742
  */
4488
- function pushBatchUndo(sessionId, before, after) {
4489
- if (before.length === 0) return;
5743
+ function pushBatchUndo(sessionId, before, after, view) {
5744
+ const changedBefore = [];
5745
+ const changedAfter = [];
5746
+ for (const [index, item] of before.entries()) {
5747
+ const counterpart = after[index];
5748
+ if (counterpart === void 0 || !stateChanged(item, counterpart)) continue;
5749
+ changedBefore.push(item);
5750
+ changedAfter.push(counterpart);
5751
+ }
5752
+ if (changedBefore.length === 0) return;
4490
5753
  pushUndo(sessionId, {
4491
- id: before[0].id,
4492
- path: before[0].path,
5754
+ id: changedBefore[0].id,
5755
+ path: changedBefore[0].path,
4493
5756
  entry: void 0,
4494
5757
  fileText: void 0,
4495
- batch: before
5758
+ batch: changedBefore
4496
5759
  }, {
4497
- id: after[0].id,
4498
- path: after[0].path,
5760
+ id: changedAfter[0].id,
5761
+ path: changedAfter[0].path,
4499
5762
  entry: void 0,
4500
5763
  fileText: void 0,
4501
- batch: after
4502
- });
4503
- }
4504
- /** Drop the top pair of one stack when it belongs to `sessionId`; otherwise the
4505
- * stack is left exactly as it was, so another session's action is untouched.
4506
- * @param stack - the session's stack (undo or redo).
4507
- * @param sessionId - the session the request is for.
4508
- * @returns the pair to move, or undefined when this session has nothing to move.
5764
+ batch: changedAfter
5765
+ }, view);
5766
+ }
5767
+ /** Drop the top pair of one lineage root's stack, when it belongs to that root; otherwise the
5768
+ * stack is left exactly as it was, so another lineage's action is untouched.
5769
+ * @param stack - the root's stack (undo or redo).
5770
+ * @param root - the canonical session the request is for.
5771
+ * @returns the pair to move, or undefined when this lineage has nothing to move.
4509
5772
  */
4510
- function popOwnPair(stack, sessionId) {
5773
+ function popOwnPair(stack, root) {
4511
5774
  const pair = stack[stack.length - 1];
4512
- if (pair === void 0 || pair.sessionId !== sessionId) return void 0;
5775
+ if (pair === void 0 || pair.root !== root) return void 0;
4513
5776
  stack.pop();
4514
5777
  return pair;
4515
5778
  }
@@ -4578,16 +5841,18 @@ function apply(ctx, config) {
4578
5841
  *
4579
5842
  * Nothing here touches `ctx.fs` or the pending store: a comment is not a change to a file, and a
4580
5843
  * restore that also wrote one would turn "take the comment back" into "take the edit back".
4581
- * @param sessionId - the session the pair was taken in (a pair's comments are one session's).
4582
5844
  * @param state - the side to install.
4583
5845
  * @param expectedFile - the other side; the comments only it lists are the ones to remove.
5846
+ * @param scope - which comment authors this restore may remove: the undo's lineage. The stale side is
5847
+ * the comments the OTHER side held, and they belong to whichever seats wrote them, so the removal has
5848
+ * to be allowed to reach every file the pair's records live in (see `CommentStore.removeMany`).
4584
5849
  */
4585
- function restoreComments(sessionId, state, expectedFile) {
5850
+ function restoreComments(state, expectedFile, scope) {
4586
5851
  const installing = state.comments ?? [];
4587
5852
  const installed = new Set(installing.map((record) => record.id));
4588
5853
  const stale = (expectedFile?.comments ?? []).map((record) => record.id).filter((id) => !installed.has(id));
4589
5854
  if (stale.length > 0) {
4590
- comments.removeMany(sessionId, stale);
5855
+ comments.removeMany(scope, stale);
4591
5856
  for (const id of stale) commentLines.delete(id);
4592
5857
  }
4593
5858
  if (installing.length > 0) comments.addMany(installing);
@@ -4600,14 +5865,18 @@ function apply(ctx, config) {
4600
5865
  * only after the write succeeds, keeping the restore all-or-nothing — and the
4601
5866
  * comments a dropping action removed are the LAST thing put back, so a refused
4602
5867
  * restore cannot leave a thread on a row that did not come back.
4603
- * @param sessionId - the owning session.
5868
+ * @param sessionId - the session the policy is resolved from (the pair's presser — see `undoStackOf`).
4604
5869
  * @param state - the snapshot to restore.
4605
5870
  * @param expectedFile - the other side's file content, checked before a write.
4606
5871
  * @param signal - aborts before atomic publication takes effect.
5872
+ * @param scope - which comment authors this restore may remove (the undo request's lineage).
5873
+ * @param progress - counts the file writes as they land, so a mid-way failure can report exactly how far
5874
+ * it got instead of claiming nothing happened (see `restoreFailure`). Omitted where the caller has no
5875
+ * use for it; a restore that gets no `progress` is unchanged.
4607
5876
  */
4608
- async function restoreState(sessionId, state, expectedFile, signal) {
5877
+ async function restoreState(sessionId, state, expectedFile, signal, scope, progress) {
4609
5878
  if (state.kind === "comments") {
4610
- restoreComments(sessionId, state, expectedFile);
5879
+ restoreComments(state, expectedFile, scope);
4611
5880
  return;
4612
5881
  }
4613
5882
  if (state.fileText !== void 0) {
@@ -4615,14 +5884,16 @@ function apply(ctx, config) {
4615
5884
  if (expectedFile?.fileText !== void 0) {
4616
5885
  if (await ctx.fs.readText(resolved, void 0) !== expectedFile.fileText) throw new Error("the file changed outside the review after the action; undo is unavailable");
4617
5886
  }
5887
+ if (progress !== void 0) progress.path = state.path;
4618
5888
  await writeRevert(resolved, state.fileText, sessionId, signal);
5889
+ if (progress !== void 0) progress.written += 1;
4619
5890
  }
4620
5891
  if (state.batch !== void 0) {
4621
5892
  const counterpart = expectedFile?.batch;
4622
5893
  for (const [index, item] of state.batch.entries()) await restoreState(sessionId, {
4623
5894
  ...item,
4624
5895
  batch: void 0
4625
- }, counterpart?.[index], signal);
5896
+ }, counterpart?.[index], signal, scope, progress);
4626
5897
  return;
4627
5898
  }
4628
5899
  if (state.entry !== void 0) store.restore(state.entry);
@@ -4666,6 +5937,21 @@ function apply(ctx, config) {
4666
5937
  }
4667
5938
  /** Mark the store dirty and schedule one throttled, coalesced write. Pass
4668
5939
  * `force` to write immediately (user actions need durable, immediate results). */
5940
+ /**
5941
+ * Mark the list dirty and start its write. The throttle is for the panel's own churn; `force` writes
5942
+ * NOW and, since 2026-10-06, RETURNS the write's promise.
5943
+ *
5944
+ * That return value is a durability guarantee, not a convenience: a caller that is about to write
5945
+ * something NAMING an entry — the annotate tool's comment — has to know the entry is readable at the
5946
+ * next boot first. Folded in memory and written on a later flush, an entry can be lost to a host that
5947
+ * stops in between, and the load-time sweep then hides (and used to delete) the comments that named
5948
+ * it. Awaiting here is what makes "the comment exists" imply "its entry was on disk first".
5949
+ *
5950
+ * @param force - write immediately rather than on the throttle's schedule.
5951
+ * @returns a promise that settles when the write this call started has finished (never rejects: a
5952
+ * failure is logged and reported through `persistFailureReported`, because a store that cannot write
5953
+ * must keep answering reads).
5954
+ */
4669
5955
  function persistSession(force = false) {
4670
5956
  persistDirty = true;
4671
5957
  if (force) {
@@ -4673,20 +5959,18 @@ function apply(ctx, config) {
4673
5959
  clearTimeout(persistTimer);
4674
5960
  persistScheduled = false;
4675
5961
  }
4676
- flushPersist();
4677
- return;
5962
+ return flushPersist();
4678
5963
  }
4679
- if (persistScheduled) return;
5964
+ if (persistScheduled) return Promise.resolve();
4680
5965
  const delay = PERSIST_THROTTLE_MS - (Date.now() - lastPersistAt);
4681
- if (delay <= 0) flushPersist();
4682
- else {
4683
- persistScheduled = true;
4684
- persistTimer = setTimeout(() => {
4685
- persistScheduled = false;
4686
- flushPersist();
4687
- }, delay);
4688
- persistTimer.unref?.();
4689
- }
5966
+ if (delay <= 0) return flushPersist();
5967
+ persistScheduled = true;
5968
+ persistTimer = setTimeout(() => {
5969
+ persistScheduled = false;
5970
+ flushPersist();
5971
+ }, delay);
5972
+ persistTimer.unref?.();
5973
+ return Promise.resolve();
4690
5974
  }
4691
5975
  ctx.on("tools/result", (exec, result) => {
4692
5976
  if (exec.name === "str_replace_editor") {
@@ -4702,31 +5986,47 @@ function apply(ctx, config) {
4702
5986
  sessionId,
4703
5987
  ...outcome,
4704
5988
  updatedAt: Date.now(),
4705
- sessionIds: [sessionId]
5989
+ sessionIds: [sessionId],
5990
+ lineage: lineageOf(sessionId, exec.agent),
5991
+ unseen: true
4706
5992
  };
4707
5993
  if (store.fold(entry)) persistSession();
4708
5994
  });
4709
5995
  const handle = async (endpoint, payload, signal) => {
4710
5996
  switch (endpoint) {
5997
+ case "update-check": return {
5998
+ ok: true,
5999
+ value: await checkForUpdate()
6000
+ };
4711
6001
  case "list": {
4712
6002
  const sessionId = sessionOf(payload);
4713
6003
  if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
4714
6004
  await ensureLoaded();
4715
- if (comments.retain(new Set(store.all().map((entry) => entry.id))) > 0) forgetOrphanedCommentLines();
4716
- const { files, redoCleared } = await listWithState(sessionId);
6005
+ const entries = store.all();
6006
+ const view = lineageView(entries);
6007
+ if (comments.retain(new Set(entries.map((entry) => entry.id))) > 0) forgetOrphanedCommentLines();
6008
+ const { files, redoCleared } = await listWithState(sessionId, view, entries);
6009
+ const scope = commentScopeOf(sessionId, view);
6010
+ const visibleComments = comments.list(scope);
6011
+ let folded = false;
4717
6012
  const commentAnswers = {};
4718
- if (comments.list(sessionId).length > 0) {
4719
- const answers = commentAsker.answers(sessionId);
4720
- for (const [requestId, read] of Object.entries(answers)) if (read.turn !== void 0) comments.recordTurnForRequest(sessionId, requestId, read.turn);
4721
- for (const turn of commentAsker.endedTurns(sessionId)) comments.markTurnEnded(sessionId, turn);
4722
- for (const [requestId, read] of Object.entries(answers)) if (read.answer !== void 0) commentAnswers[requestId] = read.answer;
6013
+ if (visibleComments.length > 0) {
6014
+ const transcripts = /* @__PURE__ */ new Map();
6015
+ for (const [transcript, requestIds] of commentAsker.answerGroups(visibleComments)) {
6016
+ const answers = commentAsker.answersFor(transcript, requestIds, transcripts);
6017
+ for (const [requestId, read] of Object.entries(answers)) if (read.turn !== void 0 && comments.recordTurnForRequest(requestId, read.turn) > 0) folded = true;
6018
+ for (const turn of commentAsker.endedTurns(transcript, transcripts)) if (comments.markTurnEnded(transcript, turn) > 0) folded = true;
6019
+ if (comments.syncAnswers(transcript, answers)) folded = true;
6020
+ for (const [requestId, read] of Object.entries(answers)) if (read.answer !== void 0) commentAnswers[requestId] = read.answer;
6021
+ }
4723
6022
  }
6023
+ const listedComments = folded ? comments.list(scope) : visibleComments;
4724
6024
  return {
4725
6025
  ok: true,
4726
6026
  value: {
4727
6027
  files,
4728
- comments: comments.list(sessionId),
4729
- commentLines: resolvedCommentLines(sessionId),
6028
+ comments: listedComments,
6029
+ commentLines: resolvedCommentLines(listedComments),
4730
6030
  commentsRevision: comments.commentsRevision(),
4731
6031
  commentAnswers,
4732
6032
  workspacePath: workspaceOf(sessionId)?.path,
@@ -4737,11 +6037,44 @@ function apply(ctx, config) {
4737
6037
  }
4738
6038
  };
4739
6039
  }
6040
+ case "list-count": {
6041
+ const sessionId = sessionOf(payload);
6042
+ if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
6043
+ await ensureLoaded();
6044
+ const entries = store.all();
6045
+ const view = lineageView(entries);
6046
+ let count = 0;
6047
+ for (const entry of entries) {
6048
+ if (!view.sees(sessionId, entry)) continue;
6049
+ if ((await probePath(entry.path)).kind !== "present") continue;
6050
+ count += 1;
6051
+ }
6052
+ return {
6053
+ ok: true,
6054
+ value: { count }
6055
+ };
6056
+ }
6057
+ case "comment-seen": {
6058
+ const seen = targetOf(payload);
6059
+ if (seen === void 0) return rpcError("sessionId and id must be non-empty strings");
6060
+ await ensureLoaded();
6061
+ const record = comments.get(seen.id);
6062
+ if (record === void 0 || !commentScopeOf(seen.sessionId, lineageView())(record.sessionId)) return {
6063
+ ok: true,
6064
+ value: { outcome: "missing" }
6065
+ };
6066
+ comments.markSeen(seen.id);
6067
+ return {
6068
+ ok: true,
6069
+ value: { outcome: "seen" }
6070
+ };
6071
+ }
4740
6072
  case "comment-add": {
4741
6073
  const input = commentAddOf(payload);
4742
6074
  if (input === void 0) return rpcError("sessionId, entryId, anchor, quote and text must be valid");
4743
6075
  await ensureLoaded();
4744
- const entry = store.list(input.sessionId).find((candidate) => candidate.id === input.entryId);
6076
+ const view = lineageView();
6077
+ const entry = actionableEntryOf(view, input.sessionId, input.entryId);
4745
6078
  if (entry === void 0) return {
4746
6079
  ok: true,
4747
6080
  value: { outcome: "missing" }
@@ -4761,7 +6094,7 @@ function apply(ctx, config) {
4761
6094
  ...input.quoteLines === void 0 ? {} : { quoteLines: input.quoteLines }
4762
6095
  };
4763
6096
  comments.add(record);
4764
- pushUndo(input.sessionId, commentState(entry.path, []), commentState(entry.path, [record]));
6097
+ pushUndo(input.sessionId, commentState(entry.path, []), commentState(entry.path, [record]), view);
4765
6098
  return {
4766
6099
  ok: true,
4767
6100
  value: {
@@ -4774,10 +6107,11 @@ function apply(ctx, config) {
4774
6107
  const target = targetOf(payload);
4775
6108
  if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
4776
6109
  await ensureLoaded();
6110
+ const view = lineageView();
4777
6111
  const record = comments.get(target.id);
4778
- const removed = comments.remove(target.sessionId, target.id);
6112
+ const removed = comments.remove(commentScopeOf(target.sessionId, view), target.id);
4779
6113
  const value = { outcome: removed ? "removed" : "missing" };
4780
- if (removed && record !== void 0) pushUndo(target.sessionId, commentState(record.entryId, [record]), commentState(record.entryId, []));
6114
+ if (removed && record !== void 0) pushUndo(target.sessionId, commentState(record.entryId, [record]), commentState(record.entryId, []), view);
4781
6115
  commentLines.delete(target.id);
4782
6116
  return {
4783
6117
  ok: true,
@@ -4788,11 +6122,13 @@ function apply(ctx, config) {
4788
6122
  const request = commentRemoveManyOf(payload);
4789
6123
  if (request === void 0) return rpcError("sessionId and a non-empty ids array of strings must be given");
4790
6124
  await ensureLoaded();
4791
- const snapshot = request.ids.map((id) => comments.get(id)).filter((record) => record !== void 0 && record.sessionId === request.sessionId);
4792
- const removed = comments.removeMany(request.sessionId, request.ids);
6125
+ const view = lineageView();
6126
+ const scope = commentScopeOf(request.sessionId, view);
6127
+ const snapshot = request.ids.map((id) => comments.get(id)).filter((record) => record !== void 0 && scope(record.sessionId));
6128
+ const removed = comments.removeMany(scope, request.ids);
4793
6129
  for (const id of removed) commentLines.delete(id);
4794
6130
  const first = snapshot[0];
4795
- if (first !== void 0) pushUndo(request.sessionId, commentState(first.entryId, snapshot), commentState(first.entryId, []));
6131
+ if (first !== void 0) pushUndo(request.sessionId, commentState(first.entryId, snapshot), commentState(first.entryId, []), view);
4796
6132
  return {
4797
6133
  ok: true,
4798
6134
  value: { removed: removed.length }
@@ -4802,18 +6138,20 @@ function apply(ctx, config) {
4802
6138
  const input = commentAskOf(payload);
4803
6139
  if (input === void 0) return rpcError("sessionId, id, prompt and text must be valid");
4804
6140
  await ensureLoaded();
6141
+ const view = lineageView();
4805
6142
  return {
4806
6143
  ok: true,
4807
- value: await commentAsker.ask(input.sessionId, input.id, input.prompt, input.text, signal)
6144
+ value: await commentAsker.ask(input.sessionId, input.id, input.prompt, input.text, signal, commentScopeOf(input.sessionId, view))
4808
6145
  };
4809
6146
  }
4810
6147
  case "keep": {
4811
6148
  const target = targetOf(payload);
4812
6149
  if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
4813
- let entry = store.get(target.id);
6150
+ const lineage = lineageView();
6151
+ let entry = actionableEntryOf(lineage, target.sessionId, target.id);
4814
6152
  if (entry === void 0) {
4815
6153
  await ensureLoaded();
4816
- entry = store.get(target.id);
6154
+ entry = actionableEntryOf(lineage, target.sessionId, target.id);
4817
6155
  if (entry === void 0) return {
4818
6156
  ok: true,
4819
6157
  value: { outcome: "missing" }
@@ -4868,10 +6206,11 @@ function apply(ctx, config) {
4868
6206
  case "revert": {
4869
6207
  const target = targetOf(payload);
4870
6208
  if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
4871
- let entry = store.get(target.id);
6209
+ const lineage = lineageView();
6210
+ let entry = actionableEntryOf(lineage, target.sessionId, target.id);
4872
6211
  if (entry === void 0) {
4873
6212
  await ensureLoaded();
4874
- entry = store.get(target.id);
6213
+ entry = actionableEntryOf(lineage, target.sessionId, target.id);
4875
6214
  if (entry === void 0) return {
4876
6215
  ok: true,
4877
6216
  value: { outcome: "missing" }
@@ -4881,6 +6220,7 @@ function apply(ctx, config) {
4881
6220
  try {
4882
6221
  undo = await revertEntryContent(entry, target.sessionId, signal);
4883
6222
  } catch (error) {
6223
+ if (isPolicyRefusal(error)) return policyRefusal(entry.path, error);
4884
6224
  return rpcError(`revert failed: ${errorMessage(error)}`);
4885
6225
  }
4886
6226
  if (payload.keepListed === true) {
@@ -4927,7 +6267,8 @@ function apply(ctx, config) {
4927
6267
  const sessionId = sessionOf(payload);
4928
6268
  if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
4929
6269
  await ensureLoaded();
4930
- const entries = store.list(sessionId);
6270
+ const lineage = lineageView();
6271
+ const entries = store.all().filter((entry) => lineage.sees(sessionId, entry));
4931
6272
  const before = [];
4932
6273
  const after = [];
4933
6274
  for (const entry of entries) {
@@ -4956,16 +6297,22 @@ function apply(ctx, config) {
4956
6297
  const sessionId = sessionOf(payload);
4957
6298
  if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
4958
6299
  await ensureLoaded();
4959
- const entries = store.list(sessionId);
6300
+ const lineage = lineageView();
6301
+ const entries = store.all().filter((entry) => lineage.sees(sessionId, entry));
4960
6302
  const batchBefore = [];
4961
6303
  const batchAfter = [];
6304
+ /** How many files this press had already put back when it stopped: said, never hidden (see 3c). */
6305
+ let reverted = 0;
4962
6306
  for (const entry of entries) {
4963
6307
  let undo;
4964
6308
  try {
4965
6309
  undo = await revertEntryContent(entry, sessionId, signal);
4966
- } catch {
4967
- return rpcError(`revert-all failed for ${entry.path}`);
6310
+ } catch (error) {
6311
+ const far = reverted === 0 ? "" : ` (this press had already reverted ${reverted} of ${entries.length} files)`;
6312
+ if (isPolicyRefusal(error)) return rpcError(refusalText(entry.path, error) + far);
6313
+ return rpcError(`revert-all failed for ${entry.path}${far}`);
4968
6314
  }
6315
+ reverted += 1;
4969
6316
  if (undo !== void 0) {
4970
6317
  batchBefore.push(droppingComments(undo.before));
4971
6318
  batchAfter.push(undo.after);
@@ -4983,10 +6330,11 @@ function apply(ctx, config) {
4983
6330
  const request = manyTargetsOf(payload);
4984
6331
  if (request === void 0) return rpcError("sessionId and a non-empty ids array of strings must be given");
4985
6332
  await ensureLoaded();
6333
+ const lineage = lineageView();
4986
6334
  const before = [];
4987
6335
  const after = [];
4988
6336
  for (const id of request.ids) {
4989
- const entry = store.get(id);
6337
+ const entry = actionableEntryOf(lineage, request.sessionId, id);
4990
6338
  if (entry === void 0) continue;
4991
6339
  if (request.keepListed === true) {
4992
6340
  store.update(id, { oldText: entry.newText });
@@ -5033,18 +6381,21 @@ function apply(ctx, config) {
5033
6381
  const request = manyTargetsOf(payload);
5034
6382
  if (request === void 0) return rpcError("sessionId and a non-empty ids array of strings must be given");
5035
6383
  await ensureLoaded();
6384
+ const lineage = lineageView();
5036
6385
  const before = [];
5037
6386
  const after = [];
5038
6387
  let affected = 0;
5039
6388
  for (const id of request.ids) {
5040
- const entry = store.get(id);
6389
+ const entry = actionableEntryOf(lineage, request.sessionId, id);
5041
6390
  if (entry === void 0) continue;
5042
6391
  affected += 1;
5043
6392
  let undo;
5044
6393
  try {
5045
6394
  undo = await revertEntryContent(entry, request.sessionId, signal);
5046
- } catch {
5047
- return rpcError(`revert-many failed for ${entry.path}`);
6395
+ } catch (error) {
6396
+ const far = affected - 1 === 0 ? "" : ` (this pick had already reverted ${affected - 1} of ${request.ids.length} files)`;
6397
+ if (isPolicyRefusal(error)) return rpcError(refusalText(entry.path, error) + far);
6398
+ return rpcError(`revert-many failed for ${entry.path}${far}`);
5048
6399
  }
5049
6400
  if (request.keepListed === true) {
5050
6401
  const content = undo?.after.fileText ?? "";
@@ -5086,7 +6437,7 @@ function apply(ctx, config) {
5086
6437
  const blockTarget = blockTargetOf(payload);
5087
6438
  if (blockTarget === void 0) return rpcError("sessionId, id, and block must be valid");
5088
6439
  await ensureLoaded();
5089
- const entry = store.get(blockTarget.id);
6440
+ const entry = actionableEntryOf(lineageView(), blockTarget.sessionId, blockTarget.id);
5090
6441
  if (entry === void 0) return {
5091
6442
  ok: true,
5092
6443
  value: { outcome: "missing" }
@@ -5140,7 +6491,7 @@ function apply(ctx, config) {
5140
6491
  const blockTarget = blockTargetOf(payload);
5141
6492
  if (blockTarget === void 0) return rpcError("sessionId, id, and block must be valid");
5142
6493
  await ensureLoaded();
5143
- const entry = store.get(blockTarget.id);
6494
+ const entry = actionableEntryOf(lineageView(), blockTarget.sessionId, blockTarget.id);
5144
6495
  if (entry === void 0) return {
5145
6496
  ok: true,
5146
6497
  value: { outcome: "missing" }
@@ -5157,7 +6508,7 @@ function apply(ctx, config) {
5157
6508
  let undo;
5158
6509
  try {
5159
6510
  const target = await ctx.fs.resolve(entry.path, { signal });
5160
- if (entry.kind === "create" && content === "") await removeRevert(target, blockTarget.sessionId, signal);
6511
+ if (entry.earlierVersion === "none" && content === "") await removeRevert(target, blockTarget.sessionId, signal);
5161
6512
  else {
5162
6513
  const preWrite = await ctx.fs.readText(target, void 0) ?? entry.newText;
5163
6514
  await writeRevert(target, content, blockTarget.sessionId, signal);
@@ -5177,6 +6528,7 @@ function apply(ctx, config) {
5177
6528
  };
5178
6529
  }
5179
6530
  } catch (error) {
6531
+ if (isPolicyRefusal(error)) return policyRefusal(entry.path, error);
5180
6532
  return rpcError(`block revert failed: ${errorMessage(error)}`);
5181
6533
  }
5182
6534
  if (undo !== void 0) pushUndo(blockTarget.sessionId, undo.before, undo.after);
@@ -5209,18 +6561,35 @@ function apply(ctx, config) {
5209
6561
  case "undo": {
5210
6562
  const sessionId = sessionOf(payload);
5211
6563
  if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
5212
- const pair = popOwnPair(undoStackOf(sessionId), sessionId);
6564
+ const view = lineageView();
6565
+ const root = canonicalOf(sessionId, view);
6566
+ const pair = popOwnPair(undoStackOf(root), root);
5213
6567
  if (pair === void 0) return {
5214
6568
  ok: true,
5215
6569
  value: { outcome: "nothing" }
5216
6570
  };
6571
+ const writes = restoreWritesOf(pair.before, pair.after);
6572
+ const refused = await preflightRestore(writes, pair.policySession, signal);
6573
+ if (refused !== void 0) {
6574
+ undoStackOf(root).push(pair);
6575
+ return restoreFailure("undo", refused.path, refused.error, {
6576
+ written: 0,
6577
+ total: writes.length,
6578
+ path: refused.path
6579
+ });
6580
+ }
6581
+ const progress = {
6582
+ written: 0,
6583
+ total: writes.length,
6584
+ path: pair.before.path
6585
+ };
5217
6586
  try {
5218
- await restoreState(pair.sessionId, pair.before, pair.after, signal);
6587
+ await restoreState(pair.policySession, pair.before, pair.after, signal, commentScopeOf(pair.pressedBy, view), progress);
5219
6588
  } catch (error) {
5220
- undoStackOf(sessionId).push(pair);
5221
- return rpcError(`undo failed: ${errorMessage(error)}`);
6589
+ undoStackOf(root).push(pair);
6590
+ return restoreFailure("undo", progress.path, error, progress);
5222
6591
  }
5223
- redoStackOf(sessionId).push(pair);
6592
+ redoStackOf(root).push(pair);
5224
6593
  persistSession(true);
5225
6594
  return {
5226
6595
  ok: true,
@@ -5233,18 +6602,35 @@ function apply(ctx, config) {
5233
6602
  case "redo": {
5234
6603
  const sessionId = sessionOf(payload);
5235
6604
  if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
5236
- const pair = popOwnPair(redoStackOf(sessionId), sessionId);
6605
+ const view = lineageView();
6606
+ const root = canonicalOf(sessionId, view);
6607
+ const pair = popOwnPair(redoStackOf(root), root);
5237
6608
  if (pair === void 0) return {
5238
6609
  ok: true,
5239
6610
  value: { outcome: "nothing" }
5240
6611
  };
6612
+ const writes = restoreWritesOf(pair.after, pair.before);
6613
+ const refused = await preflightRestore(writes, pair.policySession, signal);
6614
+ if (refused !== void 0) {
6615
+ redoStackOf(root).push(pair);
6616
+ return restoreFailure("redo", refused.path, refused.error, {
6617
+ written: 0,
6618
+ total: writes.length,
6619
+ path: refused.path
6620
+ });
6621
+ }
6622
+ const progress = {
6623
+ written: 0,
6624
+ total: writes.length,
6625
+ path: pair.after.path
6626
+ };
5241
6627
  try {
5242
- await restoreState(pair.sessionId, pair.after, pair.before, signal);
6628
+ await restoreState(pair.policySession, pair.after, pair.before, signal, commentScopeOf(pair.pressedBy, view), progress);
5243
6629
  } catch (error) {
5244
- redoStackOf(sessionId).push(pair);
5245
- return rpcError(`redo failed: ${errorMessage(error)}`);
6630
+ redoStackOf(root).push(pair);
6631
+ return restoreFailure("redo", progress.path, error, progress);
5246
6632
  }
5247
- undoStackOf(sessionId).push(pair);
6633
+ undoStackOf(root).push(pair);
5248
6634
  persistSession(true);
5249
6635
  return {
5250
6636
  ok: true,
@@ -5292,11 +6678,12 @@ function apply(ctx, config) {
5292
6678
  id: change.path,
5293
6679
  sessionId,
5294
6680
  path: change.path,
5295
- kind: change.kind,
6681
+ earlierVersion: change.earlierVersion,
5296
6682
  oldText: change.oldText,
5297
6683
  newText: change.newText,
5298
6684
  updatedAt: Date.now(),
5299
- sessionIds: [sessionId]
6685
+ sessionIds: [sessionId],
6686
+ lineage: lineageOf(sessionId)
5300
6687
  }))),
5301
6688
  detected: true
5302
6689
  }
@@ -5306,7 +6693,7 @@ function apply(ctx, config) {
5306
6693
  const target = targetOf(payload);
5307
6694
  if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
5308
6695
  await ensureLoaded();
5309
- const entry = store.get(target.id);
6696
+ const entry = actionableEntryOf(lineageView(), target.sessionId, target.id);
5310
6697
  if (entry === void 0) return {
5311
6698
  ok: true,
5312
6699
  value: { outcome: "missing" }
@@ -5340,18 +6727,19 @@ function apply(ctx, config) {
5340
6727
  ok: true,
5341
6728
  value: { outcome: "no-change" }
5342
6729
  };
5343
- if (change.kind === entry.kind && change.oldText === entry.oldText && change.newText === entry.newText) return {
6730
+ if (change.earlierVersion === entry.earlierVersion && change.oldText === entry.oldText && change.newText === entry.newText) return {
5344
6731
  ok: true,
5345
6732
  value: { outcome: "unchanged" }
5346
6733
  };
5347
6734
  const refreshed = {
5348
6735
  ...entry,
5349
- kind: change.kind,
6736
+ earlierVersion: change.earlierVersion,
5350
6737
  oldText: change.oldText,
5351
6738
  newText: change.newText,
5352
6739
  updatedAt: Date.now()
5353
6740
  };
5354
6741
  store.restore(refreshed);
6742
+ store.markSeen(entry.path);
5355
6743
  pushUndo(target.sessionId, {
5356
6744
  id: entry.path,
5357
6745
  path: entry.path,
@@ -5492,11 +6880,12 @@ function apply(ctx, config) {
5492
6880
  id: absolute,
5493
6881
  sessionId: target.sessionId,
5494
6882
  path: absolute,
5495
- kind: "edit",
6883
+ earlierVersion: "file",
5496
6884
  oldText: content,
5497
6885
  newText: content,
5498
6886
  updatedAt: now,
5499
- sessionIds: [target.sessionId]
6887
+ sessionIds: [target.sessionId],
6888
+ lineage: lineageOf(target.sessionId)
5500
6889
  }], false, false);
5501
6890
  }
5502
6891
  const root = detectVcsRoot(workspace.path);
@@ -5537,11 +6926,12 @@ function apply(ctx, config) {
5537
6926
  id: change.path,
5538
6927
  sessionId: target.sessionId,
5539
6928
  path: change.path,
5540
- kind: change.kind,
6929
+ earlierVersion: change.earlierVersion,
5541
6930
  oldText: change.oldText,
5542
6931
  newText: change.newText,
5543
6932
  updatedAt: now,
5544
- sessionIds: [target.sessionId]
6933
+ sessionIds: [target.sessionId],
6934
+ lineage: lineageOf(target.sessionId)
5545
6935
  }));
5546
6936
  let truncated = false;
5547
6937
  if (target.includeUnchanged) {
@@ -5562,25 +6952,42 @@ function apply(ctx, config) {
5562
6952
  id: file.path,
5563
6953
  sessionId: target.sessionId,
5564
6954
  path: file.path,
5565
- kind: "edit",
6955
+ earlierVersion: "file",
5566
6956
  oldText: file.content,
5567
6957
  newText: file.content,
5568
6958
  updatedAt: now,
5569
- sessionIds: [target.sessionId]
6959
+ sessionIds: [target.sessionId],
6960
+ lineage: lineageOf(target.sessionId)
5570
6961
  });
5571
6962
  }
5572
6963
  }
5573
6964
  return await admitNamed(candidates, isDirectory, truncated);
5574
6965
  }
6966
+ case "seen": {
6967
+ const seen = payload ?? {};
6968
+ if (typeof seen.id !== "string" || seen.id.length === 0) return rpcError("id must be a non-empty string");
6969
+ await ensureLoaded();
6970
+ if (store.markSeen(seen.id)) persistSession();
6971
+ return {
6972
+ ok: true,
6973
+ value: { outcome: "seen" }
6974
+ };
6975
+ }
5575
6976
  case "open": {
5576
6977
  const target = openTargetOf(payload);
5577
6978
  if (target === void 0) return rpcError("sessionId, id, and action must be valid");
5578
6979
  await ensureLoaded();
5579
- const entry = store.get(target.id);
6980
+ const lineage = lineageView();
6981
+ let entry = actionableEntryOf(lineage, target.sessionId, target.id);
6982
+ if (entry === void 0) {
6983
+ await ensureLoaded();
6984
+ entry = actionableEntryOf(lineage, target.sessionId, target.id);
6985
+ }
5580
6986
  if (entry === void 0) return {
5581
6987
  ok: true,
5582
6988
  value: { outcome: "missing" }
5583
6989
  };
6990
+ if (store.markSeen(entry.path)) persistSession();
5584
6991
  try {
5585
6992
  const resolved = await ctx.fs.resolve(entry.path, { signal });
5586
6993
  await launchPath(ctx.fs.processPath(resolved), target.action);
@@ -5632,11 +7039,11 @@ function apply(ctx, config) {
5632
7039
  return server.register(route);
5633
7040
  }, "diff-approval: code font slices");
5634
7041
  ctx.effect(() => ctx.on("fs/edit-intent", async (target, actor, next) => {
5635
- await stashEditorIntent(target, actor, "edit");
7042
+ await stashEditorIntent(target, actor, "file");
5636
7043
  return next();
5637
7044
  }, { prepend: true }), "diff-approval: str_replace_editor edit basis");
5638
7045
  ctx.effect(() => ctx.on("fs/write-intent", async (target, actor, next) => {
5639
- await stashEditorIntent(target, actor, "create");
7046
+ await stashEditorIntent(target, actor, "none");
5640
7047
  return next();
5641
7048
  }, { prepend: true }), "diff-approval: str_replace_editor create basis");
5642
7049
  }