@magic-markdown/cli 0.3.13 → 0.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +930 -379
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -270,8 +270,8 @@ var require_punycode = __commonJS({
270
270
  });
271
271
 
272
272
  // src/index.ts
273
- import { readFile as readFile6 } from "node:fs/promises";
274
- import { resolve as resolve5 } from "node:path";
273
+ import { readFile as readFile7 } from "node:fs/promises";
274
+ import { resolve as resolve6 } from "node:path";
275
275
 
276
276
  // ../core/src/types.ts
277
277
  var SIDECAR_SCHEMA_VERSION = 1;
@@ -339,6 +339,7 @@ var POLLUTING_PATTERNS = [
339
339
  { name: "CriticMarkup substitution", pattern: /\{~~[\s\S]*?~~\}/ },
340
340
  { name: "CriticMarkup comment", pattern: /\{>>[\s\S]*?<<\}/ },
341
341
  { name: "mdocs HTML marker", pattern: /<!--\s*mdocs:/i },
342
+ { name: "mdocs suggestion marker", pattern: /<!--\s*\/?\s*mdocs-suggestion\b/i },
342
343
  { name: "mdocs directive", pattern: /:{1,3}(?:comment|suggestion|anchor)\b/i }
343
344
  ];
344
345
  function findCleanMarkdownViolations(markdown) {
@@ -405,7 +406,7 @@ function replacementLinesForRange(replacement) {
405
406
  function extractMarkdownImages(markdown, documentPath) {
406
407
  const masked = maskCode(markdown);
407
408
  const referenceDefinitions = collectReferenceDefinitions(markdown, masked);
408
- const lineStarts = getLineStarts(markdown);
409
+ const lineStarts2 = getLineStarts(markdown);
409
410
  const images = [];
410
411
  let index = 0;
411
412
  while (index < masked.length) {
@@ -423,7 +424,7 @@ function extractMarkdownImages(markdown, documentPath) {
423
424
  }
424
425
  const alt = markdown.slice(labelOpen + 1, labelClose);
425
426
  const afterLabel = labelClose + 1;
426
- const position = positionAt(lineStarts, start);
427
+ const position = positionAt(lineStarts2, start);
427
428
  if (masked[afterLabel] === "(") {
428
429
  const destinationClose = findClosingParen(markdown, afterLabel + 1);
429
430
  if (destinationClose === -1) {
@@ -491,7 +492,7 @@ function extractMarkdownLinks(markdown) {
491
492
  const codeMasked = maskCode(markdown);
492
493
  const referenceDefinitions = collectReferenceDefinitions(markdown, codeMasked);
493
494
  const masked = maskReferenceDefinitions(markdown, codeMasked);
494
- const lineStarts = getLineStarts(markdown);
495
+ const lineStarts2 = getLineStarts(markdown);
495
496
  const links = [];
496
497
  let index = 0;
497
498
  while (index < masked.length) {
@@ -508,7 +509,7 @@ function extractMarkdownLinks(markdown) {
508
509
  }
509
510
  const label = markdown.slice(start + 1, labelClose);
510
511
  const afterLabel = labelClose + 1;
511
- const position = positionAt(lineStarts, start);
512
+ const position = positionAt(lineStarts2, start);
512
513
  if (masked[afterLabel] === "(") {
513
514
  const destinationClose = findClosingParen(markdown, afterLabel + 1);
514
515
  if (destinationClose === -1) {
@@ -564,7 +565,7 @@ function extractMarkdownLinks(markdown) {
564
565
  }
565
566
  index = labelClose + 1;
566
567
  }
567
- extractWikilinks(markdown, masked, lineStarts).forEach((link2) => links.push(link2));
568
+ extractWikilinks(markdown, masked, lineStarts2).forEach((link2) => links.push(link2));
568
569
  return links.sort((left, right) => left.line - right.line || left.column - right.column);
569
570
  }
570
571
  function toImageReference(image2) {
@@ -591,7 +592,7 @@ function toLinkReference(link2) {
591
592
  syntax: link2.syntax
592
593
  };
593
594
  }
594
- function extractWikilinks(markdown, masked, lineStarts) {
595
+ function extractWikilinks(markdown, masked, lineStarts2) {
595
596
  const links = [];
596
597
  let index = 0;
597
598
  while (index < masked.length) {
@@ -608,7 +609,7 @@ function extractWikilinks(markdown, masked, lineStarts) {
608
609
  }
609
610
  const parsed = parseWikilinkBody(markdown.slice(start + 2, close2));
610
611
  if (parsed) {
611
- const position = positionAt(lineStarts, start);
612
+ const position = positionAt(lineStarts2, start);
612
613
  links.push({
613
614
  label: parsed.label ?? parsed.target,
614
615
  target: parsed.target,
@@ -888,12 +889,12 @@ function getLineStarts(value) {
888
889
  }
889
890
  return starts;
890
891
  }
891
- function positionAt(lineStarts, index) {
892
+ function positionAt(lineStarts2, index) {
892
893
  let low = 0;
893
- let high = lineStarts.length - 1;
894
+ let high = lineStarts2.length - 1;
894
895
  while (low <= high) {
895
896
  const middle = Math.floor((low + high) / 2);
896
- const start = lineStarts[middle] ?? 0;
897
+ const start = lineStarts2[middle] ?? 0;
897
898
  if (start <= index) {
898
899
  low = middle + 1;
899
900
  } else {
@@ -903,7 +904,7 @@ function positionAt(lineStarts, index) {
903
904
  const lineIndex = Math.max(0, high);
904
905
  return {
905
906
  line: lineIndex + 1,
906
- column: index - (lineStarts[lineIndex] ?? 0) + 1
907
+ column: index - (lineStarts2[lineIndex] ?? 0) + 1
907
908
  };
908
909
  }
909
910
 
@@ -979,270 +980,455 @@ function contextSuffix(value) {
979
980
  return value?.split(/\r?\n/).filter(Boolean).slice(0, 3).join("\n") ?? "";
980
981
  }
981
982
 
982
- // ../core/src/text-merge.ts
983
- var MAX_DIFF_CELLS = 25e6;
984
- function mergeText(base, ours, theirs) {
985
- if (ours === theirs) return { ok: true, text: ours };
986
- if (base === ours) return { ok: true, text: theirs };
987
- if (base === theirs) return { ok: true, text: ours };
988
- const baseLines = base.split("\n");
989
- const ourLines = ours.split("\n");
990
- const theirLines = theirs.split("\n");
991
- let regionsA;
992
- let regionsB;
993
- try {
994
- regionsA = diffTextRegions(baseLines, ourLines);
995
- regionsB = diffTextRegions(baseLines, theirLines);
996
- } catch {
997
- return { ok: false, reason: "too_large" };
998
- }
999
- const clusters = clusterRegions(regionsA, regionsB);
1000
- const merged = [];
1001
- let baseCursor = 0;
1002
- for (const cluster of clusters) {
1003
- for (let line = baseCursor; line < cluster.lo; line += 1) merged.push(baseLines[line]);
1004
- const baseText = baseLines.slice(cluster.lo, cluster.hi);
1005
- const ourText = sideSlice(ourLines, regionsA, cluster.lo, cluster.hi);
1006
- const theirText = sideSlice(theirLines, regionsB, cluster.lo, cluster.hi);
1007
- const oursChanged = !linesEqual(ourText, baseText);
1008
- const theirsChanged = !linesEqual(theirText, baseText);
1009
- if (oursChanged && theirsChanged && !linesEqual(ourText, theirText)) {
1010
- return { ok: false, reason: "overlapping_changes" };
1011
- }
1012
- merged.push(...oursChanged ? ourText : theirText);
1013
- baseCursor = cluster.hi;
1014
- }
1015
- for (let line = baseCursor; line < baseLines.length; line += 1) merged.push(baseLines[line]);
1016
- return { ok: true, text: merged.join("\n") };
1017
- }
1018
- function diffTextRegions(base, side) {
1019
- let prefix = 0;
1020
- while (prefix < base.length && prefix < side.length && base[prefix] === side[prefix]) prefix += 1;
1021
- let suffix = 0;
1022
- while (suffix < base.length - prefix && suffix < side.length - prefix && base[base.length - 1 - suffix] === side[side.length - 1 - suffix]) {
1023
- suffix += 1;
1024
- }
1025
- const baseCore = base.slice(prefix, base.length - suffix);
1026
- const sideCore = side.slice(prefix, side.length - suffix);
1027
- if (!baseCore.length && !sideCore.length) return [];
1028
- if ((baseCore.length + 1) * (sideCore.length + 1) > MAX_DIFF_CELLS) throw new Error("diff too large");
1029
- const rows = baseCore.length + 1;
1030
- const cols = sideCore.length + 1;
1031
- const lcs = new Uint32Array(rows * cols);
1032
- for (let row2 = baseCore.length - 1; row2 >= 0; row2 -= 1) {
1033
- for (let col2 = sideCore.length - 1; col2 >= 0; col2 -= 1) {
1034
- lcs[row2 * cols + col2] = baseCore[row2] === sideCore[col2] ? lcs[(row2 + 1) * cols + col2 + 1] + 1 : Math.max(lcs[(row2 + 1) * cols + col2], lcs[row2 * cols + col2 + 1]);
1035
- }
1036
- }
1037
- const regions = [];
1038
- let row = 0;
1039
- let col = 0;
1040
- let pendingBase = -1;
1041
- let pendingSide = -1;
1042
- const flush = (baseEnd, sideEnd) => {
1043
- if (pendingBase < 0) return;
1044
- regions.push({
1045
- baseStart: prefix + pendingBase,
1046
- baseLength: baseEnd - pendingBase,
1047
- sideStart: prefix + pendingSide,
1048
- sideLength: sideEnd - pendingSide
1049
- });
1050
- pendingBase = -1;
1051
- pendingSide = -1;
1052
- };
1053
- while (row < baseCore.length || col < sideCore.length) {
1054
- if (row < baseCore.length && col < sideCore.length && baseCore[row] === sideCore[col]) {
1055
- flush(row, col);
1056
- row += 1;
1057
- col += 1;
1058
- } else {
1059
- if (pendingBase < 0) {
1060
- pendingBase = row;
1061
- pendingSide = col;
1062
- }
1063
- if (col >= sideCore.length || row < baseCore.length && lcs[(row + 1) * cols + col] >= lcs[row * cols + col + 1]) {
1064
- row += 1;
1065
- } else {
1066
- col += 1;
1067
- }
1068
- }
1069
- }
1070
- flush(row, col);
1071
- return regions;
1072
- }
1073
- function clusterRegions(regionsA, regionsB) {
1074
- const spans = [...regionsA, ...regionsB].map((region) => ({ lo: region.baseStart, hi: region.baseStart + region.baseLength })).sort((left, right) => left.lo - right.lo || left.hi - right.hi);
1075
- const clusters = [];
1076
- for (const span of spans) {
1077
- const last = clusters.at(-1);
1078
- if (last && span.lo <= last.hi) {
1079
- last.hi = Math.max(last.hi, span.hi);
1080
- } else {
1081
- clusters.push({ lo: span.lo, hi: span.hi });
983
+ // ../core/src/review-markdown.ts
984
+ var START_MARKER = "<!--mdocs-suggestion";
985
+ var END_MARKER = "<!--/mdocs-suggestion-->";
986
+ function parseReviewMarkdown(reviewMarkdown) {
987
+ const segments = [];
988
+ const suggestions = [];
989
+ let cursor = 0;
990
+ let canonicalOffset = 0;
991
+ while (cursor < reviewMarkdown.length) {
992
+ const markerStart = reviewMarkdown.indexOf(START_MARKER, cursor);
993
+ if (markerStart === -1) {
994
+ appendTextSegment(reviewMarkdown, segments, cursor, reviewMarkdown.length, canonicalOffset);
995
+ break;
1082
996
  }
997
+ appendTextSegment(reviewMarkdown, segments, cursor, markerStart, canonicalOffset);
998
+ canonicalOffset += markerStart - cursor;
999
+ const startClose = reviewMarkdown.indexOf("-->", markerStart);
1000
+ if (startClose === -1) throw invalidReviewMarkdown("Unclosed mdocs suggestion marker.");
1001
+ const attrs2 = parseMarkerAttributes(reviewMarkdown.slice(markerStart + START_MARKER.length, startClose));
1002
+ if (!attrs2.id) throw invalidReviewMarkdown("Suggestion marker is missing an id.");
1003
+ if (attrs2.kind !== "insert" && attrs2.kind !== "delete" && attrs2.kind !== "replace") {
1004
+ throw invalidReviewMarkdown(`Suggestion ${attrs2.id} has an invalid kind.`);
1005
+ }
1006
+ const bodyStart = startClose + 3;
1007
+ const markerEnd = reviewMarkdown.indexOf(END_MARKER, bodyStart);
1008
+ if (markerEnd === -1) throw invalidReviewMarkdown(`Suggestion ${attrs2.id} is missing its closing marker.`);
1009
+ const body = reviewMarkdown.slice(bodyStart, markerEnd);
1010
+ if (body.includes(START_MARKER)) throw invalidReviewMarkdown(`Suggestion ${attrs2.id} contains a nested suggestion.`);
1011
+ const parsedBody = parseSuggestionBody(attrs2.kind, body, attrs2.id);
1012
+ const token = {
1013
+ id: attrs2.id,
1014
+ kind: attrs2.kind,
1015
+ form: parsedBody.before.includes("\n") || parsedBody.after.includes("\n") ? "block" : "inline",
1016
+ author: actorFromAttributes(attrs2),
1017
+ message: attrs2.message,
1018
+ createdAt: attrs2.createdAt,
1019
+ updatedAt: attrs2.updatedAt,
1020
+ before: parsedBody.before,
1021
+ after: parsedBody.after,
1022
+ markerStart,
1023
+ markerEnd: markerEnd + END_MARKER.length,
1024
+ canonicalStart: canonicalOffset,
1025
+ canonicalEnd: canonicalOffset + parsedBody.before.length,
1026
+ raw: reviewMarkdown.slice(markerStart, markerEnd + END_MARKER.length)
1027
+ };
1028
+ segments.push({ type: "suggestion", token });
1029
+ suggestions.push(token);
1030
+ canonicalOffset += token.before.length;
1031
+ cursor = token.markerEnd;
1083
1032
  }
1084
- return clusters;
1085
- }
1086
- function sideSlice(side, regions, lo, hi) {
1087
- return side.slice(lo + sideDeltaBefore(regions, lo, false), hi + sideDeltaBefore(regions, hi, true));
1033
+ return {
1034
+ reviewMarkdown,
1035
+ canonicalMarkdown: canonicalFromSegments(segments, statusLookup(void 0)),
1036
+ segments,
1037
+ suggestions
1038
+ };
1088
1039
  }
1089
- function sideDeltaBefore(regions, basePos, includeInsertionAt) {
1090
- let delta = 0;
1091
- for (const region of regions) {
1092
- const end = region.baseStart + region.baseLength;
1093
- if (end > basePos) break;
1094
- if (end === basePos && region.baseLength === 0 && !includeInsertionAt) break;
1095
- delta += region.sideLength - region.baseLength;
1040
+ function projectCanonicalMarkdown(reviewMarkdown, sidecarStatuses) {
1041
+ return canonicalFromSegments(parseReviewMarkdown(reviewMarkdown).segments, statusLookup(sidecarStatuses));
1042
+ }
1043
+ function deriveSidecarFromReviewMarkdown(sidecar, reviewMarkdown, options) {
1044
+ const parsed = parseReviewMarkdown(reviewMarkdown);
1045
+ const now = options.now ?? (/* @__PURE__ */ new Date()).toISOString();
1046
+ const existingSuggestions = new Map(sidecar.suggestions.map((suggestion) => [suggestion.id, suggestion]));
1047
+ const existingAnchors = new Map(sidecar.anchors.map((anchor) => [anchor.id, anchor]));
1048
+ const anchors = sidecar.anchors.filter((anchor) => anchor.kind !== "suggestion");
1049
+ const suggestions = [];
1050
+ const base = { contentHash: contentHashForText(parsed.canonicalMarkdown), ...options.baseHead ? { head: options.baseHead } : {} };
1051
+ for (const token of parsed.suggestions) {
1052
+ const existing = existingSuggestions.get(token.id);
1053
+ const anchorId = existing?.anchorId ?? stableAnchorIdForSuggestion(token.id);
1054
+ const range = lineRangeForCanonicalSpan(parsed.canonicalMarkdown, token.canonicalStart, token.canonicalEnd);
1055
+ const selector = selectorForCanonicalSpan(parsed.canonicalMarkdown, token.canonicalStart, token.canonicalEnd);
1056
+ const anchor = {
1057
+ ...existingAnchors.get(anchorId) ?? {},
1058
+ id: anchorId,
1059
+ kind: "suggestion",
1060
+ status: "mapped",
1061
+ selector,
1062
+ range,
1063
+ confidence: 1,
1064
+ updatedAt: now
1065
+ };
1066
+ const author = token.author ?? existing?.author ?? options.author;
1067
+ const message = token.message ?? existing?.message ?? "Suggested edit";
1068
+ const suggestion = {
1069
+ ...existing ?? {},
1070
+ id: token.id,
1071
+ anchorId,
1072
+ status: "open",
1073
+ kind: token.kind,
1074
+ author,
1075
+ message,
1076
+ patch: {
1077
+ type: "replace_lines",
1078
+ range,
1079
+ before: token.before,
1080
+ after: token.after
1081
+ },
1082
+ base: existing?.base ?? base,
1083
+ diff: {
1084
+ before: token.before,
1085
+ after: token.after,
1086
+ summary: diffSummary(token.before, token.after)
1087
+ },
1088
+ projectionStatus: "clean",
1089
+ reviewRange: { start: token.markerStart, end: token.markerEnd },
1090
+ createdAt: token.createdAt ?? existing?.createdAt ?? now,
1091
+ updatedAt: token.updatedAt ?? now
1092
+ };
1093
+ anchors.push(anchor);
1094
+ suggestions.push(suggestion);
1096
1095
  }
1097
- return delta;
1098
- }
1099
- function linesEqual(left, right) {
1100
- return left.length === right.length && left.every((line, index) => line === right[index]);
1096
+ return {
1097
+ ...sidecar,
1098
+ reviewMarkdown,
1099
+ anchors,
1100
+ suggestions,
1101
+ updatedAt: now
1102
+ };
1101
1103
  }
1102
-
1103
- // ../core/src/patches.ts
1104
- function applySuggestion(markdown, suggestion, input) {
1105
- if (suggestion.status !== "open") {
1106
- throw new MdocsError(
1107
- "validation_error",
1108
- `Suggestion ${suggestion.id} is not open (status: ${suggestion.status}).`,
1109
- "Only open suggestions can be applied. List open suggestions with the suggestions command."
1110
- );
1111
- }
1112
- const context = applySuggestionContext(input);
1113
- const baseResult = applySuggestionFromBase(markdown, suggestion, context.baseMarkdown);
1114
- if (baseResult.kind === "applied") return baseResult.markdown;
1115
- if (baseResult.kind === "conflict") {
1104
+ function createSuggestionMarkup(reviewMarkdown, patch, metadata) {
1105
+ const parsed = parseReviewMarkdown(reviewMarkdown);
1106
+ assertNoDuplicateSuggestionId(parsed, metadata.suggestionId);
1107
+ const [contentStart, contentEnd] = lineRangeOffsets(parsed.canonicalMarkdown, patch.range);
1108
+ const selected = parsed.canonicalMarkdown.slice(contentStart, contentEnd);
1109
+ if (selected !== patch.before) {
1116
1110
  throw new MdocsError(
1117
1111
  "conflict",
1118
- `Suggestion ${suggestion.id} no longer applies cleanly: the document changed inside the suggested edit.`,
1119
- "Review the current text around the suggestion, then accept it manually or ask the agent for a fresh suggestion."
1112
+ "Suggestion target does not match the review document projection.",
1113
+ "Re-read the document and create the suggestion against the current canonical text."
1120
1114
  );
1121
1115
  }
1122
- const range = currentSuggestionRange(markdown, suggestion, context.anchor);
1123
- if (!range) {
1116
+ const [canonicalStart, canonicalEnd] = lineRangeReplacementOffsets(parsed.canonicalMarkdown, patch.range);
1117
+ if (selectionTouchesSuggestion(parsed.suggestions, canonicalStart, canonicalEnd)) {
1124
1118
  throw new MdocsError(
1125
1119
  "conflict",
1126
- `Suggestion ${suggestion.id} no longer applies cleanly: the document changed since it was created.`,
1127
- "Re-read the document, then create a fresh suggestion against the current content."
1120
+ "Suggestion overlaps an unresolved suggestion.",
1121
+ "Accept or reject the existing suggestion before creating another edit over the same text."
1128
1122
  );
1129
1123
  }
1130
- const next = replaceLineRange(markdown, range, suggestion.patch.after);
1131
- assertCleanMarkdown(next);
1132
- return next;
1124
+ const rawStart = rawOffsetForCanonicalOffset(parsed.segments, canonicalStart);
1125
+ const rawEnd = rawOffsetForCanonicalOffset(parsed.segments, canonicalEnd);
1126
+ const before = parsed.canonicalMarkdown.slice(canonicalStart, canonicalEnd);
1127
+ const after = replacementTextForSpan(parsed.canonicalMarkdown, patch, canonicalStart, canonicalEnd);
1128
+ const marker = formatSuggestionMarker(metadata, kindForText(before, after), before, after);
1129
+ const nextReviewMarkdown = `${reviewMarkdown.slice(0, rawStart)}${marker}${reviewMarkdown.slice(rawEnd)}`;
1130
+ const token = parseReviewMarkdown(nextReviewMarkdown).suggestions.find((candidate) => candidate.id === metadata.suggestionId);
1131
+ if (!token) throw invalidReviewMarkdown(`Failed to create suggestion ${metadata.suggestionId}.`);
1132
+ return { reviewMarkdown: nextReviewMarkdown, token };
1133
+ }
1134
+ function projectReviewMarkdown(canonicalMarkdown, suggestions) {
1135
+ let reviewMarkdown = canonicalMarkdown;
1136
+ const open = suggestions.filter((suggestion) => suggestion.status === "open" && suggestion.patch?.type === "replace_lines").slice().sort((left, right) => left.patch.range.startLine - right.patch.range.startLine);
1137
+ for (const suggestion of open) {
1138
+ try {
1139
+ reviewMarkdown = createSuggestionMarkup(reviewMarkdown, suggestion.patch, {
1140
+ suggestionId: suggestion.id,
1141
+ author: suggestion.author,
1142
+ message: suggestion.message,
1143
+ createdAt: suggestion.createdAt,
1144
+ updatedAt: suggestion.updatedAt
1145
+ }).reviewMarkdown;
1146
+ } catch {
1147
+ }
1148
+ }
1149
+ return reviewMarkdown;
1133
1150
  }
1134
- function applySuggestionContext(input) {
1135
- if (!input) return {};
1136
- if ("selector" in input) return { anchor: input };
1137
- return input;
1138
- }
1139
- function applySuggestionFromBase(markdown, suggestion, baseMarkdown) {
1140
- if (!baseMarkdown || !suggestion.base?.contentHash) return { kind: "unavailable" };
1141
- if (contentHashForText(baseMarkdown) !== suggestion.base.contentHash) return { kind: "unavailable" };
1142
- if (!rangeIsWithin(baseMarkdown, suggestion.patch.range) || extractLineRange(baseMarkdown, suggestion.patch.range) !== suggestion.patch.before) {
1143
- return { kind: "conflict" };
1144
- }
1145
- const rebased = rebaseSuggestionPatch(baseMarkdown, markdown, suggestion);
1146
- if (rebased !== void 0) return { kind: "applied", markdown: rebased };
1147
- const suggestedMarkdown = replaceLineRange(baseMarkdown, suggestion.patch.range, suggestion.patch.after);
1148
- assertCleanMarkdown(suggestedMarkdown);
1149
- const merged = mergeText(baseMarkdown, markdown, suggestedMarkdown);
1150
- if (!merged.ok) return { kind: "conflict" };
1151
- assertCleanMarkdown(merged.text);
1152
- return { kind: "applied", markdown: merged.text };
1153
- }
1154
- function rebaseSuggestionPatch(baseMarkdown, markdown, suggestion) {
1155
- const baseLines = getLines(baseMarkdown);
1156
- const currentLines = getLines(markdown);
1157
- let regions;
1158
- try {
1159
- regions = diffTextRegions(baseLines, currentLines);
1160
- } catch {
1161
- return void 0;
1151
+ function resolveSuggestionMarkup(reviewMarkdown, suggestionId, resolution) {
1152
+ const parsed = parseReviewMarkdown(reviewMarkdown);
1153
+ const token = parsed.suggestions.find((candidate) => candidate.id === suggestionId);
1154
+ if (!token) {
1155
+ throw new MdocsError("not_found", `Unknown suggestion: ${suggestionId}`, "Refresh review state and try again.");
1162
1156
  }
1163
- const baseStart = suggestion.patch.range.startLine - 1;
1164
- const baseEnd = suggestion.patch.range.endLine;
1165
- if (regions.some((region) => regionTouchesSuggestionRange(region, baseStart, baseEnd))) return void 0;
1166
- const currentStart = baseStart + sideDeltaBefore(regions, baseStart, true);
1167
- const currentEnd = baseEnd + sideDeltaBefore(regions, baseEnd, false);
1168
- const currentRange = { startLine: currentStart + 1, endLine: currentEnd };
1169
- if (!rangeIsWithin(markdown, currentRange)) return void 0;
1170
- if (extractLineRange(markdown, currentRange) !== suggestion.patch.before) return void 0;
1171
- const next = replaceLineRange(markdown, currentRange, suggestion.patch.after);
1172
- assertCleanMarkdown(next);
1173
- return next;
1157
+ const resolved = resolutionText(token, resolution);
1158
+ return `${reviewMarkdown.slice(0, token.markerStart)}${resolved}${reviewMarkdown.slice(token.markerEnd)}`;
1174
1159
  }
1175
- function regionTouchesSuggestionRange(region, baseStart, baseEnd) {
1176
- if (region.baseLength === 0) return region.baseStart > baseStart && region.baseStart < baseEnd;
1177
- const regionEnd = region.baseStart + region.baseLength;
1178
- return region.baseStart < baseEnd && regionEnd > baseStart;
1179
- }
1180
- function currentSuggestionRange(markdown, suggestion, anchor) {
1181
- const anchoredRange = currentAnchorRange(markdown, suggestion, anchor);
1182
- if (anchoredRange) return anchoredRange;
1183
- if (anchor && !anchorIsReliable(anchor)) return void 0;
1184
- const hintedRange = suggestion.patch.range;
1185
- if (rangeIsWithin(markdown, hintedRange) && extractLineRange(markdown, hintedRange) === suggestion.patch.before) return hintedRange;
1186
- const candidates = findBeforeCandidates(markdown, suggestion.patch.before);
1187
- if (candidates.length === 0) return void 0;
1188
- if (candidates.length === 1) return candidates[0];
1189
- if (!anchor) return void 0;
1190
- const scored = candidates.map((range) => ({ range, score: scoreContext2(markdown, range, anchor) })).sort((left, right) => right.score - left.score);
1191
- const best = scored[0];
1192
- if (!best) return void 0;
1193
- const tied = scored.filter((candidate) => candidate.score === best.score);
1194
- if (tied.length > 1) return void 0;
1195
- if (best.score < 0.7) return void 0;
1196
- return best.range;
1160
+ function escapeReviewSuggestionText(value) {
1161
+ return value.replaceAll("\\", "\\\\").replaceAll("{++", "\\{++").replaceAll("++}", "\\++}").replaceAll("{--", "\\{--").replaceAll("--}", "\\--}").replaceAll("{~~", "\\{~~").replaceAll("~>", "\\~>").replaceAll("~~}", "\\~~}");
1197
1162
  }
1198
- function currentAnchorRange(markdown, suggestion, anchor) {
1199
- if (!anchor || !anchorIsReliable(anchor) || !anchor.range) return void 0;
1200
- if (!rangeIsWithin(markdown, anchor.range)) return void 0;
1201
- return extractLineRange(markdown, anchor.range) === suggestion.patch.before ? anchor.range : void 0;
1163
+ function lineRangeForCanonicalSpan(markdown, start, end) {
1164
+ assertCanonicalSpan(markdown, start, end);
1165
+ return lineRangeForOffsets(markdown, start, end);
1202
1166
  }
1203
- function anchorIsReliable(anchor) {
1204
- return anchor.status === "mapped" && anchor.confidence >= 0.65;
1167
+ function selectorForCanonicalSpan(markdown, start, end) {
1168
+ assertCanonicalSpan(markdown, start, end);
1169
+ const context = 160;
1170
+ const prefix = markdown.slice(Math.max(0, start - context), start);
1171
+ const suffix = markdown.slice(end, Math.min(markdown.length, end + context));
1172
+ return {
1173
+ quote: markdown.slice(start, end),
1174
+ ...prefix ? { prefix } : {},
1175
+ ...suffix ? { suffix } : {}
1176
+ };
1205
1177
  }
1206
- function findBeforeCandidates(markdown, before) {
1207
- const lines = getLines(markdown);
1208
- const beforeLines = linePattern(before);
1209
- if (beforeLines.length === 0 || beforeLines.length > lines.length) return [];
1210
- const ranges = [];
1211
- const beforeText = beforeLines.join("\n");
1212
- for (let index = 0; index <= lines.length - beforeLines.length; index += 1) {
1213
- if (lines.slice(index, index + beforeLines.length).join("\n") === beforeText) {
1214
- ranges.push({ startLine: index + 1, endLine: index + beforeLines.length });
1178
+ function appendTextSegment(source, segments, start, end, canonicalStart) {
1179
+ if (end <= start) return;
1180
+ segments.push({
1181
+ type: "text",
1182
+ text: source.slice(start, end),
1183
+ start,
1184
+ end,
1185
+ canonicalStart,
1186
+ canonicalEnd: canonicalStart + (end - start)
1187
+ });
1188
+ }
1189
+ function canonicalFromSegments(segments, statuses) {
1190
+ return segments.map((segment) => {
1191
+ if (segment.type === "text") return segment.text;
1192
+ const status = statuses(segment.token.id);
1193
+ if (status === "accepted") return acceptedText(segment.token);
1194
+ return originalText(segment.token);
1195
+ }).join("");
1196
+ }
1197
+ function parseMarkerAttributes(raw) {
1198
+ const attrs2 = {};
1199
+ const pattern = /\s+([a-zA-Z][\w:-]*)="([^"]*)"/g;
1200
+ let match2;
1201
+ while ((match2 = pattern.exec(raw)) !== null) {
1202
+ const key = match2[1];
1203
+ if (key === "id" || key === "kind" || key === "authorId" || key === "authorKind" || key === "authorName" || key === "authorEmail" || key === "message" || key === "createdAt" || key === "updatedAt") {
1204
+ attrs2[key] = decodeAttribute(match2[2] ?? "");
1215
1205
  }
1216
1206
  }
1217
- return ranges;
1207
+ return attrs2;
1218
1208
  }
1219
- function rangeIsWithin(markdown, range) {
1220
- const lineCount = getLines(markdown).length;
1221
- return range.startLine >= 1 && range.endLine >= range.startLine && range.endLine <= lineCount;
1209
+ function actorFromAttributes(attrs2) {
1210
+ if (!attrs2.authorId || !attrs2.authorName) return void 0;
1211
+ const kind = attrs2.authorKind === "agent" || attrs2.authorKind === "system" || attrs2.authorKind === "human" ? attrs2.authorKind : "human";
1212
+ return {
1213
+ id: attrs2.authorId,
1214
+ kind,
1215
+ name: attrs2.authorName,
1216
+ ...attrs2.authorEmail ? { email: attrs2.authorEmail } : {}
1217
+ };
1218
+ }
1219
+ function parseSuggestionBody(kind, body, id) {
1220
+ if (kind === "insert") {
1221
+ return { before: "", after: parseWrappedBody(body, "{++", "++}", id) };
1222
+ }
1223
+ if (kind === "delete") {
1224
+ return { before: parseWrappedBody(body, "{--", "--}", id), after: "" };
1225
+ }
1226
+ if (!body.startsWith("{~~") || !body.endsWith("~~}")) {
1227
+ throw invalidReviewMarkdown(`Replacement suggestion ${id} has invalid CriticMarkup payload.`);
1228
+ }
1229
+ const inner = body.slice(3, -3);
1230
+ const separator = findUnescaped2(inner, "~>");
1231
+ if (separator === -1) throw invalidReviewMarkdown(`Replacement suggestion ${id} is missing a separator.`);
1232
+ return {
1233
+ before: unescapeReviewSuggestionText(inner.slice(0, separator)),
1234
+ after: unescapeReviewSuggestionText(inner.slice(separator + 2))
1235
+ };
1236
+ }
1237
+ function parseWrappedBody(body, open, close2, id) {
1238
+ if (!body.startsWith(open) || !body.endsWith(close2) || isEscapedAt(body, body.length - close2.length)) {
1239
+ throw invalidReviewMarkdown(`Suggestion ${id} has invalid CriticMarkup payload.`);
1240
+ }
1241
+ return unescapeReviewSuggestionText(body.slice(open.length, -close2.length));
1242
+ }
1243
+ function formatSuggestionMarker(metadata, kind, before, after) {
1244
+ const attrs2 = [
1245
+ ["id", metadata.suggestionId],
1246
+ ["kind", kind]
1247
+ ];
1248
+ if (metadata.author) {
1249
+ attrs2.push(["authorId", metadata.author.id]);
1250
+ attrs2.push(["authorKind", metadata.author.kind]);
1251
+ attrs2.push(["authorName", metadata.author.name]);
1252
+ if (metadata.author.email) attrs2.push(["authorEmail", metadata.author.email]);
1253
+ }
1254
+ if (metadata.message) attrs2.push(["message", metadata.message]);
1255
+ if (metadata.createdAt) attrs2.push(["createdAt", metadata.createdAt]);
1256
+ if (metadata.updatedAt) attrs2.push(["updatedAt", metadata.updatedAt]);
1257
+ const attrText = attrs2.map(([key, value]) => `${key}="${encodeAttribute(value)}"`).join(" ");
1258
+ const body = kind === "insert" ? `{++${escapeReviewSuggestionText(after)}++}` : kind === "delete" ? `{--${escapeReviewSuggestionText(before)}--}` : `{~~${escapeReviewSuggestionText(before)}~>${escapeReviewSuggestionText(after)}~~}`;
1259
+ return `${START_MARKER} ${attrText} -->${body}${END_MARKER}`;
1260
+ }
1261
+ function stableAnchorIdForSuggestion(suggestionId) {
1262
+ return `anchor_${suggestionId.replace(/[^a-zA-Z0-9_:-]/g, "_")}`;
1263
+ }
1264
+ function diffSummary(before, after) {
1265
+ if (!before && after) return "Inserted text";
1266
+ if (before && !after) return "Deleted text";
1267
+ return "Replaced text";
1268
+ }
1269
+ function unescapeReviewSuggestionText(value) {
1270
+ let result = "";
1271
+ for (let index = 0; index < value.length; index += 1) {
1272
+ if (value[index] !== "\\") {
1273
+ result += value[index];
1274
+ continue;
1275
+ }
1276
+ const next = value[index + 1];
1277
+ if (next === void 0) {
1278
+ result += "\\";
1279
+ continue;
1280
+ }
1281
+ result += next;
1282
+ index += 1;
1283
+ }
1284
+ return result;
1222
1285
  }
1223
- function linePattern(value) {
1224
- return value.replace(/\r\n?/g, "\n").split("\n");
1286
+ function findUnescaped2(value, needle, from = 0) {
1287
+ let index = value.indexOf(needle, from);
1288
+ while (index !== -1) {
1289
+ if (!isEscapedAt(value, index)) return index;
1290
+ index = value.indexOf(needle, index + needle.length);
1291
+ }
1292
+ return -1;
1293
+ }
1294
+ function isEscapedAt(value, index) {
1295
+ let slashes = 0;
1296
+ for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) slashes += 1;
1297
+ return slashes % 2 === 1;
1225
1298
  }
1226
- function scoreContext2(markdown, range, anchor) {
1299
+ function statusLookup(statuses) {
1300
+ if (!statuses) return () => "open";
1301
+ if (isStatusMap(statuses)) return (suggestionId) => statuses.get(suggestionId) ?? "open";
1302
+ if (Array.isArray(statuses)) {
1303
+ const map = /* @__PURE__ */ new Map();
1304
+ for (const item of statuses) {
1305
+ const id = item.id ?? item.suggestionId;
1306
+ if (id && item.status) map.set(id, item.status);
1307
+ }
1308
+ return (suggestionId) => map.get(suggestionId) ?? "open";
1309
+ }
1310
+ const record = statuses;
1311
+ return (suggestionId) => record[suggestionId] ?? "open";
1312
+ }
1313
+ function isStatusMap(value) {
1314
+ return typeof value.get === "function";
1315
+ }
1316
+ function acceptedText(token) {
1317
+ return token.kind === "delete" ? "" : token.after;
1318
+ }
1319
+ function originalText(token) {
1320
+ return token.kind === "insert" ? "" : token.before;
1321
+ }
1322
+ function resolutionText(token, resolution) {
1323
+ return resolution === "accept" ? acceptedText(token) : originalText(token);
1324
+ }
1325
+ function kindForText(before, after) {
1326
+ if (before.length === 0) return "insert";
1327
+ if (after.length === 0) return "delete";
1328
+ return "replace";
1329
+ }
1330
+ function assertNoDuplicateSuggestionId(parsed, suggestionId) {
1331
+ if (parsed.suggestions.some((suggestion) => suggestion.id === suggestionId)) {
1332
+ throw new MdocsError("validation_error", `Duplicate suggestion id: ${suggestionId}`, "Suggestion ids must be stable and unique per document.");
1333
+ }
1334
+ }
1335
+ function assertCanonicalSpan(markdown, start, end) {
1336
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end > markdown.length) {
1337
+ throw new MdocsError("invalid_range", `Invalid canonical span ${start}:${end}.`, "Re-read the document and try again.");
1338
+ }
1339
+ }
1340
+ function selectionTouchesSuggestion(tokens, start, end) {
1341
+ return tokens.some((token) => {
1342
+ if (token.canonicalStart === token.canonicalEnd) return start <= token.canonicalStart && end >= token.canonicalEnd;
1343
+ return start < token.canonicalEnd && end > token.canonicalStart;
1344
+ });
1345
+ }
1346
+ function rawOffsetForCanonicalOffset(segments, offset) {
1347
+ for (const segment of segments) {
1348
+ if (segment.type === "text") {
1349
+ if (offset >= segment.canonicalStart && offset <= segment.canonicalEnd) {
1350
+ return segment.start + (offset - segment.canonicalStart);
1351
+ }
1352
+ continue;
1353
+ }
1354
+ const token = segment.token;
1355
+ if (offset > token.canonicalStart && offset < token.canonicalEnd) {
1356
+ throw new MdocsError(
1357
+ "conflict",
1358
+ "Selection falls inside an unresolved suggestion.",
1359
+ "Accept or reject the existing suggestion before editing inside it."
1360
+ );
1361
+ }
1362
+ }
1363
+ if (offset === segmentsEndCanonicalOffset(segments)) return segmentsEndRawOffset(segments);
1364
+ throw new MdocsError("invalid_range", "Could not map canonical offset into review Markdown.", "Re-read the document and try again.");
1365
+ }
1366
+ function segmentsEndCanonicalOffset(segments) {
1367
+ const last = segments[segments.length - 1];
1368
+ if (!last) return 0;
1369
+ return last.type === "text" ? last.canonicalEnd : last.token.canonicalEnd;
1370
+ }
1371
+ function segmentsEndRawOffset(segments) {
1372
+ const last = segments[segments.length - 1];
1373
+ if (!last) return 0;
1374
+ return last.type === "text" ? last.end : last.token.markerEnd;
1375
+ }
1376
+ function lineRangeOffsets(markdown, range) {
1227
1377
  const lines = getLines(markdown);
1228
- let score = anchor.selector.quote === extractLineRange(markdown, range) ? 0.7 : 0.65;
1229
- const expectedPrefix = contextPrefix2(anchor.selector.prefix);
1230
- if (expectedPrefix) {
1231
- const actualPrefix = contextPrefix2(lines.slice(Math.max(0, range.startLine - 4), range.startLine - 1).join("\n"));
1232
- if (actualPrefix?.endsWith(expectedPrefix)) score += 0.15;
1378
+ if (range.startLine < 1 || range.endLine < range.startLine || range.endLine > lines.length) {
1379
+ throw new MdocsError("invalid_range", `Invalid line range ${range.startLine}:${range.endLine}.`, "Re-read the document and try again.");
1233
1380
  }
1234
- const expectedSuffix = contextSuffix2(anchor.selector.suffix);
1235
- if (expectedSuffix) {
1236
- const actualSuffix = contextSuffix2(lines.slice(range.endLine, range.endLine + 3).join("\n"));
1237
- if (actualSuffix?.startsWith(expectedSuffix)) score += 0.15;
1381
+ let start = 0;
1382
+ for (let index = 0; index < range.startLine - 1; index += 1) start += lines[index].length + 1;
1383
+ let end = start;
1384
+ for (let index = range.startLine - 1; index < range.endLine; index += 1) {
1385
+ if (index > range.startLine - 1) end += 1;
1386
+ end += lines[index].length;
1387
+ }
1388
+ return [start, end];
1389
+ }
1390
+ function lineRangeReplacementOffsets(markdown, range) {
1391
+ const [start, contentEnd] = lineRangeOffsets(markdown, range);
1392
+ if (contentEnd < markdown.length && markdown[contentEnd] === "\n") return [start, contentEnd + 1];
1393
+ return [start, contentEnd];
1394
+ }
1395
+ function replacementTextForSpan(markdown, patch, start, end) {
1396
+ const applied = replaceLineRange(markdown, patch.range, patch.after);
1397
+ const suffixLength = markdown.length - end;
1398
+ const replacementEnd = applied.length - suffixLength;
1399
+ if (replacementEnd < start) return "";
1400
+ return applied.slice(start, replacementEnd);
1401
+ }
1402
+ function lineRangeForOffsets(markdown, start, end) {
1403
+ const starts = lineStarts(markdown);
1404
+ return {
1405
+ startLine: lineNumberAt(starts, start),
1406
+ endLine: lineNumberAt(starts, Math.max(start, end - 1))
1407
+ };
1408
+ }
1409
+ function lineStarts(markdown) {
1410
+ const starts = [0];
1411
+ for (let index = 0; index < markdown.length; index += 1) {
1412
+ if (markdown[index] === "\n") starts.push(index + 1);
1238
1413
  }
1239
- return Math.min(1, score);
1414
+ return starts;
1240
1415
  }
1241
- function contextPrefix2(value) {
1242
- return value?.split(/\r?\n/).filter(Boolean).slice(-3).join("\n") || void 0;
1416
+ function lineNumberAt(starts, offset) {
1417
+ let line = 1;
1418
+ for (let index = 0; index < starts.length; index += 1) {
1419
+ if (starts[index] <= offset) line = index + 1;
1420
+ else break;
1421
+ }
1422
+ return line;
1423
+ }
1424
+ function encodeAttribute(value) {
1425
+ return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
1426
+ }
1427
+ function decodeAttribute(value) {
1428
+ return value.replaceAll("&quot;", '"').replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
1243
1429
  }
1244
- function contextSuffix2(value) {
1245
- return value?.split(/\r?\n/).filter(Boolean).slice(0, 3).join("\n") || void 0;
1430
+ function invalidReviewMarkdown(message) {
1431
+ return new MdocsError("validation_error", `Invalid review Markdown: ${message}`, "Repair or remove the malformed suggestion markup.");
1246
1432
  }
1247
1433
 
1248
1434
  // ../core/src/graph.ts
@@ -1547,6 +1733,7 @@ async function getDocumentState(io, pathOrDocId) {
1547
1733
  path: entry.path,
1548
1734
  title: entry.title,
1549
1735
  markdown,
1736
+ reviewMarkdown: nextSidecar.reviewMarkdown ?? markdown,
1550
1737
  sidecar: nextSidecar,
1551
1738
  anchors: remappedAnchors.map((anchor) => ({
1552
1739
  id: anchor.id,
@@ -1634,6 +1821,7 @@ async function addSuggestion(io, pathOrDocId, range, replacement, message, autho
1634
1821
  id: createId("suggestion"),
1635
1822
  anchorId: anchor.id,
1636
1823
  status: "open",
1824
+ kind: replacement.length === 0 ? "delete" : before.length === 0 ? "insert" : "replace",
1637
1825
  author,
1638
1826
  message,
1639
1827
  patch: {
@@ -1650,53 +1838,72 @@ async function addSuggestion(io, pathOrDocId, range, replacement, message, autho
1650
1838
  createdAt: now,
1651
1839
  updatedAt: now
1652
1840
  };
1653
- await writeSidecar(io, {
1654
- ...state.sidecar,
1655
- anchors: [...state.sidecar.anchors, anchor],
1656
- suggestions: [...state.sidecar.suggestions, suggestion],
1841
+ const review = createSuggestionMarkup(state.sidecar.reviewMarkdown ?? state.markdown, suggestion.patch, {
1842
+ suggestionId: suggestion.id,
1843
+ author,
1844
+ message,
1845
+ createdAt: now,
1657
1846
  updatedAt: now
1658
1847
  });
1659
- return suggestion;
1848
+ const nextSidecar = deriveSidecarFromReviewMarkdown(
1849
+ { ...state.sidecar, reviewMarkdown: review.reviewMarkdown, anchors: [...state.sidecar.anchors, anchor] },
1850
+ review.reviewMarkdown,
1851
+ { author, baseHead: entry?.currentSha, now }
1852
+ );
1853
+ await writeSidecar(io, nextSidecar);
1854
+ const enriched = nextSidecar.suggestions.find((candidate) => candidate.id === suggestion.id);
1855
+ if (!enriched) throw new MdocsError("validation_error", `Could not derive suggestion ${suggestion.id}.`, "Refresh review state and try again.");
1856
+ return enriched;
1660
1857
  }
1661
- async function updateSuggestionStatus(io, suggestionId, status, actor = defaultActor()) {
1858
+ async function acceptSuggestion(io, suggestionId, actor = defaultActor()) {
1662
1859
  const manifest = await indexWorkspace(io);
1663
1860
  for (const entry of manifest.docs) {
1861
+ const markdown = await io.readText(entry.path);
1664
1862
  const sidecar = await readSidecar(io, entry.docId);
1665
1863
  const suggestion = sidecar.suggestions.find((candidate) => candidate.id === suggestionId);
1666
1864
  if (!suggestion) continue;
1667
1865
  const now = nowIso();
1668
- const updated = {
1669
- ...suggestion,
1670
- status,
1671
- updatedAt: now,
1672
- resolvedAt: now,
1673
- resolvedBy: actor
1674
- };
1675
- await writeSidecar(io, {
1676
- ...sidecar,
1677
- suggestions: sidecar.suggestions.map((candidate) => candidate.id === suggestionId ? updated : candidate),
1678
- updatedAt: now
1679
- });
1680
- return updated;
1866
+ const reviewMarkdown = sidecar.reviewMarkdown ?? markdown;
1867
+ const resolvedReviewMarkdown = resolveSuggestionMarkup(reviewMarkdown, suggestionId, "accept");
1868
+ const nextMarkdown = projectCanonicalMarkdown(resolvedReviewMarkdown);
1869
+ assertCleanMarkdown(nextMarkdown);
1870
+ const nextSidecar = deriveSidecarFromReviewMarkdown(
1871
+ { ...sidecar, reviewMarkdown: resolvedReviewMarkdown },
1872
+ resolvedReviewMarkdown,
1873
+ { author: suggestion.author, baseHead: entry.currentSha, now }
1874
+ );
1875
+ await io.writeText(entry.path, nextMarkdown);
1876
+ await writeSidecar(io, nextSidecar);
1877
+ return resolvedSuggestion(suggestion, "accepted", actor, now);
1681
1878
  }
1682
1879
  throw new MdocsError("not_found", `Unknown suggestion: ${suggestionId}`, "List suggestion ids with the suggestions command.");
1683
1880
  }
1684
- async function acceptSuggestion(io, suggestionId, actor = defaultActor()) {
1881
+ async function rejectSuggestion(io, suggestionId, actor = defaultActor()) {
1685
1882
  const manifest = await indexWorkspace(io);
1686
1883
  for (const entry of manifest.docs) {
1687
- const markdown = await io.readText(entry.path);
1688
1884
  const sidecar = await readSidecar(io, entry.docId);
1689
1885
  const suggestion = sidecar.suggestions.find((candidate) => candidate.id === suggestionId);
1690
1886
  if (!suggestion) continue;
1691
- const anchor = sidecar.anchors.find((candidate) => candidate.id === suggestion.anchorId);
1692
- const nextMarkdown = applySuggestion(markdown, suggestion, anchor);
1693
- await io.writeText(entry.path, nextMarkdown);
1694
- return updateSuggestionStatus(io, suggestionId, "accepted", actor);
1887
+ const now = nowIso();
1888
+ const updated = resolvedSuggestion(suggestion, "rejected", actor, now);
1889
+ const reviewMarkdown = resolveSuggestionMarkup(sidecar.reviewMarkdown ?? "", suggestionId, "reject");
1890
+ const nextSidecar = deriveSidecarFromReviewMarkdown({ ...sidecar, reviewMarkdown }, reviewMarkdown, {
1891
+ author: suggestion.author,
1892
+ now
1893
+ });
1894
+ await writeSidecar(io, nextSidecar);
1895
+ return updated;
1695
1896
  }
1696
1897
  throw new MdocsError("not_found", `Unknown suggestion: ${suggestionId}`, "List suggestion ids with the suggestions command.");
1697
1898
  }
1698
- async function rejectSuggestion(io, suggestionId, actor = defaultActor()) {
1699
- return updateSuggestionStatus(io, suggestionId, "rejected", actor);
1899
+ function resolvedSuggestion(suggestion, status, actor, now) {
1900
+ return {
1901
+ ...suggestion,
1902
+ status,
1903
+ updatedAt: now,
1904
+ resolvedAt: now,
1905
+ resolvedBy: actor
1906
+ };
1700
1907
  }
1701
1908
  async function resolveComment(io, commentId, actor = defaultActor()) {
1702
1909
  const manifest = await indexWorkspace(io);
@@ -11056,7 +11263,7 @@ var RemoteDocumentIO = class {
11056
11263
  };
11057
11264
 
11058
11265
  // src/agent.ts
11059
- var CLI_VERSION = "0.3.13";
11266
+ var CLI_VERSION = "0.3.17";
11060
11267
  var CLI_PACKAGE_NAME = "@magic-markdown/cli";
11061
11268
  var AGENT_COMMANDS = [
11062
11269
  {
@@ -11233,7 +11440,7 @@ var AGENT_COMMANDS = [
11233
11440
  },
11234
11441
  {
11235
11442
  name: "suggest",
11236
- summary: "Create a sidecar replacement suggestion without modifying clean Markdown.",
11443
+ summary: "Create a reviewMarkdown replacement suggestion without modifying canonical Markdown.",
11237
11444
  usage: "mdocs suggest <path|docId> --range <start:end> --with <markdown> --message <text> --json",
11238
11445
  output: "json",
11239
11446
  mutates: true,
@@ -11343,20 +11550,39 @@ var AGENT_COMMANDS = [
11343
11550
  {
11344
11551
  name: "bridge resume",
11345
11552
  summary: "Backfill missed Magic changes after an agent restart, then keep the bridge running.",
11346
- usage: "mdocs bridge resume --root <path> [--url <base-url>] [--workspace <id>] [--root-id <id>] [--request-token] [--once]",
11553
+ usage: "mdocs bridge resume --root <path> [--url <base-url>] [--workspace <id>] [--root-id <id>] [--request-token] [--forever] [--once]",
11347
11554
  output: "long-running",
11348
11555
  mutates: true,
11349
11556
  examples: [
11350
- "mdocs bridge resume --root . --request-token",
11557
+ "mdocs bridge resume --root . --forever --request-token",
11351
11558
  "mdocs bridge resume --root . --once --request-token"
11352
11559
  ],
11353
11560
  notes: [
11354
11561
  "Reads non-secret connection defaults from .mdocs/bridge.json written by bridge setup.",
11355
11562
  "Use this after a sandbox/container/agent session restarts. It backfills canonical Magic changes before publishing local edits.",
11563
+ "--forever is the intended startup/sandbox command: keep it running for live collaboration and liveness heartbeats.",
11356
11564
  "--request-token opens a human approval URL when no MDOCS_BRIDGE_TOKEN is available; do not ask users to paste bridge tokens by default.",
11357
11565
  "--once performs only the backfill/reconcile step and exits."
11358
11566
  ]
11359
11567
  },
11568
+ {
11569
+ name: "bridge startup",
11570
+ summary: "Write a bridge runner and optional launchd/systemd startup hook for long-lived bridge environments.",
11571
+ usage: "mdocs bridge startup --root <path> [--mode auto|shell|launchd|systemd] [--install] [--json]",
11572
+ output: "json",
11573
+ mutates: true,
11574
+ examples: [
11575
+ "mdocs bridge startup --root . --json",
11576
+ "mdocs bridge startup --root . --mode shell --json",
11577
+ "mdocs bridge startup --root . --mode systemd --install --json"
11578
+ ],
11579
+ notes: [
11580
+ "Run this after bridge setup has written .mdocs/bridge.json.",
11581
+ "Sandbox providers should put the returned startupCommand in their on-start hook.",
11582
+ "--install installs and starts the generated launchd/systemd user service when the host supports it.",
11583
+ "The generated runner stores no bridge token; provide MDOCS_BRIDGE_TOKEN through platform secrets if you use manual tokens."
11584
+ ]
11585
+ },
11360
11586
  {
11361
11587
  name: "bridge",
11362
11588
  summary: "Sync a local Markdown root with a Magic workspace over WebSocket (long-running).",
@@ -11424,7 +11650,7 @@ Run \`mdocs help <command>\` (or \`mdocs <command> --help\`) for usage, examples
11424
11650
  4. Run \`mdocs map --json\` or \`mdocs remote map --json\` to list documents, paths, docIds, open comments, open suggestions, anchor review counts, and link counts. File-scoped joins (most share links) cover a single document, so \`mdocs remote map\` is unavailable for them \u2014 use \`mdocs remote context --summary --json\` instead. Project-scoped joins cover one root; workspace-scoped joins cover Home and can target duplicate paths as \`<rootId>:<path-or-docId>\`.
11425
11651
  5. Run \`mdocs graph --json\` or project-scoped \`mdocs remote graph --json\` before broad edits to inspect the Obsidian-style document graph built from Markdown links and wikilinks.
11426
11652
  6. For a document, run \`mdocs context <path|docId> --summary --json\` locally or \`mdocs remote context <path|docId> --summary --json\` for a joined share before reading full content. Then page Markdown with \`--start-line\` / \`--end-line\` and \`--no-review\` when you only need document text.
11427
- 7. Pull review state separately with \`mdocs review <path|docId> --json\` locally or \`mdocs remote review <path|docId> --json\` for a joined share. Use \`mdocs comment\` / \`mdocs remote comment\` for review notes and \`mdocs suggest\` / \`mdocs remote suggest\` for proposed replacements. Do not insert comments, CriticMarkup, directives, or Magic markers into Markdown files.
11653
+ 7. Pull review state separately with \`mdocs review <path|docId> --json\` locally or \`mdocs remote review <path|docId> --json\` for a joined share. Use \`mdocs comment\` / \`mdocs remote comment\` for review notes and \`mdocs suggest\` / \`mdocs remote suggest\` for proposed replacements. Do not hand-edit canonical Markdown files or \`.mdocs\` reviewMarkdown syntax directly.
11428
11654
 
11429
11655
  ## Filesystem Bridge / Resume
11430
11656
 
@@ -11445,16 +11671,24 @@ Return the Magic approval link and keep the bridge process running after approva
11445
11671
  If your sandbox, container, terminal, or agent session restarts after setup, do not ask for a raw bridge token. Start the latest CLI again and resume from the same root:
11446
11672
 
11447
11673
  \`\`\`bash
11448
- npx --yes --package=@magic-markdown/cli@latest mdocs bridge resume --root . --request-token
11674
+ npx --yes --package=@magic-markdown/cli@latest mdocs bridge resume --root . --forever --request-token
11449
11675
  \`\`\`
11450
11676
 
11451
- \`bridge resume\` reads non-secret defaults from \`.mdocs/bridge.json\`, backfills missed Magic changes before publishing local edits, and then keeps polling/watching. Use \`--once\` only when the user asked for a one-shot backfill rather than a live bridge.
11677
+ \`bridge resume --forever\` reads non-secret defaults from \`.mdocs/bridge.json\`, backfills missed Magic changes before publishing local edits, and then keeps polling/watching with liveness heartbeats. Use \`--once\` only when the user asked for a one-shot backfill rather than a live bridge.
11678
+
11679
+ For sandboxes, VPSs, or local machines that support startup hooks, generate a durable runner after setup:
11680
+
11681
+ \`\`\`bash
11682
+ npx --yes --package=@magic-markdown/cli@latest mdocs bridge startup --root . --json
11683
+ \`\`\`
11684
+
11685
+ Put the returned \`startupCommand\` into the provider's on-start hook. On local macOS or systemd Linux hosts, \`mdocs bridge startup --root . --install\` writes and starts a user service. The generated runner stores no bridge token; use platform secrets for \`MDOCS_BRIDGE_TOKEN\` if you manage tokens manually.
11452
11686
 
11453
11687
  If Magic and the local root both changed the same document while you were offline, the bridge keeps the Magic canonical path, saves your local divergent version as a \`.conflict-...\` Markdown copy, and lets the conflict copy sync as a normal file. If Magic reports a server-side conflict, wait for the human to resolve it in Magic Markdown before retrying content pushes.
11454
11688
 
11455
11689
  ## Editing Rules
11456
11690
 
11457
- - Comments and suggestions are sidecar operations. They should not modify clean Markdown until a suggestion is accepted.
11691
+ - Comments are sidecar operations; suggestions are reviewMarkdown operations derived into sidecar state. They should not modify canonical Markdown until a suggestion is accepted.
11458
11692
  - \`--range <start:end>\` is 1-based and inclusive, and validated against the current document: read the document first and quote line numbers from what you just read.
11459
11693
  - Prefer the smallest coherent suggestion range that contains the actual change. If one sentence, list item, table row, or short paragraph changes, suggest that unit rather than replacing surrounding unchanged paragraphs or sections.
11460
11694
  - Broader paragraph, section, or multi-section rewrites are appropriate when the edit genuinely changes structure, ordering, transitions, or multiple interdependent ideas. Do not avoid a long edit when it is the clearest correct revision.
@@ -12085,7 +12319,7 @@ var tools = [
12085
12319
  },
12086
12320
  {
12087
12321
  name: "mdocs_suggest",
12088
- description: "Create a sidecar replacement suggestion without modifying clean Markdown. Prefer the smallest coherent range; use longer ranges only when the broader rewrite is genuinely needed.",
12322
+ description: "Create a reviewMarkdown replacement suggestion without modifying canonical Markdown. Prefer the smallest coherent range; use longer ranges only when the broader rewrite is genuinely needed.",
12089
12323
  inputSchema: {
12090
12324
  type: "object",
12091
12325
  properties: {
@@ -12842,7 +13076,6 @@ function joinStoreDir(root) {
12842
13076
  }
12843
13077
 
12844
13078
  // src/remote.ts
12845
- var PUSH_REBASE_ATTEMPTS = 3;
12846
13079
  async function runJoinCommand(root, parsed) {
12847
13080
  const target = parsed.command[1];
12848
13081
  const existingId = typeof parsed.flags.id === "string" ? parsed.flags.id : void 0;
@@ -13172,6 +13405,7 @@ async function remoteCreateFile(root, record, parsed) {
13172
13405
  path: normalizedPath,
13173
13406
  title,
13174
13407
  markdown,
13408
+ reviewMarkdown: markdown,
13175
13409
  sidecar,
13176
13410
  anchors: [],
13177
13411
  images: [],
@@ -13238,7 +13472,7 @@ async function remoteSuggest(root, record, parsed) {
13238
13472
  const result = await postReviewOperation(
13239
13473
  documentRecord,
13240
13474
  document.docId,
13241
- { kind: "create_suggestion", payload: { ...range, replacement, message } },
13475
+ { kind: "create_suggestion", baseHead: document.currentSha, payload: { ...range, replacement, message } },
13242
13476
  actorForRecord(record)
13243
13477
  );
13244
13478
  if (result.document) await recordHead(root, record, result.document);
@@ -13333,44 +13567,14 @@ async function remoteReject(root, record, parsed) {
13333
13567
  hint: "Only open suggestions can be rejected."
13334
13568
  });
13335
13569
  }
13336
- try {
13337
- const additions = { anchors: [], comments: [], suggestions: [], withdrawSuggestionIds: [suggestionId] };
13338
- const pushed = await postReview(documentRecord, document.docId, additions, actor);
13339
- await recordHead(root, record, pushed);
13340
- const updated = pushed.sidecar.suggestions.find((candidate) => candidate.id === suggestionId);
13341
- return { suggestion: updated ?? suggestion, document: pushed };
13342
- } catch (error) {
13343
- if (!isWithdrawalUnsupported(error)) throw error;
13344
- }
13345
- if (suggestion.author.id !== actor.id) {
13346
- throw new CliError("unauthorized", "You can only withdraw your own suggestions; rejecting another author's requires edit access.", {
13347
- hint: "Ask a human collaborator to reject it from the editor."
13348
- });
13349
- }
13350
- for (let index = 0; index < PUSH_REBASE_ATTEMPTS; index += 1) {
13351
- if (index > 0) document = await fetchDocument(record, document.docId);
13352
- documentRecord = rootScopedRecordFor(record, document.rootId);
13353
- const io = new RemoteDocumentIO(document);
13354
- const rejected = await rejectSuggestion(io, suggestionId, actor);
13355
- const state = await getDocumentState(io, document.docId);
13356
- try {
13357
- const pushed = await pushDocument(documentRecord, document, state.markdown, state.sidecar);
13358
- await recordHead(root, record, pushed);
13359
- return { suggestion: rejected, document: pushed };
13360
- } catch (error) {
13361
- const conflicted = isCliError(error) && error.code === "conflict";
13362
- if (!conflicted || index === PUSH_REBASE_ATTEMPTS - 1) throw error;
13363
- }
13364
- }
13365
- throw new CliError("conflict", "Remote change was not accepted after retries.", {
13366
- hint: "The document is changing rapidly. Refetch with mdocs remote context --summary --json, then retry against the needed content range."
13367
- });
13368
- }
13369
- function isWithdrawalUnsupported(error) {
13370
- if (isReviewEndpointUnsupported(error)) return true;
13371
- if (!isCliError(error)) return false;
13372
- const details = error.details ?? {};
13373
- return details.error === "empty_review";
13570
+ const result = await postReviewOperation(
13571
+ documentRecord,
13572
+ document.docId,
13573
+ { kind: "reject_suggestion", baseHead: document.currentSha, payload: { suggestionId } },
13574
+ actor
13575
+ );
13576
+ if (result.document) await recordHead(root, record, result.document);
13577
+ return { suggestion: result.suggestion ?? { ...suggestion, status: "rejected" }, reviewRecord: result.reviewRecord, document: result.document };
13374
13578
  }
13375
13579
  async function submitReview(root, record, pathOrDocId, mutate) {
13376
13580
  if (!pathOrDocId) {
@@ -13391,43 +13595,14 @@ async function submitReview(root, record, pathOrDocId, mutate) {
13391
13595
  additions: {
13392
13596
  anchors: state.sidecar.anchors.filter((anchor) => anchor.id === created.anchorId),
13393
13597
  comments: state.sidecar.comments.filter((comment2) => comment2.id === created.id),
13394
- suggestions: state.sidecar.suggestions.filter((suggestion) => suggestion.id === created.id)
13598
+ reviewMarkdown: state.sidecar.reviewMarkdown
13395
13599
  }
13396
13600
  };
13397
13601
  };
13398
- let attempt = await build(document);
13399
- try {
13400
- const pushed = await postReview(documentRecord, document.docId, attempt.additions, actorForRecord(record));
13401
- await recordHead(root, record, pushed);
13402
- return { created: attempt.created, document: pushed };
13403
- } catch (error) {
13404
- if (!isReviewEndpointUnsupported(error)) throw error;
13405
- }
13406
- for (let index = 0; index < PUSH_REBASE_ATTEMPTS; index += 1) {
13407
- if (index > 0) {
13408
- document = await fetchDocument(record, document.docId);
13409
- documentRecord = rootScopedRecordFor(record, document.rootId);
13410
- attempt = await build(document);
13411
- }
13412
- try {
13413
- const pushed = await pushDocument(documentRecord, document, attempt.state.markdown, attempt.state.sidecar);
13414
- await recordHead(root, record, pushed);
13415
- return { created: attempt.created, document: pushed };
13416
- } catch (error) {
13417
- const conflicted = isCliError(error) && error.code === "conflict";
13418
- if (!conflicted || index === PUSH_REBASE_ATTEMPTS - 1) throw error;
13419
- }
13420
- }
13421
- throw new CliError("conflict", "Remote change was not accepted after retries.", {
13422
- hint: "The document is changing rapidly. Refetch with mdocs remote context --summary --json, then retry against the needed content range."
13423
- });
13424
- }
13425
- function isReviewEndpointUnsupported(error) {
13426
- if (!isCliError(error)) return false;
13427
- if (error.code === "unauthorized") return true;
13428
- if (error.code !== "not_found") return false;
13429
- const details = error.details ?? {};
13430
- return details.error !== "document_not_found";
13602
+ const attempt = await build(document);
13603
+ const pushed = await postReview(documentRecord, document.docId, attempt.additions, actorForRecord(record));
13604
+ await recordHead(root, record, pushed);
13605
+ return { created: attempt.created, document: pushed };
13431
13606
  }
13432
13607
  async function recordHead(root, record, document) {
13433
13608
  await writeJoinRecord(root, { ...record, updatedAt: (/* @__PURE__ */ new Date()).toISOString(), currentHead: document.currentSha });
@@ -13602,6 +13777,8 @@ function optionalFolderTarget(value) {
13602
13777
  import { createHash as createHash2, randomUUID as randomUUID3 } from "node:crypto";
13603
13778
  import { basename as basename2, resolve as resolve4 } from "node:path";
13604
13779
  var BRIDGE_CONFIG_PATH = ".mdocs/bridge.json";
13780
+ var BRIDGE_STATUS_PATH = ".mdocs/bridge-status.json";
13781
+ var BRIDGE_HEARTBEAT_INTERVAL_MS = 1e4;
13605
13782
  function bridgeSetupIdentity(input) {
13606
13783
  const actorName = input.actorName?.trim() || "Agent";
13607
13784
  return {
@@ -13667,9 +13844,11 @@ async function runBridgeResume(options) {
13667
13844
  }
13668
13845
  async function runBridge(options) {
13669
13846
  const root = resolve4(options.root);
13847
+ if (options.once && options.forever) {
13848
+ throw new Error("Use either --once or --forever, not both.");
13849
+ }
13670
13850
  const localManifestSource = await readLocalSource(root);
13671
13851
  const rootId = options.rootId ?? options.sourceId ?? localManifestSource?.sourceId ?? rootIdForPath(root);
13672
- const token = options.token ?? (options.requestToken ? await requestBridgeToken(options, rootId) : void 0);
13673
13852
  const replicaId = `replica_${createHash2("sha256").update(`${root}:${options.actorId}`).digest("hex").slice(0, 12)}`;
13674
13853
  const replicaKind = actorKindForBridge(options.actorId) === "agent" ? "agent_runtime" : "local";
13675
13854
  const mapping = createSourceMapping({
@@ -13692,7 +13871,29 @@ async function runBridge(options) {
13692
13871
  const claimMode = Boolean(options.claimToken || localManifestSource?.canonicalHead);
13693
13872
  let lastAppliedHead = localManifestSource?.canonicalHead;
13694
13873
  let socket;
13695
- const registeredRoot = token ? await fetchScopedRoot({ ...options, token }, rootId) : await registerRoot(options, root, rootId, mapping);
13874
+ let lastSuccessfulSyncAt;
13875
+ let lastHeartbeatAt;
13876
+ let lastHeartbeatSentMs = 0;
13877
+ let lastError;
13878
+ await writeCurrentBridgeStatus("starting");
13879
+ let token = options.token;
13880
+ if (!token && options.requestToken) {
13881
+ try {
13882
+ token = await requestBridgeToken(options, rootId);
13883
+ } catch (error) {
13884
+ lastError = errorMessage(error);
13885
+ await writeCurrentBridgeStatus("error");
13886
+ throw error;
13887
+ }
13888
+ }
13889
+ let registeredRoot;
13890
+ try {
13891
+ registeredRoot = token ? await fetchScopedRoot({ ...options, token }, rootId) : await registerRoot(options, root, rootId, mapping);
13892
+ } catch (error) {
13893
+ lastError = errorMessage(error);
13894
+ await writeCurrentBridgeStatus("error");
13895
+ throw error;
13896
+ }
13696
13897
  lastAppliedHead = lastAppliedHead ?? registeredRoot?.canonical.head;
13697
13898
  await writeSourceState(lastAppliedHead);
13698
13899
  await writeBridgeConfig(root, {
@@ -13710,20 +13911,30 @@ async function runBridge(options) {
13710
13911
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
13711
13912
  });
13712
13913
  const backfilled = await resumeFromCanonical();
13713
- if (options.once) return;
13914
+ if (options.once) {
13915
+ lastSuccessfulSyncAt = (/* @__PURE__ */ new Date()).toISOString();
13916
+ await writeCurrentBridgeStatus("once_complete");
13917
+ return;
13918
+ }
13714
13919
  connect();
13715
13920
  if (claimMode && !backfilled) await primeLocalSignatures();
13716
13921
  else await publishSnapshot("initial");
13717
13922
  const timer = setInterval(() => {
13718
13923
  void publishSnapshot("poll").catch((error) => {
13719
- process.stderr.write(`bridge poll failed: ${error instanceof Error ? error.message : String(error)}
13924
+ lastError = errorMessage(error);
13925
+ void writeCurrentBridgeStatus("error").catch(() => void 0);
13926
+ sendHeartbeat("error", lastError);
13927
+ process.stderr.write(`bridge poll failed: ${lastError}
13720
13928
  `);
13721
13929
  });
13722
13930
  }, options.intervalMs);
13723
- process.on("SIGINT", () => {
13931
+ process.once("SIGINT", () => {
13724
13932
  clearInterval(timer);
13725
- socket?.close();
13726
- process.exit(0);
13933
+ sendHeartbeat("offline", void 0, false);
13934
+ void writeCurrentBridgeStatus("stopped").catch(() => void 0).finally(() => {
13935
+ socket?.close();
13936
+ process.exit(0);
13937
+ });
13727
13938
  });
13728
13939
  await new Promise(() => void 0);
13729
13940
  function connect() {
@@ -13734,8 +13945,11 @@ async function runBridge(options) {
13734
13945
  if (token) url.searchParams.set("token", token);
13735
13946
  socket = new WebSocket(url);
13736
13947
  socket.addEventListener("open", () => {
13948
+ lastError = void 0;
13737
13949
  process.stdout.write(`mdocs bridge connected ${root} -> ${options.workspaceId}/${rootId}
13738
13950
  `);
13951
+ sendHeartbeat("synced");
13952
+ void writeCurrentBridgeStatus("connected").catch(() => void 0);
13739
13953
  });
13740
13954
  socket.addEventListener("message", (event) => {
13741
13955
  if (typeof event.data !== "string") return;
@@ -13748,9 +13962,12 @@ async function runBridge(options) {
13748
13962
  });
13749
13963
  socket.addEventListener("close", () => {
13750
13964
  socket = void 0;
13965
+ void writeCurrentBridgeStatus("offline").catch(() => void 0);
13751
13966
  setTimeout(connect, 1e3);
13752
13967
  });
13753
13968
  socket.addEventListener("error", () => {
13969
+ lastError = "WebSocket error";
13970
+ void writeCurrentBridgeStatus("error").catch(() => void 0);
13754
13971
  socket?.close();
13755
13972
  });
13756
13973
  }
@@ -13759,7 +13976,7 @@ async function runBridge(options) {
13759
13976
  const currentDocIds = new Set(map.docs.map((doc) => doc.docId));
13760
13977
  const treeSignature = hashJson(map.docs.map((doc) => ({ docId: doc.docId, path: doc.path })));
13761
13978
  if (treeSignature !== lastTreeSignature) {
13762
- if (send("file-tree", { reason, docs: map.docs, mapping, canonicalHead: lastAppliedHead })) {
13979
+ if (send("file-tree", { reason, docs: map.docs, mapping: bridgeReplicaMetadata("synced"), canonicalHead: lastAppliedHead })) {
13763
13980
  lastTreeSignature = treeSignature;
13764
13981
  }
13765
13982
  }
@@ -13787,6 +14004,9 @@ async function runBridge(options) {
13787
14004
  pendingDeletes.add(docId);
13788
14005
  }
13789
14006
  }
14007
+ lastError = void 0;
14008
+ lastSuccessfulSyncAt = (/* @__PURE__ */ new Date()).toISOString();
14009
+ maybeSendHeartbeat("synced");
13790
14010
  }
13791
14011
  async function resumeFromCanonical() {
13792
14012
  if (!options.resume && !token) return false;
@@ -13899,7 +14119,9 @@ async function runBridge(options) {
13899
14119
  }
13900
14120
  if (message.type === "sync-error") {
13901
14121
  const payload = message.payload;
13902
- process.stderr.write(`sync rejected${payload.code ? ` (${payload.code})` : ""}: ${payload.hint ?? "see workspace settings"}
14122
+ lastError = `sync rejected${payload.code ? ` (${payload.code})` : ""}: ${payload.hint ?? "see workspace settings"}`;
14123
+ void writeCurrentBridgeStatus("error").catch(() => void 0);
14124
+ process.stderr.write(`${lastError}
13903
14125
  `);
13904
14126
  if (!payload.docId) return;
13905
14127
  pendingDocs.delete(payload.docId);
@@ -13938,6 +14160,12 @@ async function runBridge(options) {
13938
14160
  await io.writeTextAtomic(payload.path, payload.markdown);
13939
14161
  await io.writeTextAtomic(sidecarPath(payload.docId), `${JSON.stringify(payload.sidecar, null, 2)}
13940
14162
  `);
14163
+ const reviewMarkdown = projectReviewMarkdown(payload.markdown, payload.sidecar.suggestions);
14164
+ if (reviewMarkdown === payload.markdown) {
14165
+ await io.deleteFile(reviewFilePath(payload.path)).catch(() => void 0);
14166
+ } else {
14167
+ await io.writeTextAtomic(reviewFilePath(payload.path), reviewMarkdown);
14168
+ }
13941
14169
  await upsertManifestDocument(payload);
13942
14170
  signatures.set(payload.docId, remoteSignature);
13943
14171
  deniedSignatures.delete(payload.docId);
@@ -14018,6 +14246,63 @@ async function runBridge(options) {
14018
14246
  );
14019
14247
  return true;
14020
14248
  }
14249
+ function maybeSendHeartbeat(status) {
14250
+ if (Date.now() - lastHeartbeatSentMs < BRIDGE_HEARTBEAT_INTERVAL_MS) return;
14251
+ sendHeartbeat(status);
14252
+ }
14253
+ function sendHeartbeat(status, error, writeLocalStatus = true) {
14254
+ const heartbeatAt = (/* @__PURE__ */ new Date()).toISOString();
14255
+ const sent = send("bridge-heartbeat", {
14256
+ reason: "heartbeat",
14257
+ canonicalHead: lastAppliedHead,
14258
+ mapping: bridgeReplicaMetadata(status, error, heartbeatAt)
14259
+ });
14260
+ if (sent) {
14261
+ lastHeartbeatAt = heartbeatAt;
14262
+ lastHeartbeatSentMs = Date.now();
14263
+ if (writeLocalStatus) {
14264
+ void writeCurrentBridgeStatus(status === "error" ? "error" : status === "offline" ? "offline" : "connected").catch(() => void 0);
14265
+ }
14266
+ }
14267
+ return sent;
14268
+ }
14269
+ function bridgeReplicaMetadata(status, error, now = (/* @__PURE__ */ new Date()).toISOString()) {
14270
+ return {
14271
+ ...mapping,
14272
+ lastAppliedHead,
14273
+ status,
14274
+ updatedAt: now,
14275
+ lastSeenAt: now,
14276
+ bridgeVersion: CLI_VERSION,
14277
+ pendingDocuments: pendingDocs.size,
14278
+ pendingDeletes: pendingDeletes.size,
14279
+ ...error ? { lastError: error } : {}
14280
+ };
14281
+ }
14282
+ function currentBridgeStatus(state) {
14283
+ return {
14284
+ schemaVersion: 1,
14285
+ workspaceId: options.workspaceId,
14286
+ rootId,
14287
+ actorId: options.actorId,
14288
+ actorName: options.actorName,
14289
+ sourceName: options.sourceName,
14290
+ baseUrl: options.baseUrl,
14291
+ state,
14292
+ connected: state === "connected",
14293
+ bridgeVersion: CLI_VERSION,
14294
+ lastAppliedHead,
14295
+ lastSuccessfulSyncAt,
14296
+ lastHeartbeatAt,
14297
+ lastError,
14298
+ pendingDocuments: pendingDocs.size,
14299
+ pendingDeletes: pendingDeletes.size,
14300
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
14301
+ };
14302
+ }
14303
+ async function writeCurrentBridgeStatus(state) {
14304
+ await writeBridgeStatus(root, currentBridgeStatus(state));
14305
+ }
14021
14306
  async function primeLocalSignatures() {
14022
14307
  const map = await getWorkspaceMap(io);
14023
14308
  for (const doc of map.docs) {
@@ -14145,6 +14430,12 @@ async function writeBridgeConfig(root, config2) {
14145
14430
  await io.writeTextAtomic(BRIDGE_CONFIG_PATH, `${JSON.stringify(config2, null, 2)}
14146
14431
  `);
14147
14432
  }
14433
+ async function writeBridgeStatus(root, status) {
14434
+ const io = new NodeWorkspaceIO(root);
14435
+ await io.mkdir(".mdocs");
14436
+ await io.writeTextAtomic(BRIDGE_STATUS_PATH, `${JSON.stringify(status, null, 2)}
14437
+ `);
14438
+ }
14148
14439
  async function fetchCanonicalSnapshot(options, rootId) {
14149
14440
  const response = await fetch(new URL(`/api/workspaces/${encodeURIComponent(options.workspaceId)}/roots/${encodeURIComponent(rootId)}/sync`, options.baseUrl), {
14150
14441
  headers: authHeaders(options.token)
@@ -14215,11 +14506,14 @@ function parseBridgeSyncMessage(data) {
14215
14506
  }
14216
14507
  }
14217
14508
  function delay(ms) {
14218
- return new Promise((resolve6) => setTimeout(resolve6, ms));
14509
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
14219
14510
  }
14220
14511
  function authHeaders(token) {
14221
14512
  return token ? { Authorization: `Bearer ${token}` } : {};
14222
14513
  }
14514
+ function errorMessage(error) {
14515
+ return error instanceof Error ? error.message : String(error);
14516
+ }
14223
14517
  async function fetchScopedRoot(options, rootId) {
14224
14518
  const response = await fetch(
14225
14519
  new URL(`/api/workspaces/${encodeURIComponent(options.workspaceId)}/roots/${encodeURIComponent(rootId)}`, options.baseUrl),
@@ -14323,6 +14617,9 @@ function conflictCopyPath(path) {
14323
14617
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "").slice(0, 15);
14324
14618
  return path.replace(/(\.[^./]+)?$/, (extension) => `.conflict-${stamp}${extension || ".md"}`);
14325
14619
  }
14620
+ function reviewFilePath(path) {
14621
+ return path.replace(/(\.md)?$/i, ".review.md");
14622
+ }
14326
14623
  function hashJson(value) {
14327
14624
  return createHash2("sha256").update(JSON.stringify(value)).digest("hex");
14328
14625
  }
@@ -14354,10 +14651,246 @@ function readDocumentPayload(payload) {
14354
14651
  };
14355
14652
  }
14356
14653
 
14654
+ // src/bridge-startup.ts
14655
+ import { execFile as execFile2 } from "node:child_process";
14656
+ import { createHash as createHash3 } from "node:crypto";
14657
+ import { chmod, copyFile, mkdir as mkdir4, readFile as readFile6, stat as stat3, writeFile as writeFile4 } from "node:fs/promises";
14658
+ import { homedir, platform } from "node:os";
14659
+ import { dirname as dirname5, join as join4, resolve as resolve5 } from "node:path";
14660
+ import { promisify as promisify2 } from "node:util";
14661
+ var execFileAsync2 = promisify2(execFile2);
14662
+ async function runBridgeStartup(options) {
14663
+ const root = resolve5(options.root);
14664
+ await assertBridgeConfigured(root);
14665
+ const requestedMode = readBridgeStartupMode(options.mode);
14666
+ const mode = requestedMode === "auto" ? await autoBridgeStartupMode() : requestedMode;
14667
+ const startupDir = join4(root, ".mdocs", "startup");
14668
+ await mkdir4(startupDir, { recursive: true });
14669
+ const runnerPath = join4(startupDir, "bridge-runner.sh");
14670
+ const runnerArgs = bridgeResumeArgs(root, options);
14671
+ const runnerScript = bridgeRunnerScript(root, runnerArgs);
14672
+ await writeFile4(runnerPath, runnerScript, "utf8");
14673
+ await chmod(runnerPath, 493);
14674
+ const nativePlan = mode === "launchd" ? await writeLaunchdPlan(root, startupDir, runnerPath) : mode === "systemd" ? await writeSystemdPlan(root, startupDir, runnerPath) : void 0;
14675
+ if (options.install && !nativePlan) {
14676
+ throw new CliError("usage_error", "Shell startup mode does not have a native service to install.", {
14677
+ hint: "Use the printed startupCommand in your sandbox/provider startup hook, or pass --mode launchd/systemd on a supported host."
14678
+ });
14679
+ }
14680
+ if (options.install && nativePlan) await nativePlan.install();
14681
+ const result = {
14682
+ ok: true,
14683
+ root,
14684
+ mode,
14685
+ runnerPath,
14686
+ startupCommand: shellCommand(["sh", runnerPath]),
14687
+ servicePath: nativePlan?.servicePath,
14688
+ installPath: nativePlan?.installPath,
14689
+ installed: Boolean(options.install && nativePlan),
14690
+ installCommands: nativePlan?.installCommands ?? [],
14691
+ notes: [
14692
+ "Sandbox providers such as Daytona, Sprites, and Modal should run startupCommand from their on-start hook.",
14693
+ "The runner stores no bridge token. If you use manual tokens, provide MDOCS_BRIDGE_TOKEN through the platform secret environment.",
14694
+ "If no MDOCS_BRIDGE_TOKEN is present, the runner uses --request-token and logs a Magic approval URL."
14695
+ ]
14696
+ };
14697
+ await writeFile4(join4(startupDir, "bridge-startup.json"), `${JSON.stringify(result, null, 2)}
14698
+ `, "utf8");
14699
+ return result;
14700
+ }
14701
+ function formatBridgeStartupResult(result) {
14702
+ const lines = [
14703
+ `Wrote Magic Markdown bridge runner: ${result.runnerPath}`,
14704
+ `Startup command: ${result.startupCommand}`
14705
+ ];
14706
+ if (result.servicePath) lines.push(`Wrote ${result.mode} service file: ${result.servicePath}`);
14707
+ if (result.installed && result.installPath) {
14708
+ lines.push(`Installed ${result.mode} service: ${result.installPath}`);
14709
+ } else if (result.installCommands.length > 0) {
14710
+ lines.push("", "To install and start it manually:", ...result.installCommands.map((command) => ` ${command}`));
14711
+ }
14712
+ lines.push("", ...result.notes.map((note) => `Note: ${note}`));
14713
+ return lines.join("\n");
14714
+ }
14715
+ function bridgeRunnerScript(root, args) {
14716
+ return `#!/usr/bin/env sh
14717
+ set -eu
14718
+ cd ${shellQuote(root)}
14719
+ exec npx --yes --package=@magic-markdown/cli@latest mdocs ${shellCommand(args)} "$@"
14720
+ `;
14721
+ }
14722
+ function bridgeResumeArgs(root, options) {
14723
+ const args = ["bridge", "resume", "--root", root, "--forever"];
14724
+ if (options.requestToken !== false) args.push("--request-token");
14725
+ if (options.baseUrl) args.push("--url", options.baseUrl);
14726
+ if (options.intervalMs) args.push("--interval", String(options.intervalMs));
14727
+ return args;
14728
+ }
14729
+ function readBridgeStartupMode(value) {
14730
+ if (!value || value === "auto" || value === "shell" || value === "launchd" || value === "systemd") {
14731
+ return value || "auto";
14732
+ }
14733
+ throw new CliError("usage_error", `Unknown bridge startup mode: ${value}`, {
14734
+ hint: "Use --mode auto, shell, launchd, or systemd."
14735
+ });
14736
+ }
14737
+ async function autoBridgeStartupMode() {
14738
+ if (platform() === "darwin") return "launchd";
14739
+ if (platform() === "linux" && await pathExists("/run/systemd/system")) return "systemd";
14740
+ return "shell";
14741
+ }
14742
+ async function assertBridgeConfigured(root) {
14743
+ const configPath = join4(root, ".mdocs", "bridge.json");
14744
+ try {
14745
+ const config2 = JSON.parse(await readFile6(configPath, "utf8"));
14746
+ if (config2.schemaVersion !== 1) throw new Error("invalid schema");
14747
+ } catch {
14748
+ throw new CliError("usage_error", "This root does not have a bridge configuration yet.", {
14749
+ hint: "Run mdocs bridge setup first, then run mdocs bridge startup from the same root."
14750
+ });
14751
+ }
14752
+ }
14753
+ async function writeLaunchdPlan(root, startupDir, runnerPath) {
14754
+ const label = serviceLabel(root);
14755
+ const servicePath = join4(startupDir, `${label}.plist`);
14756
+ const installPath = join4(homedir(), "Library", "LaunchAgents", `${label}.plist`);
14757
+ const outLog = join4(root, ".mdocs", "bridge.log");
14758
+ const errLog = join4(root, ".mdocs", "bridge.err.log");
14759
+ await writeFile4(servicePath, launchdPlist(label, root, runnerPath, outLog, errLog), "utf8");
14760
+ const target = `gui/$(id -u)`;
14761
+ return {
14762
+ servicePath,
14763
+ installPath,
14764
+ installCommands: [
14765
+ shellCommand(["mkdir", "-p", dirname5(installPath)]),
14766
+ shellCommand(["cp", servicePath, installPath]),
14767
+ `launchctl bootout ${target} ${shellQuote(installPath)} 2>/dev/null || true`,
14768
+ `launchctl bootstrap ${target} ${shellQuote(installPath)}`,
14769
+ `launchctl enable ${target}/${label}`,
14770
+ `launchctl kickstart -k ${target}/${label}`
14771
+ ],
14772
+ install: async () => {
14773
+ await mkdir4(dirname5(installPath), { recursive: true });
14774
+ await copyFile(servicePath, installPath);
14775
+ const uid = typeof process.getuid === "function" ? process.getuid() : void 0;
14776
+ if (uid === void 0) return;
14777
+ const launchTarget = `gui/${uid}`;
14778
+ await runOptionalCommand("launchctl", ["bootout", launchTarget, installPath]);
14779
+ await runCommand("launchctl", ["bootstrap", launchTarget, installPath]);
14780
+ await runCommand("launchctl", ["enable", `${launchTarget}/${label}`]);
14781
+ await runCommand("launchctl", ["kickstart", "-k", `${launchTarget}/${label}`]);
14782
+ }
14783
+ };
14784
+ }
14785
+ async function writeSystemdPlan(root, startupDir, runnerPath) {
14786
+ const unitName = `${serviceLabel(root)}.service`;
14787
+ const servicePath = join4(startupDir, unitName);
14788
+ const installPath = join4(homedir(), ".config", "systemd", "user", unitName);
14789
+ await writeFile4(servicePath, systemdUnit(unitName, root, runnerPath), "utf8");
14790
+ return {
14791
+ servicePath,
14792
+ installPath,
14793
+ installCommands: [
14794
+ shellCommand(["mkdir", "-p", dirname5(installPath)]),
14795
+ shellCommand(["cp", servicePath, installPath]),
14796
+ "systemctl --user daemon-reload",
14797
+ shellCommand(["systemctl", "--user", "enable", "--now", unitName])
14798
+ ],
14799
+ install: async () => {
14800
+ await mkdir4(dirname5(installPath), { recursive: true });
14801
+ await copyFile(servicePath, installPath);
14802
+ await runCommand("systemctl", ["--user", "daemon-reload"]);
14803
+ await runCommand("systemctl", ["--user", "enable", "--now", unitName]);
14804
+ }
14805
+ };
14806
+ }
14807
+ function launchdPlist(label, root, runnerPath, outLog, errLog) {
14808
+ return `<?xml version="1.0" encoding="UTF-8"?>
14809
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
14810
+ <plist version="1.0">
14811
+ <dict>
14812
+ <key>Label</key>
14813
+ <string>${escapeXml(label)}</string>
14814
+ <key>ProgramArguments</key>
14815
+ <array>
14816
+ <string>/bin/sh</string>
14817
+ <string>${escapeXml(runnerPath)}</string>
14818
+ </array>
14819
+ <key>WorkingDirectory</key>
14820
+ <string>${escapeXml(root)}</string>
14821
+ <key>RunAtLoad</key>
14822
+ <true/>
14823
+ <key>KeepAlive</key>
14824
+ <true/>
14825
+ <key>StandardOutPath</key>
14826
+ <string>${escapeXml(outLog)}</string>
14827
+ <key>StandardErrorPath</key>
14828
+ <string>${escapeXml(errLog)}</string>
14829
+ </dict>
14830
+ </plist>
14831
+ `;
14832
+ }
14833
+ function systemdUnit(unitName, root, runnerPath) {
14834
+ return `[Unit]
14835
+ Description=Magic Markdown bridge (${unitName})
14836
+ After=network-online.target
14837
+ Wants=network-online.target
14838
+
14839
+ [Service]
14840
+ Type=simple
14841
+ WorkingDirectory=${systemdQuote(root)}
14842
+ ExecStart=/bin/sh ${systemdQuote(runnerPath)}
14843
+ Restart=always
14844
+ RestartSec=5
14845
+ Environment=PATH=${systemdQuote(process.env.PATH || "/usr/local/bin:/usr/bin:/bin")}
14846
+
14847
+ [Install]
14848
+ WantedBy=default.target
14849
+ `;
14850
+ }
14851
+ function serviceLabel(root) {
14852
+ const digest = createHash3("sha256").update(root).digest("hex").slice(0, 12);
14853
+ return `com.magic-markdown.bridge.${digest}`;
14854
+ }
14855
+ async function runOptionalCommand(command, args) {
14856
+ try {
14857
+ await execFileAsync2(command, args);
14858
+ } catch {
14859
+ }
14860
+ }
14861
+ async function runCommand(command, args) {
14862
+ try {
14863
+ await execFileAsync2(command, args);
14864
+ } catch (error) {
14865
+ const detail = error;
14866
+ throw new CliError("internal_error", `${command} ${args.join(" ")} failed: ${detail.stderr || detail.message || String(error)}`);
14867
+ }
14868
+ }
14869
+ async function pathExists(path) {
14870
+ try {
14871
+ await stat3(path);
14872
+ return true;
14873
+ } catch {
14874
+ return false;
14875
+ }
14876
+ }
14877
+ function shellCommand(args) {
14878
+ return args.map(shellQuote).join(" ");
14879
+ }
14880
+ function shellQuote(value) {
14881
+ return `'${value.replaceAll("'", "'\\''")}'`;
14882
+ }
14883
+ function systemdQuote(value) {
14884
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
14885
+ }
14886
+ function escapeXml(value) {
14887
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
14888
+ }
14889
+
14357
14890
  // src/index.ts
14358
14891
  async function main() {
14359
14892
  const parsed = parseArgs(process.argv.slice(2));
14360
- const cwd = resolve5(String(parsed.flags.cwd ?? process.cwd()));
14893
+ const cwd = resolve6(String(parsed.flags.cwd ?? process.cwd()));
14361
14894
  const io = new NodeWorkspaceIO(cwd);
14362
14895
  const [command, subcommand] = parsed.command;
14363
14896
  if (parsed.flags.version || command === "version") {
@@ -14372,7 +14905,7 @@ async function main() {
14372
14905
  }
14373
14906
  switch (command) {
14374
14907
  case "init": {
14375
- const target = resolve5(parsed.command[1] ?? cwd);
14908
+ const target = resolve6(parsed.command[1] ?? cwd);
14376
14909
  const targetIo = new NodeWorkspaceIO(target);
14377
14910
  const manifest = await indexWorkspace(targetIo);
14378
14911
  print(manifest, parsed.flags);
@@ -14409,7 +14942,7 @@ async function main() {
14409
14942
  markdown,
14410
14943
  images: state.images.map((image2) => ({
14411
14944
  ...image2,
14412
- ...image2.workspacePath ? { absolutePath: resolve5(cwd, image2.workspacePath) } : {}
14945
+ ...image2.workspacePath ? { absolutePath: resolve6(cwd, image2.workspacePath) } : {}
14413
14946
  })),
14414
14947
  links: state.links,
14415
14948
  ...parsed.flags["no-review"] ? {} : {
@@ -14513,7 +15046,7 @@ async function main() {
14513
15046
  return;
14514
15047
  }
14515
15048
  case "bridge": {
14516
- const root = resolve5(String(parsed.flags.root ?? cwd));
15049
+ const root = resolve6(String(parsed.flags.root ?? cwd));
14517
15050
  const baseOptions = {
14518
15051
  root,
14519
15052
  rootId: typeof parsed.flags["root-id"] === "string" ? parsed.flags["root-id"] : void 0,
@@ -14529,8 +15062,21 @@ async function main() {
14529
15062
  token: typeof parsed.flags.token === "string" ? parsed.flags.token : process.env.MDOCS_BRIDGE_TOKEN || void 0,
14530
15063
  requestToken: Boolean(parsed.flags["request-token"] || parsed.flags.pair),
14531
15064
  pairingTimeoutMs: typeof parsed.flags["pairing-timeout-ms"] === "string" ? Number(parsed.flags["pairing-timeout-ms"]) : void 0,
14532
- once: Boolean(parsed.flags.once)
15065
+ once: Boolean(parsed.flags.once),
15066
+ forever: Boolean(parsed.flags.forever)
14533
15067
  };
15068
+ if (subcommand === "startup" || subcommand === "daemon") {
15069
+ const result = await runBridgeStartup({
15070
+ root,
15071
+ mode: typeof parsed.flags.mode === "string" ? parsed.flags.mode : void 0,
15072
+ install: Boolean(parsed.flags.install),
15073
+ requestToken: !parsed.flags["no-request-token"],
15074
+ baseUrl: optionalBridgeBaseUrl(parsed.flags),
15075
+ intervalMs: typeof parsed.flags.interval === "string" ? Number(parsed.flags.interval) : void 0
15076
+ });
15077
+ print(parsed.flags.json ? result : formatBridgeStartupResult(result), parsed.flags);
15078
+ return;
15079
+ }
14534
15080
  if (subcommand === "resume") {
14535
15081
  await runBridgeResume({
14536
15082
  ...baseOptions,
@@ -14669,7 +15215,7 @@ async function readRequiredTextFlag2(flags, cwd, names) {
14669
15215
  async function readOptionalTextFlag2(flags, cwd, names) {
14670
15216
  for (const name of names) {
14671
15217
  const fileValue = flags[`${name}-file`];
14672
- if (typeof fileValue === "string") return readFile6(resolve5(cwd, fileValue), "utf8");
15218
+ if (typeof fileValue === "string") return readFile7(resolve6(cwd, fileValue), "utf8");
14673
15219
  const value = flags[name];
14674
15220
  if (typeof value === "string") return value;
14675
15221
  }
@@ -14717,9 +15263,9 @@ Commands:
14717
15263
  comments <path> --json List comments
14718
15264
  comment <path> --range 3:5 --body Add a sidecar comment
14719
15265
  suggestions <path> --json List suggestions
14720
- suggest <path> --range 3:5 --with Add a sidecar suggestion
15266
+ suggest <path> --range 3:5 --with Add a reviewMarkdown suggestion
14721
15267
  suggest <path> --range 3:5 --with-file replacement.md
14722
- Add a multiline sidecar suggestion
15268
+ Add a multiline reviewMarkdown suggestion
14723
15269
  accept <suggestionId> Apply and accept a suggestion
14724
15270
  reject <suggestionId> Reject a suggestion
14725
15271
  resolve-comment <commentId> Resolve a comment thread
@@ -14742,9 +15288,12 @@ Commands:
14742
15288
  bridge setup --workspace <id> --root . --url <base-url> [--folder-id <id>] --request-token
14743
15289
  Initialize, validate, request approval,
14744
15290
  and start an agent filesystem bridge
14745
- bridge resume --root . --request-token [--once]
15291
+ bridge resume --root . --forever --request-token [--once]
14746
15292
  Backfill missed Magic changes, then keep
14747
15293
  the bridge running unless --once is set
15294
+ bridge startup|daemon --root . [--mode auto|shell|launchd|systemd] [--install]
15295
+ Write a bridge runner and startup hook
15296
+ for sandboxes, launchd, or systemd
14748
15297
  bridge --workspace <id> --root . --url <base-url> --request-token
14749
15298
  Request human approval, then sync an
14750
15299
  approved local root with the workspace
@@ -14764,7 +15313,8 @@ Exit codes: 0 ok, 1 internal, 2 usage, 3 conflict, 4 not found, 5 network, 6 una
14764
15313
  `);
14765
15314
  }
14766
15315
  function commandHelp(commandPath) {
14767
- const candidates = [commandPath.slice(0, 2).join(" "), commandPath[0] ?? ""].filter(Boolean);
15316
+ const normalizedPath = commandPath[0] === "bridge" && commandPath[1] === "daemon" ? ["bridge", "startup"] : commandPath;
15317
+ const candidates = [normalizedPath.slice(0, 2).join(" "), normalizedPath[0] ?? ""].filter(Boolean);
14768
15318
  const spec = AGENT_COMMANDS.find((command) => candidates.includes(command.name));
14769
15319
  if (!spec) return false;
14770
15320
  const lines = [
@@ -14785,7 +15335,8 @@ function commandHelp(commandPath) {
14785
15335
  return true;
14786
15336
  }
14787
15337
  function usageFor(commandPath) {
14788
- const candidates = [commandPath.slice(0, 2).join(" "), commandPath[0] ?? ""].filter(Boolean);
15338
+ const normalizedPath = commandPath[0] === "bridge" && commandPath[1] === "daemon" ? ["bridge", "startup"] : commandPath;
15339
+ const candidates = [normalizedPath.slice(0, 2).join(" "), normalizedPath[0] ?? ""].filter(Boolean);
14789
15340
  return AGENT_COMMANDS.find((command) => candidates.includes(command.name))?.usage;
14790
15341
  }
14791
15342
  main().catch((error) => {