openwriter 0.26.0 → 0.27.1

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.
@@ -33,7 +33,8 @@
33
33
  */
34
34
  import { existsSync, mkdirSync, readFileSync, unlinkSync, readdirSync, rmSync } from 'fs';
35
35
  import { join } from 'path';
36
- import { getDataDir, atomicWriteFileSync } from './helpers.js';
36
+ import { getDataDir, atomicWriteFileSync, resolveDocPath } from './helpers.js';
37
+ import { markdownToTiptap } from './markdown-parse.js';
37
38
  // ============================================================================
38
39
  // DIAGNOSTIC HELPERS — node-text preview + entry summary for log readability
39
40
  // ============================================================================
@@ -107,6 +108,52 @@ export function loadOverlay(docId) {
107
108
  return [];
108
109
  }
109
110
  }
111
+ /**
112
+ * Read the entire sidecar JSON object (raw). Used by pending-metadata.ts so
113
+ * it can read the `metadata:` slot without re-implementing file I/O. Returns
114
+ * null when the sidecar is missing or unreadable.
115
+ */
116
+ export function readSidecarRaw(docId) {
117
+ if (!docId)
118
+ return null;
119
+ const path = getSidecarPath(docId);
120
+ if (!existsSync(path))
121
+ return null;
122
+ try {
123
+ const raw = readFileSync(path, 'utf-8');
124
+ return JSON.parse(raw);
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ /**
131
+ * Atomically write the sidecar with the full JSON object. Both pending-overlay
132
+ * (entries) and pending-metadata (metadata slot) share this single file, so
133
+ * each must preserve the other's slot on every write. This is the low-level
134
+ * primitive both use.
135
+ *
136
+ * If the resulting object has no entries AND no metadata, the sidecar is
137
+ * deleted (absence = "nothing pending for this doc").
138
+ */
139
+ export function writeSidecarRaw(docId, payload) {
140
+ if (!docId)
141
+ return;
142
+ const hasEntries = Array.isArray(payload.entries) && payload.entries.length > 0;
143
+ const hasMeta = !!payload.metadata && Object.keys(payload.metadata).length > 0;
144
+ if (!hasEntries && !hasMeta) {
145
+ deleteOverlay(docId);
146
+ return;
147
+ }
148
+ ensurePendingDir();
149
+ const path = getSidecarPath(docId);
150
+ const out = { version: 1 };
151
+ if (hasEntries)
152
+ out.entries = payload.entries;
153
+ if (hasMeta)
154
+ out.metadata = payload.metadata;
155
+ atomicWriteFileSync(path, JSON.stringify(out, null, 2));
156
+ }
110
157
  export function saveOverlay(docId, entries) {
111
158
  if (!docId)
112
159
  return;
@@ -156,7 +203,22 @@ export function saveOverlay(docId, entries) {
156
203
  if (!newById.has(e.nodeId))
157
204
  changes.push(`-${entrySummary(e)}`);
158
205
  }
206
+ // Preserve the sidecar's `metadata` slot (used by pending-metadata.ts) across
207
+ // entries-only writes. Without this read-modify-write, saving entries would
208
+ // clobber any pending title-rename staged for the same doc.
209
+ // adr: adr/pending-overlay-model.md
210
+ const prevRaw = readSidecarRaw(docId);
211
+ const prevMetadata = prevRaw?.metadata && Object.keys(prevRaw.metadata).length > 0 ? prevRaw.metadata : null;
159
212
  if (dedupedEntries.length === 0) {
213
+ if (prevMetadata) {
214
+ // Entries empty but metadata still pending — keep the sidecar alive with
215
+ // metadata only.
216
+ writeSidecarRaw(docId, { metadata: prevMetadata });
217
+ if (prevEntries.length > 0) {
218
+ diagLog(`[Overlay] SAVE docId=${docId} entries=0 (was ${prevEntries.length}) metadata preserved`);
219
+ }
220
+ return;
221
+ }
160
222
  if (prevEntries.length > 0) {
161
223
  diagLog(`[Overlay] SAVE docId=${docId} → DELETE (was ${prevEntries.length} entries)`);
162
224
  }
@@ -165,7 +227,10 @@ export function saveOverlay(docId, entries) {
165
227
  }
166
228
  ensurePendingDir();
167
229
  const path = getSidecarPath(docId);
168
- atomicWriteFileSync(path, JSON.stringify({ version: 1, entries: dedupedEntries }, null, 2));
230
+ const out = { version: 1, entries: dedupedEntries };
231
+ if (prevMetadata)
232
+ out.metadata = prevMetadata;
233
+ atomicWriteFileSync(path, JSON.stringify(out, null, 2));
169
234
  if (changes.length > 0) {
170
235
  diagLog(`[Overlay] SAVE docId=${docId} entries=${dedupedEntries.length} changes=[${changes.join(' | ')}]`);
171
236
  }
@@ -558,6 +623,24 @@ export function applyOverlay(canonical, entries) {
558
623
  * adr: adr/pending-overlay-model.md
559
624
  */
560
625
  export function applyOverlayPure(canonical, entries) {
626
+ // Strip the parser-fallback / createDocumentFile-stub empty paragraph
627
+ // before merging. markdownToTiptap mints a placeholder empty paragraph
628
+ // whenever the disk body has no block content (TipTap requires at least
629
+ // one node), and createDocumentFile mints the same shape for fresh empty
630
+ // docs. Both surface as a trailing empty `[p:...]` in the merged view
631
+ // whenever the doc's real content lives only in the overlay. We strip
632
+ // ONLY when canonical is exactly that single empty-paragraph shape AND
633
+ // no overlay entry anchors to it — so steady-state docs with real
634
+ // canonical content (or where an overlay entry references the empty
635
+ // paragraph deliberately, like a pending-delete on a user-emptied node)
636
+ // are untouched. adr: adr/pending-overlay-model.md
637
+ if (entries.length > 0 && isFallbackEmptyCanonical(canonical)) {
638
+ const fallbackId = canonical.content[0]?.attrs?.id;
639
+ const referenced = entries.some((e) => e.nodeId === fallbackId || e.afterNodeId === fallbackId || e.parentNodeId === fallbackId);
640
+ if (!referenced) {
641
+ canonical = { ...canonical, content: [] };
642
+ }
643
+ }
561
644
  const merged = canonical ? JSON.parse(JSON.stringify(canonical)) : { type: 'doc', content: [] };
562
645
  if (entries.length === 0)
563
646
  return merged;
@@ -860,3 +943,69 @@ export function migrateLegacyPending(doc, legacyPending) {
860
943
  walk(doc?.content || [], null);
861
944
  return entries;
862
945
  }
946
+ // ============================================================================
947
+ // MERGED-VIEW DOC LOADER
948
+ // ============================================================================
949
+ /**
950
+ * Load a doc from disk WITH its sidecar overlay applied — returns the
951
+ * user-visible merged view. This is the canonical reader for any code path
952
+ * that surfaces a non-active doc to the user (MCP read tools, browser HTTP
953
+ * fetches, anything that says "give me the doc").
954
+ *
955
+ * Why this exists. fb666e6 (May 2026) split persistence into two surfaces:
956
+ * the .md body holds canonical content, the per-docId sidecar at
957
+ * `_pending/{docId}.json` holds the pending overlay. Writes were updated
958
+ * to handle both halves symmetrically; reads weren't. The bare
959
+ * `markdownToTiptap` returns canonical-only — anyone calling it directly
960
+ * and treating the result as "the doc" silently drops the user's pending
961
+ * content. This function closes that asymmetry: there is one entry point
962
+ * for "load the doc," and it always materializes the full merged view.
963
+ *
964
+ * Callers that explicitly want canonical-only (the save-time matcher, the
965
+ * on-disk identity persistence path, sync-check roundtripping) continue to
966
+ * call `markdownToTiptap` directly. Those callsites are internal to the
967
+ * persistence layer and deliberately operate on the pre-overlay shape.
968
+ *
969
+ * Throws if the file doesn't exist (matches resolveDocTarget's existing
970
+ * behavior; callers that want soft-failure should check existsSync first).
971
+ *
972
+ * adr: adr/pending-overlay-model.md
973
+ */
974
+ export function loadDocFromDisk(filename) {
975
+ const targetPath = resolveDocPath(filename);
976
+ if (!existsSync(targetPath)) {
977
+ throw new Error(`Document file not found: ${filename}`);
978
+ }
979
+ const raw = readFileSync(targetPath, 'utf-8');
980
+ const parsed = markdownToTiptap(raw);
981
+ const docId = (parsed.metadata && typeof parsed.metadata.docId === 'string')
982
+ ? parsed.metadata.docId
983
+ : '';
984
+ let document = parsed.document;
985
+ if (docId) {
986
+ const overlayEntries = loadOverlay(docId);
987
+ if (overlayEntries.length > 0) {
988
+ // applyOverlayPure handles the parser-fallback / stub-empty strip
989
+ // itself — see the comment block at its top for why.
990
+ document = applyOverlayPure(parsed.document, overlayEntries);
991
+ }
992
+ }
993
+ return {
994
+ document,
995
+ title: parsed.title,
996
+ metadata: parsed.metadata,
997
+ docId,
998
+ rawFrontmatter: parsed.rawFrontmatter,
999
+ };
1000
+ }
1001
+ /** True when a parsed doc's canonical body is `[{ paragraph with no content }]`
1002
+ * — the markdownToTiptap fallback shape that signals "disk body had no
1003
+ * block-level content." Genuine user empty paragraphs in a doc with other
1004
+ * real content don't trip this (length check is strict); only the parser
1005
+ * fallback's single-node case matches. */
1006
+ function isFallbackEmptyCanonical(canonical) {
1007
+ if (!canonical?.content || canonical.content.length !== 1)
1008
+ return false;
1009
+ const node = canonical.content[0];
1010
+ return node?.type === 'paragraph' && (!node.content || node.content.length === 0);
1011
+ }
@@ -146,12 +146,27 @@ export class PluginManager {
146
146
  }
147
147
  return resolved;
148
148
  }
149
- /** Persist enabled/config state to ~/.openwriter/config.json. */
149
+ /**
150
+ * Persist enabled/config state to ~/.openwriter/config.json.
151
+ *
152
+ * IMPORTANT: plugins can store arbitrary nested data on their own slot
153
+ * (e.g. the github plugin stores `blogSites: [...]`). This writer
154
+ * preserves any such keys by merging into the existing on-disk slot
155
+ * rather than rebuilding the slot from scratch. Without this preserve
156
+ * step, every plugin enable/disable/config edit would silently drop
157
+ * blogSites and any other plugin-owned data.
158
+ *
159
+ * adr: adr/plugin-slot-nested-data.md
160
+ */
150
161
  savePluginState() {
151
- const pluginsState = {};
162
+ const current = readConfig();
163
+ const existing = (current.plugins || {});
164
+ const pluginsState = { ...existing };
152
165
  for (const [name, managed] of this.plugins) {
166
+ const prior = (existing[name] || {});
153
167
  pluginsState[name] = {
154
- enabled: managed.enabled,
168
+ ...prior, // preserve blogSites + any other plugin-owned data
169
+ enabled: managed.enabled, // overwrite managed fields
155
170
  config: managed.config,
156
171
  };
157
172
  }
@@ -16,7 +16,8 @@ import { matchNodes } from './node-matcher.js';
16
16
  import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
17
17
  import { anyLegacyRaw } from './node-fingerprint.js';
18
18
  import { markdownToNodes, resolvePreviousNodes, resolveGraveyard } from './markdown-parse.js';
19
- import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
19
+ import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, loadDocFromDisk, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
20
+ import { loadPendingMetadata, savePendingMetadata } from './pending-metadata.js';
20
21
  import { harvestSentenceHashes, harvestCharCount, isEnrichmentStale } from './enrichment.js';
21
22
  import { clearActivityBuffer } from './activity-log.js';
22
23
  import { titleFromDoc, shouldAutoTitle } from './title-from-body.js';
@@ -84,6 +85,7 @@ const DEFAULT_DOC = {
84
85
  let state = {
85
86
  canonical: DEFAULT_DOC,
86
87
  overlay: new Map(),
88
+ pendingMetadata: null,
87
89
  document: DEFAULT_DOC,
88
90
  title: 'Untitled',
89
91
  metadata: { title: 'Untitled' },
@@ -543,6 +545,21 @@ export function getOverlay() {
543
545
  export function getTitle() {
544
546
  return state.title;
545
547
  }
548
+ /** Snapshot of the active doc's pending metadata (title, etc.). Null if no
549
+ * metadata is staged. The sidebar / title bar / ReviewTab consult this to
550
+ * decide whether to render a metadata-pending decoration.
551
+ * adr: adr/pending-overlay-model.md */
552
+ export function getPendingMetadata() {
553
+ return state.pendingMetadata ? { ...state.pendingMetadata } : null;
554
+ }
555
+ /** Replace the active doc's pending metadata. Persists to the sidecar so the
556
+ * proposal survives a doc switch or restart. Pass null to clear. */
557
+ export function setPendingMetadata(meta) {
558
+ state.pendingMetadata = meta && Object.keys(meta).length > 0 ? meta : null;
559
+ if (state.docId) {
560
+ savePendingMetadata(state.docId, state.pendingMetadata);
561
+ }
562
+ }
546
563
  export function getFilePath() {
547
564
  return state.filePath;
548
565
  }
@@ -775,6 +792,13 @@ export function setMetadata(updates) {
775
792
  state.metadata = merged;
776
793
  if (updates.title)
777
794
  state.title = updates.title;
795
+ // Same contract as updateDocument: any path that mutates persistent state
796
+ // MUST bump docVersion, otherwise writeToDisk's no-op gate
797
+ // (docVersion === lastSavedDocVersion) silently drops the write and the
798
+ // mutation never reaches disk. Metadata-only changes (mark-sent tweetUrl,
799
+ // schedule edits, autoAccept toggle) all flow through here.
800
+ // adr: adr/pending-overlay-model.md
801
+ bumpDocVersion();
778
802
  // Auto-tag based on context metadata
779
803
  const filename = state.filePath
780
804
  ? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
@@ -1026,11 +1050,19 @@ export function cancelDebouncedSave() {
1026
1050
  }
1027
1051
  }
1028
1052
  export function applyChanges(changes) {
1029
- // Apply to server-side document (source of truth)
1030
- const processed = applyChangesToDocument(changes);
1031
- // Bump version + lock browser doc-updates to prevent stale state overwrite
1053
+ // Bump version BEFORE applying so new overlay entries created by
1054
+ // applyChangesToDocument's setPrimaryFromMerged → setOverlayFromEntries
1055
+ // pass are stamped with the post-bump version (the version we're about
1056
+ // to broadcast), not the pre-bump version. Without this, a stale
1057
+ // browser doc-update arriving with browserVersion == preBump satisfies
1058
+ // syncBrowserDocUpdate's `addedAtVersion > browserVersion` filter as
1059
+ // FALSE for entries just added by this call — preservedServerEntries
1060
+ // becomes 0, the overlay is wiped, and the agent's write silently
1061
+ // vanishes despite a success response. adr: adr/pending-overlay-model.md
1032
1062
  const version = bumpDocVersion();
1033
1063
  setAgentLockActive();
1064
+ // Apply to server-side document (source of truth)
1065
+ const processed = applyChangesToDocument(changes);
1034
1066
  // Broadcast processed changes (with server-assigned IDs + version) to browser clients
1035
1067
  for (const listener of listeners) {
1036
1068
  listener(processed, version);
@@ -1466,6 +1498,9 @@ export function setActiveDocument(doc, title, filePath, isTemp, lastModified, me
1466
1498
  // Pending overlay rehydration. See mergeOverlayOnLoad for the three cases
1467
1499
  // (sidecar present, legacy migration, no pending).
1468
1500
  mergeOverlayOnLoad();
1501
+ // Pending metadata rehydration. Read the sidecar's `metadata:` slot so a
1502
+ // staged title rename survives doc-switch and restart. adr: adr/pending-overlay-model.md
1503
+ state.pendingMetadata = state.docId ? loadPendingMetadata(state.docId) : null;
1469
1504
  // Subscribe the fs watcher to this doc so external writes (Edit tool,
1470
1505
  // VSCode, scripts) trigger a unified reload + version bump + broadcast.
1471
1506
  // adr: adr/active-doc-watcher.md
@@ -1649,6 +1684,7 @@ export function clearAllCaches() {
1649
1684
  state = {
1650
1685
  canonical: DEFAULT_DOC,
1651
1686
  overlay: new Map(),
1687
+ pendingMetadata: null,
1652
1688
  document: DEFAULT_DOC,
1653
1689
  title: 'Untitled',
1654
1690
  metadata: { title: 'Untitled' },
@@ -1750,6 +1786,12 @@ export function reloadActiveDocFromDisk() {
1750
1786
  // sidecar (or legacy migration). Recompute merged via the helper.
1751
1787
  state.canonical = canonical;
1752
1788
  setOverlayFromEntries(entries);
1789
+ // Pending metadata rehydration — mirror what setActiveDocument does.
1790
+ // External-write reloads must keep state.pendingMetadata aligned with the
1791
+ // sidecar's metadata: slot, otherwise a staged title rename gets dropped
1792
+ // from in-memory state the moment fs.watch fires.
1793
+ // adr: adr/pending-overlay-model.md
1794
+ state.pendingMetadata = state.docId ? loadPendingMetadata(state.docId) : null;
1753
1795
  // External writes change the body but leave disk frontmatter pointing at
1754
1796
  // the previous save's fingerprints. If the user cuts/deletes a block before
1755
1797
  // the next browser-driven save, the matcher graveyards with that stale
@@ -2355,6 +2397,12 @@ export function load() {
2355
2397
  // source.
2356
2398
  // adr: adr/pending-overlay-model.md
2357
2399
  mergeOverlayOnLoad();
2400
+ // Pending metadata rehydration. Boot path bypasses setActiveDocument,
2401
+ // so the per-doc sidecar's `metadata:` slot must be loaded here too.
2402
+ // Without this, a server restart drops staged title renames from
2403
+ // in-memory state until the user switches docs.
2404
+ // adr: adr/pending-overlay-model.md
2405
+ state.pendingMetadata = state.docId ? loadPendingMetadata(state.docId) : null;
2358
2406
  break;
2359
2407
  }
2360
2408
  catch {
@@ -2545,11 +2593,25 @@ export function addDocTag(filename, tag) {
2545
2593
  if (!tags.includes(tag)) {
2546
2594
  tags.push(tag);
2547
2595
  state.metadata.tags = tags;
2548
- // Preserve mtime tag changes shouldn't affect sidebar sort order
2596
+ // Same docVersion contract as setMetadata: tag changes mutate
2597
+ // state.metadata, so they must bump or writeToDisk's no-op gate
2598
+ // silently drops the write.
2599
+ bumpDocVersion();
2600
+ // Preserve mtime — tag changes shouldn't affect sidebar sort order.
2601
+ // After rolling back disk mtime, we MUST also roll back state.loadedMtime
2602
+ // (which writeToDisk just stamped to the post-write disk mtime).
2603
+ // The fs.watch self-suppression contract is `diskMtime === loadedMtime`;
2604
+ // a divergence here will fire a phantom "external write detected"
2605
+ // reload banner when the watcher's debounced handler runs.
2549
2606
  const mtime = state.filePath ? safeGetMtime(state.filePath) : null;
2550
2607
  save();
2551
- if (mtime && state.filePath)
2608
+ if (mtime && state.filePath) {
2552
2609
  safeRestoreMtime(state.filePath, mtime);
2610
+ try {
2611
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
2612
+ }
2613
+ catch { /* best-effort */ }
2614
+ }
2553
2615
  }
2554
2616
  }
2555
2617
  else {
@@ -2585,11 +2647,19 @@ export function removeDocTag(filename, tag) {
2585
2647
  if (idx >= 0) {
2586
2648
  tags.splice(idx, 1);
2587
2649
  state.metadata.tags = tags.length > 0 ? tags : undefined;
2588
- // Preserve mtime tag changes shouldn't affect sidebar sort order
2650
+ // Same docVersion contract as addDocTag mutation must bump or
2651
+ // writeToDisk's no-op gate silently drops the write.
2652
+ bumpDocVersion();
2653
+ // Same loadedMtime re-stamp as addDocTag — see comment there.
2589
2654
  const mtime = state.filePath ? safeGetMtime(state.filePath) : null;
2590
2655
  save();
2591
- if (mtime && state.filePath)
2656
+ if (mtime && state.filePath) {
2592
2657
  safeRestoreMtime(state.filePath, mtime);
2658
+ try {
2659
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
2660
+ }
2661
+ catch { /* best-effort */ }
2662
+ }
2593
2663
  }
2594
2664
  }
2595
2665
  else {
@@ -2897,41 +2967,50 @@ function flushDocToFile(filename, doc, title, metadata) {
2897
2967
  invalidateBacklinksCache();
2898
2968
  }
2899
2969
  export function populateDocumentFile(filename, doc) {
2900
- const targetPath = resolveDocPath(filename);
2901
- const raw = readFileSync(targetPath, 'utf-8');
2902
- const parsed = markdownToTiptap(raw);
2970
+ // Read the existing doc as the user-visible MERGED view — canonical body
2971
+ // plus any pre-existing sidecar overlay. Using the bare markdownToTiptap
2972
+ // here would silently drop pre-existing pending entries (the file's
2973
+ // sidecar lives outside the .md body), so a re-populate or a populate on
2974
+ // a doc with prior pending state would clobber the overlay on the
2975
+ // subsequent flushDocToFile. adr: adr/pending-overlay-model.md
2976
+ const loaded = loadDocFromDisk(filename);
2903
2977
  // Skip pending tagging when the target doc effectively has autoAccept on —
2904
2978
  // content commits directly as accepted.
2905
- if (!isAutoAcceptActive(filename, parsed.metadata)) {
2979
+ if (!isAutoAcceptActive(filename, loaded.metadata)) {
2906
2980
  markAllNodesAsPending(doc, 'insert');
2907
2981
  }
2908
- // Bug #1 fix (v0.20.0): preserve the stub's trailing canonical paragraph(s).
2909
- // flushDocToFile writes `doc` directly it does NOT merge with the existing
2910
- // parsed.document on disk. Without this merge step, the stub's auto-generated
2911
- // trailing paragraph falls out of canonical, the matcher's `previousNodes`
2912
- // for any subsequent save no longer references it, and a follow-up Accept All
2913
- // doc-update can find itself with no matching previousNodes to anchor against.
2914
- // Cascading: the matcher classifies the newly accepted inserts as deletions
2915
- // (orphaned from the empty previousNodes set), they go to graveyard, the disk
2916
- // body ends up empty.
2917
- // Mirrors the active-doc fix in mcp.ts:populate_document.
2918
- if (parsed.document?.content?.length) {
2982
+ // Preserve any pre-existing real content (pending nodes from a prior
2983
+ // populate or write) that isn't in the incoming set so a re-populate or
2984
+ // populate-on-top doesn't clobber prior agent proposals. flushDocToFile
2985
+ // writes `doc` directly and extracts its overlay wholesale, so what isn't
2986
+ // in `doc.content` after this step disappears from the next save.
2987
+ //
2988
+ // Empty paragraphs are explicitly NOT preserved. createDocumentFile mints
2989
+ // a trailing empty paragraph as a TipTap "doc must have at least one
2990
+ // node" stub; that stub is obsolete the moment populate provides real
2991
+ // content, and preserving it leaves a phantom empty paragraph at the end
2992
+ // of every populated doc forever. Mirrors the active-doc preserve in
2993
+ // mcp.ts:populate_document. adr: adr/pending-overlay-model.md
2994
+ if (loaded.document?.content?.length) {
2919
2995
  const incomingIds = new Set(doc.content
2920
2996
  .map((n) => n?.attrs?.id)
2921
2997
  .filter((id) => typeof id === 'string'));
2922
- const preserved = parsed.document.content.filter((n) => {
2998
+ const preserved = loaded.document.content.filter((n) => {
2923
2999
  const id = n?.attrs?.id;
2924
- return id && !incomingIds.has(id);
3000
+ if (!id || incomingIds.has(id))
3001
+ return false;
3002
+ const isEmptyParagraph = n.type === 'paragraph' && (!n.content || n.content.length === 0);
3003
+ return !isEmptyParagraph;
2925
3004
  });
2926
3005
  if (preserved.length > 0) {
2927
3006
  doc.content = [...doc.content, ...preserved];
2928
3007
  }
2929
3008
  }
2930
- flushDocToFile(filename, doc, parsed.title, parsed.metadata);
3009
+ flushDocToFile(filename, doc, loaded.title, loaded.metadata);
2931
3010
  const pendingCount = countPending(doc.content);
2932
3011
  const text = extractText(doc.content);
2933
3012
  const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
2934
- return { title: parsed.title, wordCount, pendingCount };
3013
+ return { title: loaded.title, wordCount, pendingCount };
2935
3014
  }
2936
3015
  /**
2937
3016
  * Apply node changes to a non-active document file on disk.
@@ -2954,12 +3033,17 @@ export function applyChangesToFile(filename, changes) {
2954
3033
  isTemp = cached.isTemp;
2955
3034
  }
2956
3035
  else {
2957
- const raw = readFileSync(targetPath, 'utf-8');
2958
- const parsed = markdownToTiptap(raw);
2959
- doc = parsed.document;
2960
- title = parsed.title;
2961
- metadata = parsed.metadata;
2962
- docId = metadata.docId || '';
3036
+ // Cache miss — load the MERGED view (canonical + sidecar overlay). The
3037
+ // bare markdownToTiptap would give canonical-only, so pre-existing pending
3038
+ // entries would not be in `doc` when we apply the new changes. The
3039
+ // subsequent flushDocToFile then extracts the overlay from `doc` and
3040
+ // overwrites the sidecar — silently dropping every prior pending entry.
3041
+ // adr: adr/pending-overlay-model.md
3042
+ const loaded = loadDocFromDisk(filename);
3043
+ doc = loaded.document;
3044
+ title = loaded.title;
3045
+ metadata = loaded.metadata;
3046
+ docId = loaded.docId;
2963
3047
  isTemp = false;
2964
3048
  }
2965
3049
  const autoAccept = isAutoAcceptActive(filename, metadata);
@@ -3003,12 +3087,15 @@ export function applyTextEditsToFile(filename, nodeId, edits) {
3003
3087
  isTemp = cached.isTemp;
3004
3088
  }
3005
3089
  else {
3006
- const raw = readFileSync(targetPath, 'utf-8');
3007
- const parsed = markdownToTiptap(raw);
3008
- doc = parsed.document;
3009
- title = parsed.title;
3010
- metadata = parsed.metadata;
3011
- docId = metadata.docId || '';
3090
+ // Cache miss load the merged view so a target node living in the
3091
+ // sidecar overlay (a pending insert the agent is now text-editing) is
3092
+ // findable. Bare markdownToTiptap would only show canonical, and the
3093
+ // findNode call below would fail. adr: adr/pending-overlay-model.md
3094
+ const loaded = loadDocFromDisk(filename);
3095
+ doc = loaded.document;
3096
+ title = loaded.title;
3097
+ metadata = loaded.metadata;
3098
+ docId = loaded.docId;
3012
3099
  isTemp = false;
3013
3100
  }
3014
3101
  const found = findNode(doc.content, nodeId, doc.content);