openwriter 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,17 +3,18 @@
3
3
  * Each document is a .md file in ~/.openwriter/ with YAML frontmatter.
4
4
  * Title lives in frontmatter metadata. Filenames are stable identifiers.
5
5
  */
6
- import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync, utimesSync } from 'fs';
6
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync, utimesSync, watch } from 'fs';
7
7
  import { join } from 'path';
8
8
  import matter from 'gray-matter';
9
9
  import { tiptapToMarkdown, tiptapToMarkdownChecked, tiptapToBody, markdownToTiptap } from './markdown.js';
10
10
  import { applyTextEditsToNode } from './text-edit.js';
11
- import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
11
+ import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync, canonicalizePath, canonicalizeIdentifier } from './helpers.js';
12
12
  import { snapshotIfNeeded, ensureDocId } from './versions.js';
13
13
  import { extractForwardLinks, extractForwardLinksFromDisk, updateBacklinksForSource } from './backlinks.js';
14
14
  import { isAutoAcceptInheritedForDoc } from './workspaces.js';
15
15
  import { matchNodes } from './node-matcher.js';
16
16
  import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
17
+ import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
17
18
  /** Read the persisted identity graph (nodes + graveyard) from a file's
18
19
  * frontmatter. This is the matcher's previousNodes baseline at save time —
19
20
  * the disk is the source of truth, not a parallel in-memory cache. Returns
@@ -48,6 +49,8 @@ const DEFAULT_DOC = {
48
49
  content: [{ type: 'paragraph', content: [] }],
49
50
  };
50
51
  let state = {
52
+ canonical: DEFAULT_DOC,
53
+ overlay: new Map(),
51
54
  document: DEFAULT_DOC,
52
55
  title: 'Untitled',
53
56
  metadata: { title: 'Untitled' },
@@ -56,37 +59,396 @@ let state = {
56
59
  lastModified: new Date(),
57
60
  docId: '',
58
61
  originalFrontmatter: null,
62
+ loadedMtime: 0,
59
63
  };
64
+ // ============================================================================
65
+ // PRIMARY-STATE WRITE HELPERS
66
+ // ============================================================================
67
+ // All mutations to canonical or overlay go through these helpers. Each updates
68
+ // the underlying state AND recomputes state.document so external readers via
69
+ // getDocument() see the new merged view. Direct assignment to state.canonical,
70
+ // state.overlay, or state.document is forbidden outside this module.
71
+ // adr: adr/pending-overlay-model.md
72
+ /** Recompute the merged view from primary state. Idempotent — running twice
73
+ * produces the same result. */
74
+ function recomputeMerged() {
75
+ state.document = applyOverlayPure(state.canonical, Array.from(state.overlay.values()));
76
+ }
77
+ /** Replace canonical wholesale. Used by load paths and accept-all flows. */
78
+ function setCanonical(doc) {
79
+ state.canonical = doc;
80
+ recomputeMerged();
81
+ }
82
+ /** Replace overlay wholesale from an entry array. Dedupes by nodeId
83
+ * (first occurrence wins; preserves original anchors). Preserves
84
+ * addedAtVersion from any pre-existing state.overlay entry with the
85
+ * same nodeId; stamps current docVersion on entries that are new. */
86
+ function setOverlayFromEntries(entries) {
87
+ const currentVersion = getDocVersion();
88
+ const newOverlay = new Map();
89
+ for (const e of entries) {
90
+ if (newOverlay.has(e.nodeId))
91
+ continue;
92
+ const existing = state.overlay.get(e.nodeId);
93
+ const entry = { ...e };
94
+ if (existing?.addedAtVersion !== undefined) {
95
+ entry.addedAtVersion = existing.addedAtVersion;
96
+ }
97
+ else if (entry.addedAtVersion === undefined) {
98
+ entry.addedAtVersion = currentVersion;
99
+ }
100
+ newOverlay.set(e.nodeId, entry);
101
+ }
102
+ state.overlay = newOverlay;
103
+ recomputeMerged();
104
+ }
105
+ /** Replace primary state from a merged-shape doc. Splits via splitMergedDoc.
106
+ * Used by paths that receive a merged doc (browser doc-updates, legacy
107
+ * on-disk migration). */
108
+ function setPrimaryFromMerged(merged) {
109
+ const { canonical, overlayEntries } = splitMergedDoc(merged);
110
+ state.canonical = canonical;
111
+ setOverlayFromEntries(overlayEntries);
112
+ }
113
+ /**
114
+ * Sync routing for a stale-version browser doc-update. The browser's
115
+ * submission was captured at server version `browserVersion`; the server
116
+ * has since advanced to a higher version because the agent wrote concurrently.
117
+ *
118
+ * Behavior: the browser's view of canonical is accepted as authoritative.
119
+ * The overlay merges browser's view with server's recent additions: any
120
+ * server overlay entry with addedAtVersion > browserVersion is an agent
121
+ * addition the browser hadn't seen, so it survives. Conflicting nodeIds
122
+ * (both browser and server have entries) → server wins, on the principle
123
+ * that an explicit agent proposal outranks a browser save that didn't see
124
+ * it. The user can reject the server's entry through the normal review UI
125
+ * if they disagree.
126
+ *
127
+ * Replaces the older "BLOCK stale doc-update" behavior — that pattern lost
128
+ * the user's typing without warning. The merge approach preserves both
129
+ * sides' work in the common case (disjoint touches) and surfaces conflicts
130
+ * (same-paragraph touches) via the existing pending-review UI.
131
+ *
132
+ * Returns the count of server overlay entries that were preserved.
133
+ * adr: adr/pending-overlay-model.md
134
+ */
135
+ export function syncBrowserDocUpdate(browserDoc, browserVersion) {
136
+ const { canonical: browserCanonical, overlayEntries: browserOverlay } = splitMergedDoc(browserDoc);
137
+ // Identify server overlay entries to preserve: those added after browser's baseline.
138
+ const preserved = [];
139
+ for (const [, entry] of state.overlay) {
140
+ const added = entry.addedAtVersion ?? 0;
141
+ if (added > browserVersion)
142
+ preserved.push(entry);
143
+ }
144
+ // Build the merged overlay. Browser's view first; server-preserved entries
145
+ // overwrite (server wins on conflict).
146
+ const merged = new Map();
147
+ for (const e of browserOverlay) {
148
+ if (!merged.has(e.nodeId))
149
+ merged.set(e.nodeId, e);
150
+ }
151
+ for (const e of preserved) {
152
+ merged.set(e.nodeId, e);
153
+ }
154
+ // Apply: browser's canonical view + merged overlay.
155
+ state.canonical = browserCanonical;
156
+ setOverlayFromEntries(Array.from(merged.values()));
157
+ return { preservedServerEntries: preserved.length };
158
+ }
60
159
  const listeners = new Set();
160
+ const idRewriteListeners = new Set();
161
+ const externalWriteConflictListeners = new Set();
162
+ export function onExternalWriteConflict(listener) {
163
+ externalWriteConflictListeners.add(listener);
164
+ return () => externalWriteConflictListeners.delete(listener);
165
+ }
166
+ function notifyExternalWriteConflict(filePath, diskMtime, loadedMtime) {
167
+ for (const listener of externalWriteConflictListeners) {
168
+ try {
169
+ listener({ filePath, diskMtime, loadedMtime });
170
+ }
171
+ catch (err) {
172
+ console.error('[State] external-write listener threw:', err);
173
+ }
174
+ }
175
+ }
176
+ /**
177
+ * Check whether the active doc's on-disk mtime is newer than what we
178
+ * loaded/saved. Returns null when the doc has no file path, the file is
179
+ * gone, or there's no drift. Exposed for the get_pad_status MCP tool so
180
+ * agents can detect "you need to reload_from_disk before your next save"
181
+ * without waiting for the save itself to fail.
182
+ */
183
+ export function getExternalMtimeDrift() {
184
+ if (!state.filePath || state.loadedMtime === 0)
185
+ return null;
186
+ try {
187
+ const diskMtime = statSync(state.filePath).mtimeMs;
188
+ if (diskMtime !== state.loadedMtime) {
189
+ return { diskMtime, loadedMtime: state.loadedMtime };
190
+ }
191
+ }
192
+ catch { /* file missing */ }
193
+ return null;
194
+ }
195
+ /** Force-refresh the active doc's loadedMtime snapshot to current disk mtime.
196
+ * Used by reload_from_disk after re-reading the file, so the freshly
197
+ * adopted content's mtime becomes our new baseline. */
198
+ export function refreshLoadedMtime() {
199
+ if (!state.filePath)
200
+ return;
201
+ try {
202
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
203
+ }
204
+ catch { /* best-effort */ }
205
+ }
206
+ // ============================================================================
207
+ // ACTIVE DOC FILE WATCHER
208
+ // ============================================================================
209
+ //
210
+ // Watches the currently-active doc's file for external writes. When any
211
+ // other process (Edit tool, VSCode, a script) mutates the file, fs.watch
212
+ // fires; we debounce burst events, verify the disk mtime actually advanced
213
+ // past our last-stamped `loadedMtime`, and route through the unified
214
+ // reloadActiveDocFromDisk pathway — which re-parses disk, re-attaches the
215
+ // pending overlay by nodeId, runs the matcher, and lets subscribers
216
+ // broadcast a document-reloaded message to clients.
217
+ //
218
+ // Why push (fs.watch) instead of pull (mtime poll on writes only):
219
+ // - The browser autosave race was: external editor writes → server's
220
+ // in-memory state is now stale → browser sends an autosave from BEFORE
221
+ // the external write → server's version counter hasn't advanced (no
222
+ // MCP write occurred) → version check passes → stale content clobbers
223
+ // the external write.
224
+ // - The fix is to advance docVersion the instant the file changes on
225
+ // disk, not the instant we try to save. fs.watch makes that immediate.
226
+ //
227
+ // Single watcher at a time: we only care about the active doc. Switching
228
+ // docs tears down the previous watcher and opens a new one.
229
+ //
230
+ // Cross-platform: Node's fs.watch is inotify on Linux, FSEvents on macOS,
231
+ // ReadDirectoryChangesW on Windows. Single-file watching is reliable on
232
+ // all three; chokidar would add value for recursive directory trees, not
233
+ // here.
234
+ //
235
+ // adr: adr/active-doc-watcher.md
236
+ let activeWatcher = null;
237
+ let activeWatcherPath = '';
238
+ let watcherDebounceTimer = null;
239
+ const documentReloadedListeners = new Set();
240
+ export function onDocumentReloaded(listener) {
241
+ documentReloadedListeners.add(listener);
242
+ return () => documentReloadedListeners.delete(listener);
243
+ }
244
+ function notifyDocumentReloaded(event) {
245
+ for (const listener of documentReloadedListeners) {
246
+ try {
247
+ listener(event);
248
+ }
249
+ catch (err) {
250
+ console.error('[State] document-reloaded listener threw:', err);
251
+ }
252
+ }
253
+ }
254
+ /** Tear down the active-doc watcher (if any) and clear bookkeeping. */
255
+ function stopActiveDocWatcher() {
256
+ if (watcherDebounceTimer) {
257
+ clearTimeout(watcherDebounceTimer);
258
+ watcherDebounceTimer = null;
259
+ }
260
+ if (activeWatcher) {
261
+ try {
262
+ activeWatcher.close();
263
+ }
264
+ catch { /* best-effort */ }
265
+ activeWatcher = null;
266
+ }
267
+ activeWatcherPath = '';
268
+ }
269
+ /** Handle a watcher event after debounce — reload if mtime actually advanced. */
270
+ function handleWatcherEvent() {
271
+ // Only act if the watched path is still the active doc. If the user
272
+ // switched away during the debounce window, drop this event — the new
273
+ // active doc has its own watcher already running.
274
+ if (!state.filePath || state.filePath !== activeWatcherPath)
275
+ return;
276
+ // Skip if the file is gone (e.g., delete during watch). The next save
277
+ // will re-create it from in-memory state.
278
+ if (!existsSync(state.filePath))
279
+ return;
280
+ let diskMtime;
281
+ try {
282
+ diskMtime = statSync(state.filePath).mtimeMs;
283
+ }
284
+ catch {
285
+ return;
286
+ }
287
+ // Filter out events from our own writes. writeToDisk re-stamps
288
+ // state.loadedMtime to the post-write disk mtime, so by the time this
289
+ // handler fires for our own atomicWriteFileSync, mtimes match and we
290
+ // skip. Only genuine external writes have diskMtime > loadedMtime.
291
+ if (diskMtime === state.loadedMtime)
292
+ return;
293
+ const reloaded = reloadActiveDocFromDisk();
294
+ if (!reloaded)
295
+ return;
296
+ // Bump version so any in-flight stale browser autosave is rejected by
297
+ // the existing version check in the WS handler. Without this bump, the
298
+ // browser's pre-external-write state would silently overwrite the
299
+ // freshly-loaded disk content.
300
+ bumpDocVersion();
301
+ notifyDocumentReloaded({
302
+ filePath: state.filePath,
303
+ filename: reloaded.filename,
304
+ document: reloaded.document,
305
+ title: reloaded.title,
306
+ docId: state.docId,
307
+ metadata: state.metadata,
308
+ orphans: reloaded.orphans,
309
+ staleBaseline: reloaded.staleBaseline,
310
+ });
311
+ const orphanCount = reloaded.orphans.length;
312
+ const staleCount = reloaded.staleBaseline.length;
313
+ const suffix = orphanCount || staleCount
314
+ ? ` (${orphanCount} orphan, ${staleCount} stale-baseline pending entries)`
315
+ : '';
316
+ console.log(`[State] reload active doc from external write: ${reloaded.filename}${suffix}`);
317
+ }
318
+ /** Start (or restart) the watcher on the active doc's filePath. Called
319
+ * whenever the active doc changes, or whenever load() lands a file. */
320
+ export function startActiveDocWatcher() {
321
+ stopActiveDocWatcher();
322
+ if (!state.filePath || !existsSync(state.filePath))
323
+ return;
324
+ // Some test environments (Windows CI, ephemeral filesystems) error from
325
+ // fs.watch on transient files. Swallow the failure — we'll simply not
326
+ // have external-write detection for this doc, which is degraded but
327
+ // not broken. writeToDisk still has the loadedMtime guard as a backstop.
328
+ try {
329
+ activeWatcherPath = state.filePath;
330
+ activeWatcher = watch(state.filePath, { persistent: false }, () => {
331
+ // Burst-debounce: editors often write through a temp file + rename,
332
+ // which fires multiple events in rapid succession. 80ms is short
333
+ // enough that human-perceptible latency is unchanged and long
334
+ // enough that one logical save coalesces.
335
+ if (watcherDebounceTimer)
336
+ clearTimeout(watcherDebounceTimer);
337
+ watcherDebounceTimer = setTimeout(handleWatcherEvent, 80);
338
+ });
339
+ activeWatcher.on('error', (err) => {
340
+ console.error(`[State] active doc watcher error on ${activeWatcherPath}:`, err.message);
341
+ stopActiveDocWatcher();
342
+ });
343
+ }
344
+ catch (err) {
345
+ console.error(`[State] failed to watch ${state.filePath}:`, err?.message || err);
346
+ stopActiveDocWatcher();
347
+ }
348
+ }
61
349
  // ============================================================================
62
350
  // EXTERNAL DOCUMENT REGISTRY
63
351
  // ============================================================================
64
352
  function getExternalDocsFile() { return join(getDataDir(), 'external-docs.json'); }
353
+ /** External docs registered with openwriter — canonicalized paths only.
354
+ * Adding via `registerExternalDoc` runs paths through `canonicalizePath`
355
+ * before insertion, so the same physical file via any spelling
356
+ * (forward/back slash, mixed case drive letter, symlink) collapses to a
357
+ * single entry.
358
+ * adr: adr/path-canonicalization.md */
65
359
  const externalDocs = new Set();
360
+ // ============================================================================
361
+ // AGENT-STUB REGISTRY (in-memory only — never persisted)
362
+ // ============================================================================
363
+ //
364
+ // A doc is an "agent stub" between create_document (which mints an empty
365
+ // shell) and populate_document + accept (which fills it with content). If
366
+ // the user rejects the populated content, the stub is deleted — the agent
367
+ // proposed a doc, the user declined, nothing should remain.
368
+ //
369
+ // HISTORICAL MISTAKE: stub status was stored as `agentCreated: true` in
370
+ // frontmatter on disk. That made it sticky across sessions, server
371
+ // restarts, and arbitrary file lifetimes. Any reject-all on a doc whose
372
+ // stub status had been forgotten in a previous flow would destructively
373
+ // delete a file with hours of accepted work. The fix is not "guard the
374
+ // destruction more carefully"; the fix is "don't persist transient
375
+ // session state to disk in the first place."
376
+ //
377
+ // Stub status is now an in-memory Set<filename> with process lifetime.
378
+ // - markAsAgentStub(filename) — called on create_document
379
+ // - unmarkAgentStub(filename) — called on any save that contains
380
+ // non-pending content (graduation), on accept-all, on rename
381
+ // - isAgentStub(filename) — the only thing reject-all-deletes consults
382
+ //
383
+ // A stub that survives a server restart is by definition no longer fresh.
384
+ // It loads from disk like any other doc and reject-all will not delete it.
385
+ // This is intentional: graduating-by-lifetime is the safest fallback.
386
+ //
387
+ // adr: adr/agent-stub-model.md
388
+ const agentStubFilenames = new Set();
389
+ export function markAsAgentStub(filename) {
390
+ if (filename)
391
+ agentStubFilenames.add(filename);
392
+ }
393
+ export function unmarkAgentStub(filename) {
394
+ if (filename)
395
+ agentStubFilenames.delete(filename);
396
+ }
397
+ export function isAgentStub(filename) {
398
+ return !!filename && agentStubFilenames.has(filename);
399
+ }
400
+ /** Legacy migration. If a file's frontmatter has `agentCreated: true`, that
401
+ * field came from the pre-architectural-fix code path. Strip it on load so
402
+ * we don't keep round-tripping a dead field. No in-memory stub registration
403
+ * happens — by definition, if the flag survived to disk this long, the doc
404
+ * is not a fresh stub anymore. */
405
+ function stripLegacyAgentCreated(metadata) {
406
+ if (metadata && 'agentCreated' in metadata)
407
+ delete metadata.agentCreated;
408
+ }
66
409
  function persistExternalDocs() {
67
410
  try {
68
411
  atomicWriteFileSync(getExternalDocsFile(), JSON.stringify([...externalDocs]));
69
412
  }
70
413
  catch { /* best-effort */ }
71
414
  }
415
+ /** Load external-doc registry from disk and canonicalize every entry on
416
+ * the way in. The Set's branded type collapses any pre-existing
417
+ * duplicates (forward-slash vs backslash entries for the same file)
418
+ * to one — that's the one-time migration. If canonicalization changed
419
+ * the on-disk representation, we re-persist so the file matches
420
+ * in-memory state.
421
+ * adr: adr/path-canonicalization.md */
72
422
  function loadExternalDocs() {
73
423
  try {
74
- if (existsSync(getExternalDocsFile())) {
75
- const paths = JSON.parse(readFileSync(getExternalDocsFile(), 'utf-8'));
76
- for (const p of paths) {
77
- if (existsSync(p))
78
- externalDocs.add(p);
424
+ if (!existsSync(getExternalDocsFile()))
425
+ return;
426
+ const paths = JSON.parse(readFileSync(getExternalDocsFile(), 'utf-8'));
427
+ let needsRewrite = false;
428
+ for (const p of paths) {
429
+ if (!existsSync(p)) {
430
+ needsRewrite = true;
431
+ continue;
79
432
  }
433
+ const canon = canonicalizePath(p);
434
+ if (canon !== p)
435
+ needsRewrite = true;
436
+ externalDocs.add(canon);
437
+ }
438
+ // If the on-disk file had duplicates or non-canonical entries, the
439
+ // collapsed Set is now smaller and/or different — persist it back.
440
+ if (needsRewrite || externalDocs.size !== paths.length) {
441
+ persistExternalDocs();
80
442
  }
81
443
  }
82
444
  catch { /* corrupt file — start fresh */ }
83
445
  }
84
446
  export function registerExternalDoc(fullPath) {
85
- externalDocs.add(fullPath);
447
+ externalDocs.add(canonicalizePath(fullPath));
86
448
  persistExternalDocs();
87
449
  }
88
450
  export function unregisterExternalDoc(fullPath) {
89
- externalDocs.delete(fullPath);
451
+ externalDocs.delete(canonicalizePath(fullPath));
90
452
  persistExternalDocs();
91
453
  }
92
454
  export function getExternalDocs() {
@@ -110,6 +472,20 @@ function isDocEmpty(doc) {
110
472
  export function getDocument() {
111
473
  return state.document;
112
474
  }
475
+ /** The clean canonical document — what disk holds, what the matcher pairs
476
+ * against, what snapshots capture. Primary state; not derived. */
477
+ export function getCanonical() {
478
+ return state.canonical;
479
+ }
480
+ /** Snapshot of the structured pending overlay. Primary state. */
481
+ export function getOverlayEntries() {
482
+ return Array.from(state.overlay.values());
483
+ }
484
+ /** Read-only view of the overlay Map. Use when you need keyed lookup
485
+ * rather than iteration. */
486
+ export function getOverlay() {
487
+ return state.overlay;
488
+ }
113
489
  export function getTitle() {
114
490
  return state.title;
115
491
  }
@@ -409,7 +785,10 @@ export function updateDocument(doc) {
409
785
  if (serverHadPending) {
410
786
  transferPendingAttrs(state.document, doc);
411
787
  }
412
- state.document = doc;
788
+ // Route the incoming merged-shape doc through the primary-state setter
789
+ // so canonical + overlay get refreshed and state.document is the
790
+ // recomputed merged view. Direct assignment is forbidden.
791
+ setPrimaryFromMerged(doc);
413
792
  state.lastModified = new Date();
414
793
  // Validate: if server had pending changes, verify they survived the transfer
415
794
  if (serverHadPending && !hasPendingChanges()) {
@@ -479,15 +858,58 @@ function transferPendingAttrs(source, target) {
479
858
  // ============================================================================
480
859
  // AGENT WRITE LOCK
481
860
  // ============================================================================
861
+ // adr: adr/agent-lock-per-doc.md
482
862
  const AGENT_LOCK_MS = 3000; // Block browser doc-updates for 3s after agent write
483
- let lastAgentWriteTime = 0;
484
- /** Set the agent write lock (called after agent changes). */
485
- export function setAgentLock() {
486
- lastAgentWriteTime = Date.now();
487
- }
488
- /** Check if the agent write lock is active. */
489
- export function isAgentLocked() {
490
- return Date.now() - lastAgentWriteTime < AGENT_LOCK_MS;
863
+ const lockExpiry = new Map();
864
+ let globalLockExpiry = 0;
865
+ /** Derive the identifier used for locking the active doc. Mirrors
866
+ * documents.ts:getActiveFilename without importing it (circular dep). */
867
+ function activeDocLockKey() {
868
+ const fp = state.filePath;
869
+ if (!fp)
870
+ return '';
871
+ if (isExternalDoc(fp))
872
+ return canonicalizeIdentifier(fp);
873
+ return fp.split(/[/\\]/).pop() || '';
874
+ }
875
+ /** Set the agent write lock for a specific document. */
876
+ export function setAgentLock(filename) {
877
+ if (!filename) {
878
+ // Defensive: empty filename means we don't know what to lock — lock global.
879
+ setAgentLockGlobal();
880
+ return;
881
+ }
882
+ const key = canonicalizeIdentifier(filename);
883
+ const wasActive = (lockExpiry.get(key) ?? 0) > Date.now();
884
+ lockExpiry.set(key, Date.now() + AGENT_LOCK_MS);
885
+ diagLog(`[Lock] SET filename=${key} ttl=${AGENT_LOCK_MS}ms${wasActive ? ' (extends active lock)' : ''}`);
886
+ }
887
+ /** Lock the currently active document. Convenience for callers that mutate
888
+ * via state.filePath rather than an explicit filename. */
889
+ export function setAgentLockActive() {
890
+ setAgentLock(activeDocLockKey());
891
+ }
892
+ /** Lock every document briefly. Used at server init so reconnecting browsers
893
+ * can't push stale state from before the restart. */
894
+ export function setAgentLockGlobal() {
895
+ globalLockExpiry = Date.now() + AGENT_LOCK_MS;
896
+ diagLog(`[Lock] SET global ttl=${AGENT_LOCK_MS}ms`);
897
+ }
898
+ /** Check if the agent write lock is active for a given document. */
899
+ export function isAgentLocked(filename) {
900
+ const now = Date.now();
901
+ if (globalLockExpiry > now)
902
+ return true;
903
+ if (!filename)
904
+ return false;
905
+ const key = canonicalizeIdentifier(filename);
906
+ const expiry = lockExpiry.get(key);
907
+ if (expiry === undefined)
908
+ return false;
909
+ if (expiry > now)
910
+ return true;
911
+ lockExpiry.delete(key);
912
+ return false;
491
913
  }
492
914
  // ---- Document version counter: prevents stale browser doc-updates ----
493
915
  let docVersion = 0;
@@ -508,9 +930,16 @@ export function resetDocVersion() {
508
930
  docVersion = 0;
509
931
  }
510
932
  // ---- Debounced save: coalesces rapid agent writes into a single disk write ----
933
+ //
934
+ // Single timer for the entire process. Both state.ts (MCP write paths, applyChanges,
935
+ // updateDocument) and ws.ts (browser doc-update, pending-resolved) call into this.
936
+ // Previously each module had its own timer (state.ts 500ms, ws.ts 2s) which meant
937
+ // a save could be armed by one path, reset by another, and fire on a delay that
938
+ // matched neither documented value. One timer, one TTL — predictable.
939
+ // adr: adr/pending-overlay-model.md
511
940
  let saveTimer = null;
512
941
  const SAVE_DEBOUNCE_MS = 500;
513
- function debouncedSave() {
942
+ export function debouncedSave() {
514
943
  if (saveTimer)
515
944
  clearTimeout(saveTimer);
516
945
  saveTimer = setTimeout(() => {
@@ -530,7 +959,7 @@ export function applyChanges(changes) {
530
959
  const processed = applyChangesToDocument(changes);
531
960
  // Bump version + lock browser doc-updates to prevent stale state overwrite
532
961
  const version = bumpDocVersion();
533
- setAgentLock();
962
+ setAgentLockActive();
534
963
  // Broadcast processed changes (with server-assigned IDs + version) to browser clients
535
964
  for (const listener of listeners) {
536
965
  listener(processed, version);
@@ -554,6 +983,10 @@ export function applyChanges(changes) {
554
983
  }
555
984
  return { count: processed.length, lastNodeId };
556
985
  }
986
+ export function onIdRewrites(listener) {
987
+ idRewriteListeners.add(listener);
988
+ return () => idRewriteListeners.delete(listener);
989
+ }
557
990
  export function onChanges(listener) {
558
991
  listeners.add(listener);
559
992
  return () => listeners.delete(listener);
@@ -562,6 +995,24 @@ export function onChanges(listener) {
562
995
  // SERVER-SIDE DOCUMENT MUTATIONS
563
996
  // ============================================================================
564
997
  // generateNodeId imported from helpers.ts
998
+ /**
999
+ * Containers whose internals are NOT addressable via MCP. `findNode` must not
1000
+ * descend into these — their child IDs are ephemeral (regenerated every
1001
+ * `markdownToTiptap` parse, untracked by the matcher) and never exposed via
1002
+ * `compactNodes`, so any agent-provided ID that happens to match a node inside
1003
+ * one is a collision, not a legitimate target.
1004
+ *
1005
+ * Before this guard existed, a `write_to_pad` rewrite could silently corrupt a
1006
+ * table cell: an agent ID collision with a freshly-minted table-internal node
1007
+ * routed the splice into the table instead of the intended top-level
1008
+ * paragraph. Reported as success, observable as stray paragraphs / mangled
1009
+ * rows in the saved markdown.
1010
+ *
1011
+ * Mirrors `node-blocks.ts`'s walker, which already treats tables as opaque.
1012
+ *
1013
+ * adr: adr/node-identity-matcher.md
1014
+ */
1015
+ const OPAQUE_CONTAINER_TYPES = new Set(['table', 'tableRow', 'tableCell', 'tableHeader']);
565
1016
  /**
566
1017
  * Find a node by ID in any document tree.
567
1018
  * topLevel is used to resolve the "end" sentinel.
@@ -577,6 +1028,11 @@ function findNode(nodes, id, topLevel) {
577
1028
  if (nodes[i].attrs?.id === id) {
578
1029
  return { parent: nodes, index: i };
579
1030
  }
1031
+ // Don't descend into table internals (table, tableRow, tableCell, tableHeader).
1032
+ // Their IDs aren't addressable via MCP and they regenerate on every parse,
1033
+ // so any match inside is a collision that would silently corrupt the table.
1034
+ if (OPAQUE_CONTAINER_TYPES.has(nodes[i].type))
1035
+ continue;
580
1036
  if (nodes[i].content && Array.isArray(nodes[i].content)) {
581
1037
  const result = findNode(nodes[i].content, id, topLevel);
582
1038
  if (result)
@@ -845,12 +1301,23 @@ export function isAutoAcceptActive(filename, metadata) {
845
1301
  return false;
846
1302
  return isAutoAcceptInheritedForDoc(filename);
847
1303
  }
848
- /** Apply changes to the active document singleton. */
1304
+ /** Apply changes to the active document singleton.
1305
+ *
1306
+ * The applyChangesToDoc engine mutates the merged view (state.document) —
1307
+ * it's where the pending-status stamping logic lives. After the mutation,
1308
+ * re-split state.document back into primary state (canonical + overlay) so
1309
+ * the cache + matcher + save paths see consistent primary state. The
1310
+ * re-split is the bridge between the legacy "mutate merged in place" engine
1311
+ * and the new "primary state is canonical + overlay" model. */
849
1312
  function applyChangesToDocument(changes) {
850
1313
  const autoAccept = isAutoAcceptActive(activeDocFilename(), state.metadata);
851
1314
  const processed = applyChangesToDoc(state.document, changes, autoAccept);
852
1315
  if (processed.length > 0) {
853
1316
  state.lastModified = new Date();
1317
+ // Re-sync primary state from the now-mutated merged view. Idempotent:
1318
+ // splitMergedDoc + applyOverlayPure round-trip leaves state.document
1319
+ // structurally equivalent to itself.
1320
+ setPrimaryFromMerged(state.document);
854
1321
  }
855
1322
  return processed;
856
1323
  }
@@ -864,8 +1331,16 @@ export function applyTextEdits(nodeId, edits) {
864
1331
  return { success: false, error: `Node ${nodeId} not found` };
865
1332
  const originalNode = found.parent[found.index];
866
1333
  const result = applyTextEditsToNode(originalNode, edits);
867
- if (!result)
868
- return { success: false, error: 'No edits matched' };
1334
+ if (!result) {
1335
+ // Surface a slice of the actual node text alongside the searched `find`
1336
+ // strings so the agent can diff for unicode/whitespace mismatches
1337
+ // (em-dash vs hyphen-minus, NBSP vs space, smart quotes, etc.). Without
1338
+ // this the failure is opaque and the agent has to guess.
1339
+ const nodeText = extractText(originalNode.content || []);
1340
+ const truncated = nodeText.length > 240 ? nodeText.slice(0, 240) + '…' : nodeText;
1341
+ const searched = edits.map((e) => JSON.stringify(e.find)).join(', ');
1342
+ return { success: false, error: `No edits matched in node ${nodeId}. Searched: ${searched}. Node text starts: ${JSON.stringify(truncated)}` };
1343
+ }
869
1344
  // Inline edit decoration only matters when there's a review surface — skip in autoAccept.
870
1345
  if (!isAutoAcceptActive(activeDocFilename(), state.metadata)) {
871
1346
  result.node.attrs = {
@@ -888,14 +1363,42 @@ export function applyTextEdits(nodeId, edits) {
888
1363
  * (Option B in adr/node-identity-matcher.md). Markdown is the source of
889
1364
  * truth; memory is an ephemeral working copy. */
890
1365
  export function setActiveDocument(doc, title, filePath, isTemp, lastModified, metadata, originalFrontmatter) {
891
- state.document = doc;
1366
+ // Route the incoming doc through the primary-state setter — splits into
1367
+ // canonical + overlay (handles legacy in-frontmatter pending if present)
1368
+ // and recomputes the merged view.
1369
+ setPrimaryFromMerged(doc);
892
1370
  state.title = title;
893
1371
  state.metadata = metadata || { title };
894
- state.filePath = filePath;
1372
+ // Legacy: strip any pre-architectural-fix `agentCreated` field that
1373
+ // arrived in metadata (e.g. from a re-parse of an old on-disk file).
1374
+ // The in-memory agentStubFilenames Set is the only authority for stub
1375
+ // status — disk frontmatter must not carry stub state.
1376
+ stripLegacyAgentCreated(state.metadata);
1377
+ // Canonicalize at the identity boundary: same physical file via any
1378
+ // spelling (forward/back slash, drive-letter case, symlink) lands the
1379
+ // same string in state.filePath, which is the cache key for the doc
1380
+ // cache and the subscription path for the fs watcher.
1381
+ // adr: adr/path-canonicalization.md
1382
+ state.filePath = filePath ? canonicalizePath(filePath) : '';
895
1383
  state.isTemp = isTemp;
896
1384
  state.lastModified = lastModified || new Date();
897
1385
  state.docId = ensureDocId(state.metadata);
898
1386
  state.originalFrontmatter = originalFrontmatter ?? null;
1387
+ // Snapshot the on-disk mtime so writeToDisk can detect external writes
1388
+ // that land while this doc is active. 0 = no file on disk yet (new doc).
1389
+ try {
1390
+ state.loadedMtime = filePath && existsSync(filePath) ? statSync(filePath).mtimeMs : 0;
1391
+ }
1392
+ catch {
1393
+ state.loadedMtime = 0;
1394
+ }
1395
+ // Pending overlay rehydration. See mergeOverlayOnLoad for the three cases
1396
+ // (sidecar present, legacy migration, no pending).
1397
+ mergeOverlayOnLoad();
1398
+ // Subscribe the fs watcher to this doc so external writes (Edit tool,
1399
+ // VSCode, scripts) trigger a unified reload + version bump + broadcast.
1400
+ // adr: adr/active-doc-watcher.md
1401
+ startActiveDocWatcher();
899
1402
  }
900
1403
  // ============================================================================
901
1404
  // PENDING DOCUMENT CACHE (avoids disk scans on every broadcast)
@@ -965,7 +1468,12 @@ function populatePendingCache() {
965
1468
  catch { /* skip unreadable files */ }
966
1469
  }
967
1470
  }
968
- const docCache = new Map(); // key = filePath
1471
+ /** Keyed by canonical path. Two spellings of the same physical file
1472
+ * (forward/back slash, drive-letter case) collapse to one cache entry —
1473
+ * preventing parallel state where the same disk file lives in two
1474
+ * cache slots.
1475
+ * adr: adr/path-canonicalization.md */
1476
+ const docCache = new Map();
969
1477
  /** Cache the active document's full state, keyed by filePath. Call after save().
970
1478
  *
971
1479
  * Identity (nodes + graveyard) is NOT cached — the save-time matcher reads
@@ -979,8 +1487,14 @@ export function cacheActiveDocument() {
979
1487
  fileMtime = statSync(state.filePath).mtimeMs;
980
1488
  }
981
1489
  catch { /* file may not exist yet */ }
982
- docCache.set(state.filePath, {
983
- document: structuredClone(state.document),
1490
+ // Split the live merged document into canonical + overlay AT cache time —
1491
+ // this is what protects against the duplicate-insert bug. Storing merged
1492
+ // and re-applying overlay later was the broken pattern.
1493
+ const split = splitMergedDoc(state.document);
1494
+ docCache.set(canonicalizePath(state.filePath), {
1495
+ canonical: split.canonical,
1496
+ overlayEntries: split.overlayEntries,
1497
+ document: applyOverlayPure(split.canonical, split.overlayEntries),
984
1498
  metadata: structuredClone(state.metadata),
985
1499
  title: state.title,
986
1500
  isTemp: state.isTemp,
@@ -992,27 +1506,28 @@ export function cacheActiveDocument() {
992
1506
  }
993
1507
  /** Get a cached document if the file hasn't been modified externally. Returns null on miss or stale. */
994
1508
  export function getCachedDocument(filePath) {
995
- const cached = docCache.get(filePath);
1509
+ const key = canonicalizePath(filePath);
1510
+ const cached = docCache.get(key);
996
1511
  if (!cached)
997
1512
  return null;
998
1513
  try {
999
1514
  const currentMtime = statSync(filePath).mtimeMs;
1000
1515
  if (currentMtime !== cached.fileMtime) {
1001
1516
  // File changed on disk — invalidate cache
1002
- docCache.delete(filePath);
1517
+ docCache.delete(key);
1003
1518
  return null;
1004
1519
  }
1005
1520
  }
1006
1521
  catch {
1007
1522
  // File doesn't exist or can't be read — invalidate
1008
- docCache.delete(filePath);
1523
+ docCache.delete(key);
1009
1524
  return null;
1010
1525
  }
1011
1526
  return cached;
1012
1527
  }
1013
1528
  /** Remove a specific file from the document cache. */
1014
1529
  export function invalidateDocCache(filePath) {
1015
- docCache.delete(filePath);
1530
+ docCache.delete(canonicalizePath(filePath));
1016
1531
  }
1017
1532
  /** Update the cache entry for a file after writing changes (without cloning the active state). */
1018
1533
  export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
@@ -1021,10 +1536,18 @@ export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId)
1021
1536
  fileMtime = statSync(filePath).mtimeMs;
1022
1537
  }
1023
1538
  catch { /* best-effort */ }
1539
+ const key = canonicalizePath(filePath);
1024
1540
  // Preserve originalFrontmatter from existing cache entry (if any)
1025
- const existing = docCache.get(filePath);
1026
- docCache.set(filePath, {
1027
- document: structuredClone(doc),
1541
+ const existing = docCache.get(key);
1542
+ // Split the incoming doc into canonical + overlay before caching so the
1543
+ // cache stores canonical-only and the duplicate-insert chain is broken.
1544
+ const split = splitMergedDoc(doc);
1545
+ const overlayEntries = split.overlayEntries;
1546
+ const canonicalClone = split.canonical;
1547
+ docCache.set(key, {
1548
+ canonical: canonicalClone,
1549
+ overlayEntries,
1550
+ document: applyOverlayPure(canonicalClone, overlayEntries),
1028
1551
  metadata: structuredClone(metadata),
1029
1552
  title,
1030
1553
  isTemp,
@@ -1036,10 +1559,15 @@ export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId)
1036
1559
  }
1037
1560
  /** Reset all in-memory caches. Called on profile switch. */
1038
1561
  export function clearAllCaches() {
1562
+ // Tear down the active-doc watcher before swapping state — leaving it
1563
+ // alive would point at a path the new profile doesn't own.
1564
+ stopActiveDocWatcher();
1039
1565
  docCache.clear();
1040
1566
  pendingDocCache.clear();
1041
1567
  externalDocs.clear();
1042
1568
  state = {
1569
+ canonical: DEFAULT_DOC,
1570
+ overlay: new Map(),
1043
1571
  document: DEFAULT_DOC,
1044
1572
  title: 'Untitled',
1045
1573
  metadata: { title: 'Untitled' },
@@ -1048,6 +1576,7 @@ export function clearAllCaches() {
1048
1576
  lastModified: new Date(),
1049
1577
  docId: '',
1050
1578
  originalFrontmatter: null,
1579
+ loadedMtime: 0,
1051
1580
  };
1052
1581
  }
1053
1582
  // ============================================================================
@@ -1069,23 +1598,225 @@ export function hasPendingChanges(doc) {
1069
1598
  }
1070
1599
  return scan(target.content);
1071
1600
  }
1072
- /** Strip all pending attrs from the current document (after browser resolves all changes). */
1601
+ /** Strip all pending attrs from the current document (after browser resolves all changes).
1602
+ * In the new model this is implemented by clearing the overlay — the merged
1603
+ * view recomputes to canonical (which already has no pending markers). */
1073
1604
  export function stripPendingAttrs() {
1074
- function strip(nodes) {
1075
- if (!nodes)
1605
+ state.overlay = new Map();
1606
+ recomputeMerged();
1607
+ removePendingCacheEntry(activeDocFilename());
1608
+ }
1609
+ /**
1610
+ * Return a deep clone of `doc` with all pending changes reverted, as if the
1611
+ * user had rejected every pending decoration:
1612
+ * - pendingStatus=insert → drop the node
1613
+ * - pendingStatus=rewrite → replace node with `pendingOriginalContent` (or drop if absent)
1614
+ * - pendingStatus=delete → keep node, clear pending attrs (the rejection is "no, don't delete")
1615
+ * - no pendingStatus → keep node, strip any stray pending attrs
1616
+ *
1617
+ * Used by `restore_version` to write a canonical-only safety checkpoint so
1618
+ * the snapshot represents a clean recovery point rather than a flattened
1619
+ * pending+canonical hybrid that never actually existed in the user's view.
1620
+ *
1621
+ * Does NOT mutate the input doc.
1622
+ */
1623
+ /**
1624
+ * Reload the active doc from disk. Re-reads the canonical .md file, applies
1625
+ * the pending overlay from the sidecar (with orphan + stale-baseline
1626
+ * classification), and updates state.document, state.metadata, state.title,
1627
+ * and state.loadedMtime in place.
1628
+ *
1629
+ * Called by:
1630
+ * - chokidar watcher when an external write modifies the active file
1631
+ * - mcp.restore_version after writing the snapshot's content to disk
1632
+ * - mcp.reload_from_disk tool (explicit user-driven reload)
1633
+ * - writeToDisk's mtime-CAS backstop (recover-and-retry path)
1634
+ *
1635
+ * Returns the reload result so callers can broadcast or react to the
1636
+ * orphan / staleBaseline classifications. Returns null if there's no
1637
+ * active file path to reload (no-op).
1638
+ *
1639
+ * adr: adr/pending-overlay-model.md
1640
+ */
1641
+ export function reloadActiveDocFromDisk() {
1642
+ if (!state.filePath || !existsSync(state.filePath))
1643
+ return null;
1644
+ const raw = readFileSync(state.filePath, 'utf-8');
1645
+ const parsed = markdownToTiptap(raw);
1646
+ // Split parsed doc: canonical (clean) + any legacy in-frontmatter pending.
1647
+ const { canonical, overlayEntries: legacyOverlay } = splitMergedDoc(parsed.document);
1648
+ state.title = parsed.title;
1649
+ state.metadata = parsed.metadata;
1650
+ stripLegacyAgentCreated(state.metadata);
1651
+ state.docId = ensureDocId(state.metadata);
1652
+ state.lastModified = new Date(statSync(state.filePath).mtimeMs);
1653
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
1654
+ // Sidecar is authoritative if present. Otherwise legacy in-doc pending
1655
+ // migrates to sidecar (one-time).
1656
+ const sidecar = loadOverlay(state.docId);
1657
+ let entries;
1658
+ if (sidecar.length > 0) {
1659
+ entries = sidecar;
1660
+ }
1661
+ else if (legacyOverlay.length > 0) {
1662
+ entries = legacyOverlay;
1663
+ saveOverlay(state.docId, legacyOverlay);
1664
+ }
1665
+ else {
1666
+ entries = [];
1667
+ }
1668
+ // Set primary state directly — canonical from disk parse, overlay from
1669
+ // sidecar (or legacy migration). Recompute merged via the helper.
1670
+ state.canonical = canonical;
1671
+ setOverlayFromEntries(entries);
1672
+ return {
1673
+ document: state.document,
1674
+ title: state.title,
1675
+ filename: state.filePath.split(/[/\\]/).pop() || '',
1676
+ orphans: [],
1677
+ staleBaseline: [],
1678
+ };
1679
+ }
1680
+ /**
1681
+ * Pending overlay rehydration. Runs after a doc loads from disk (load() or
1682
+ * setActiveDocument). Three cases:
1683
+ *
1684
+ * 1. Sidecar has entries: state.document is canonical (parser found no
1685
+ * `meta.pending`). Apply sidecar overlay to layer pending decorations
1686
+ * back onto the canonical tree.
1687
+ *
1688
+ * 2. Sidecar empty, parser stamped pending attrs from legacy frontmatter:
1689
+ * one-time migration. Extract overlay from state.document, save to
1690
+ * sidecar. State.document already has pending attrs from parse, so
1691
+ * no apply step needed.
1692
+ *
1693
+ * 3. Sidecar empty, parser stamped nothing: no pending state. No-op.
1694
+ *
1695
+ * Returns the merged overlay entries for callers that need them.
1696
+ * adr: adr/pending-overlay-model.md
1697
+ */
1698
+ function mergeOverlayOnLoad() {
1699
+ if (!state.docId)
1700
+ return [];
1701
+ // setActiveDocument just populated primary state via setPrimaryFromMerged.
1702
+ // If a sidecar exists for this docId, it overrides any legacy in-doc pending
1703
+ // that the splitter extracted from the input. If no sidecar exists and the
1704
+ // legacy split found entries, persist them as a one-time migration.
1705
+ const sidecar = loadOverlay(state.docId);
1706
+ const legacy = Array.from(state.overlay.values());
1707
+ if (sidecar.length > 0) {
1708
+ setOverlayFromEntries(sidecar);
1709
+ return sidecar;
1710
+ }
1711
+ else if (legacy.length > 0) {
1712
+ saveOverlay(state.docId, legacy);
1713
+ // overlay already has these entries from setPrimaryFromMerged; no-op
1714
+ return legacy;
1715
+ }
1716
+ return [];
1717
+ }
1718
+ /**
1719
+ * Apply an oldId → newId translation map to a TipTap doc tree in place.
1720
+ * Used by writeToDisk to bring state.document's nodeIds in sync with the
1721
+ * matcher's post-pass canonical IDs.
1722
+ */
1723
+ function applyIdTranslationToDoc(doc, translation) {
1724
+ if (translation.size === 0)
1725
+ return;
1726
+ function walk(nodes) {
1727
+ if (!Array.isArray(nodes))
1076
1728
  return;
1077
1729
  for (const node of nodes) {
1078
- if (node.attrs?.pendingStatus) {
1079
- delete node.attrs.pendingStatus;
1080
- delete node.attrs.pendingOriginalContent;
1081
- delete node.attrs.pendingTextEdits;
1730
+ const oldId = node?.attrs?.id;
1731
+ if (oldId && translation.has(oldId)) {
1732
+ node.attrs.id = translation.get(oldId);
1082
1733
  }
1083
- if (node.content)
1084
- strip(node.content);
1734
+ if (node?.content)
1735
+ walk(node.content);
1085
1736
  }
1086
1737
  }
1087
- strip(state.document.content);
1088
- removePendingCacheEntry(activeDocFilename());
1738
+ walk(doc.content || []);
1739
+ }
1740
+ export function cloneWithPendingReverted(doc) {
1741
+ const PENDING_KEYS = ['pendingStatus', 'pendingOriginalContent', 'pendingGroupId', 'pendingTextEdits', 'pendingSelectionFrom', 'pendingSelectionTo', 'pendingOriginalFrom', 'pendingOriginalTo', 'pendingOrphan', 'pendingStaleBaseline'];
1742
+ function clean(node) {
1743
+ const clone = JSON.parse(JSON.stringify(node));
1744
+ if (clone.attrs) {
1745
+ for (const k of PENDING_KEYS)
1746
+ delete clone.attrs[k];
1747
+ }
1748
+ if (clone.content)
1749
+ clone.content = walk(clone.content);
1750
+ return clone;
1751
+ }
1752
+ function walk(nodes) {
1753
+ const result = [];
1754
+ for (const node of nodes) {
1755
+ const status = node.attrs?.pendingStatus;
1756
+ if (status === 'insert')
1757
+ continue; // drop fresh agent inserts
1758
+ if (status === 'rewrite') {
1759
+ const original = node.attrs?.pendingOriginalContent;
1760
+ if (original)
1761
+ result.push(clean(original));
1762
+ // If no original stashed, drop the node — we have nothing to revert to.
1763
+ continue;
1764
+ }
1765
+ // 'delete' status or no status: keep the node, strip any pending attrs.
1766
+ result.push(clean(node));
1767
+ }
1768
+ return result;
1769
+ }
1770
+ return { type: 'doc', content: walk(doc.content || []) };
1771
+ }
1772
+ /**
1773
+ * Does the document have any "accepted" content — i.e. blocks that wouldn't
1774
+ * vanish under reject-all? Used to clear the `agentCreated` flag once a stub
1775
+ * has graduated into a real document, so a later reject-all on stale pending
1776
+ * decorations doesn't accidentally trigger the delete-on-reject cascade.
1777
+ *
1778
+ * A node counts as accepted if it has no pendingStatus, or has
1779
+ * pendingStatus=delete (reject keeps the node), or pendingStatus=rewrite with
1780
+ * `pendingOriginalContent` present (reject restores prior content).
1781
+ */
1782
+ export function hasAcceptedContent(doc) {
1783
+ function extractTextLocal(nodes) {
1784
+ let out = '';
1785
+ for (const n of nodes) {
1786
+ if (typeof n.text === 'string')
1787
+ out += n.text;
1788
+ if (n.content)
1789
+ out += extractTextLocal(n.content);
1790
+ }
1791
+ return out;
1792
+ }
1793
+ function walk(nodes) {
1794
+ if (!nodes)
1795
+ return false;
1796
+ for (const node of nodes) {
1797
+ const status = node.attrs?.pendingStatus;
1798
+ // Nodes that would NOT survive reject-all: skip entirely, including their
1799
+ // children. Otherwise an insert-pending paragraph's text children would be
1800
+ // misread as accepted content.
1801
+ if (status === 'insert')
1802
+ continue;
1803
+ if (status === 'rewrite' && !node.attrs?.pendingOriginalContent)
1804
+ continue;
1805
+ // This node survives reject-all (no status, or delete, or rewrite-with-original).
1806
+ const surfaceText = node.text || extractTextLocal(node.content || []);
1807
+ if (surfaceText && surfaceText.trim().length > 0)
1808
+ return true;
1809
+ // Non-text leaf types also count as accepted content
1810
+ if (node.type === 'image' || node.type === 'horizontalRule' || node.type === 'table')
1811
+ return true;
1812
+ // Recurse into container nodes (lists, blockquotes) only when the
1813
+ // container itself isn't a dropped-on-reject node.
1814
+ if (node.content && walk(node.content))
1815
+ return true;
1816
+ }
1817
+ return false;
1818
+ }
1819
+ return walk(doc.content || []);
1089
1820
  }
1090
1821
  /**
1091
1822
  * Mark leaf block nodes as pending within a node array.
@@ -1146,54 +1877,145 @@ function writeToDisk() {
1146
1877
  }
1147
1878
  catch { /* best-effort */ }
1148
1879
  }
1880
+ // Stub graduation: once the doc contains accepted content, it's no longer
1881
+ // a fresh stub. Remove it from the in-memory stub registry so reject-all
1882
+ // can never trigger the cleanup-delete on it.
1883
+ // adr: adr/agent-stub-model.md
1884
+ if (hasAcceptedContent(state.document)) {
1885
+ unmarkAgentStub(activeDocFilename());
1886
+ }
1887
+ // Defensive: never serialize `agentCreated` to disk. The field is dead;
1888
+ // any code reading it would be the bug, not the field's presence.
1889
+ if (state.metadata)
1890
+ stripLegacyAgentCreated(state.metadata);
1149
1891
  let markdown;
1150
1892
  if (isExternalDoc(state.filePath)) {
1151
- // External files: preserve original frontmatter verbatim, no OpenWriter metadata injected
1893
+ // External files: preserve original frontmatter verbatim, no OpenWriter metadata injected.
1894
+ // External docs don't participate in the pending overlay system (the external editor
1895
+ // is the source of truth for the file's structure).
1152
1896
  const body = tiptapToBody(state.document).replace(/(?:\s*<!-- -->\s*)+$/, '\n');
1153
1897
  markdown = state.originalFrontmatter
1154
1898
  ? `---\n${state.originalFrontmatter}\n---\n\n${body}`
1155
1899
  : body;
1156
1900
  }
1157
1901
  else {
1158
- // Save-time matcher pass (Option B: disk is the source of truth).
1902
+ // Save-time matcher pass + pending overlay split.
1903
+ //
1904
+ // Architectural model: disk is canonical only. Pending state lives in a
1905
+ // sidecar at `_pending/{docId}.json`. We split state.document into:
1906
+ // - canonical: a clone with all pending reverted (matcher operates on this)
1907
+ // - overlay: the extracted pending entries (saved to sidecar)
1159
1908
  //
1160
- // Read the existing file's frontmatter to recover previousNodes +
1161
- // graveyard, run the matcher against the current TipTap tree, and apply
1162
- // pinned IDs back onto the tree. Without this, type-change and
1163
- // graveyard-restore never fire within a session the editor mints fresh
1164
- // IDs at insert time and the load-time matcher only sees the post-edit
1165
- // state. Memory holds no identity cache; identity always re-derives from
1166
- // disk at the save boundary.
1909
+ // The matcher runs on canonical so the on-disk `nodes:` fingerprints match
1910
+ // the on-disk body. Pre-matcher canonical IDs are translated to post-matcher
1911
+ // IDs and the same translation is applied to (a) state.document, so the
1912
+ // in-memory tree stays consistent with disk, and (b) the overlay entries,
1913
+ // so they re-anchor correctly on reload.
1167
1914
  //
1168
- // adr: adr/node-identity-matcher.md
1915
+ // adr: adr/node-identity-matcher.md · adr: adr/pending-overlay-model.md
1916
+ const canonical = cloneWithPendingReverted(state.document);
1169
1917
  const { previousNodes, graveyard } = readPersistedIdentity(state.filePath);
1170
1918
  let nextGraveyard = graveyard;
1919
+ const idTranslation = new Map();
1171
1920
  if (previousNodes.length > 0) {
1172
- const newBlocks = tiptapToBlocks(state.document);
1921
+ const newBlocks = tiptapToBlocks(canonical);
1922
+ const beforeIds = newBlocks.map((b) => b.id);
1173
1923
  const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
1174
1924
  const pinnedByPosition = new Map();
1175
1925
  for (const p of matchResult.pinned)
1176
1926
  pinnedByPosition.set(p.position, p.id);
1177
- applyIdsToTiptap(state.document, pinnedByPosition);
1927
+ applyIdsToTiptap(canonical, pinnedByPosition);
1178
1928
  nextGraveyard = matchResult.nextGraveyard;
1929
+ // Build pre→post id translation (canonical's IDs match state.document's
1930
+ // IDs at non-insert positions, since cloneWithPendingReverted preserves
1931
+ // IDs on rewrite/delete/passthrough nodes).
1932
+ for (let i = 0; i < beforeIds.length; i++) {
1933
+ const oldId = beforeIds[i];
1934
+ const newId = pinnedByPosition.get(i);
1935
+ if (oldId && newId && oldId !== newId) {
1936
+ idTranslation.set(oldId, newId);
1937
+ }
1938
+ }
1939
+ // Apply translation to primary state. The matcher renamed IDs on
1940
+ // canonical (above); we need state.canonical, state.overlay's entry
1941
+ // keys (the nodeId fields), and state.document to all see the new IDs.
1942
+ if (idTranslation.size > 0) {
1943
+ applyIdTranslationToDoc(state.canonical, idTranslation);
1944
+ // Translate overlay entry nodeIds (rewrite/delete entries point at
1945
+ // canonical IDs that may have shifted; insert entries have unique
1946
+ // IDs not in the translation map and pass through).
1947
+ const newOverlay = new Map();
1948
+ for (const [nodeId, entry] of state.overlay) {
1949
+ const newNodeId = idTranslation.get(nodeId) ?? nodeId;
1950
+ newOverlay.set(newNodeId, { ...entry, nodeId: newNodeId });
1951
+ }
1952
+ state.overlay = newOverlay;
1953
+ recomputeMerged();
1954
+ }
1955
+ // Broadcast id-rewrites so browser clients converge their TipTap state.
1956
+ if (idRewriteListeners.size > 0 && idTranslation.size > 0) {
1957
+ const rewrites = Array.from(idTranslation, ([oldId, newId]) => ({ oldId, newId }));
1958
+ for (const listener of idRewriteListeners)
1959
+ listener(rewrites);
1960
+ }
1961
+ }
1962
+ // Persist the structured overlay to sidecar. state.overlay IS the overlay
1963
+ // in the new model — no extraction needed.
1964
+ if (state.docId) {
1965
+ saveOverlay(state.docId, Array.from(state.overlay.values()));
1179
1966
  }
1180
1967
  // Pass graveyard through metadata so the serializer can emit it in frontmatter.
1181
1968
  const metaWithGraveyard = nextGraveyard.length > 0
1182
1969
  ? { ...state.metadata, graveyard: nextGraveyard.map((g) => ({ id: g.id, fp: g.fingerprint })) }
1183
1970
  : state.metadata;
1184
- // Checked serializer — verifies the TipTap markdown → TipTap round-trip
1185
- // preserves block shape. Logs to console on drift; never blocks the save.
1186
- const result = tiptapToMarkdownChecked(state.document, state.title, metaWithGraveyard);
1971
+ // Checked serializer — operates on canonical (already pending-reverted).
1972
+ // The serializer no longer emits `meta.pending` (overlay handles that).
1973
+ const result = tiptapToMarkdownChecked(canonical, state.title, metaWithGraveyard);
1187
1974
  markdown = result.markdown;
1188
1975
  }
1189
1976
  if (existsSync(state.filePath)) {
1190
1977
  // Skip write if content is identical (prevents phantom git changes on doc switch)
1191
1978
  try {
1192
1979
  const existing = readFileSync(state.filePath, 'utf-8');
1193
- if (existing === markdown)
1980
+ if (existing === markdown) {
1981
+ // Even on a no-op write, refresh our mtime snapshot so we don't
1982
+ // misread a stale `loadedMtime` as evidence of an external write.
1983
+ try {
1984
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
1985
+ }
1986
+ catch { /* best-effort */ }
1194
1987
  return;
1988
+ }
1195
1989
  }
1196
1990
  catch { /* read failed, proceed with write */ }
1991
+ // EXTERNAL-WRITE GUARD: if disk mtime is newer than the mtime we stamped
1992
+ // at load (or our last successful save), an external writer modified the
1993
+ // file out from under us. Blindly writing our in-memory state would
1994
+ // clobber their content silently. Block the write, log, and surface via
1995
+ // sync-status so the agent/user can resolve via reload_from_disk.
1996
+ //
1997
+ // Edge cases handled:
1998
+ // - First save of a new doc: loadedMtime=0, so the guard never fires
1999
+ // (every real file's mtime will be > 0).
2000
+ // - Atomic-write race: writeFileSync momentarily mtime-bumps. We re-
2001
+ // stamp loadedMtime AFTER every successful own write so subsequent
2002
+ // guard checks compare against our own write, not a phantom delta.
2003
+ // - Clock drift: we compare exact ms equality (not >); any change at
2004
+ // all is treated as external. Filesystems guarantee monotonic mtime
2005
+ // per file on the same host so this is safe.
2006
+ if (state.loadedMtime > 0) {
2007
+ try {
2008
+ const diskMtime = statSync(state.filePath).mtimeMs;
2009
+ if (diskMtime !== state.loadedMtime) {
2010
+ console.error(`[State] BLOCKED save: external write detected on ${state.filePath} ` +
2011
+ `(disk mtime ${new Date(diskMtime).toISOString()} != loaded mtime ${new Date(state.loadedMtime).toISOString()}). ` +
2012
+ `Call reload_from_disk to adopt external content, or write_to_pad to re-apply changes on top.`);
2013
+ notifyExternalWriteConflict(state.filePath, diskMtime, state.loadedMtime);
2014
+ return;
2015
+ }
2016
+ }
2017
+ catch { /* stat failed, proceed with save */ }
2018
+ }
1197
2019
  // Safety: don't overwrite a file with substantial content using near-empty content.
1198
2020
  // Prevents save cascades where empty editor state destroys chapter files.
1199
2021
  // Exception: docs with pending changes may legitimately be smaller (agent replaced content).
@@ -1209,6 +2031,12 @@ function writeToDisk() {
1209
2031
  }
1210
2032
  }
1211
2033
  atomicWriteFileSync(state.filePath, markdown);
2034
+ // Re-stamp loadedMtime so the next save's guard compares against our own
2035
+ // most-recent write, not the prior load's mtime.
2036
+ try {
2037
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
2038
+ }
2039
+ catch { /* best-effort */ }
1212
2040
  // Best-effort version snapshot — never blocks saves
1213
2041
  try {
1214
2042
  snapshotIfNeeded(state.docId, state.filePath);
@@ -1228,14 +2056,17 @@ function writeToDisk() {
1228
2056
  }
1229
2057
  export function save() {
1230
2058
  if (!state.filePath) {
1231
- // First save — assign a file path
2059
+ // First save — assign a file path. Canonicalize at this identity
2060
+ // boundary so cache lookups and watcher subscriptions key on the
2061
+ // same string regardless of how this path was produced.
2062
+ // adr: adr/path-canonicalization.md
1232
2063
  ensureDataDir();
1233
2064
  if (state.title === 'Untitled') {
1234
- state.filePath = tempFilePath();
2065
+ state.filePath = canonicalizePath(tempFilePath());
1235
2066
  state.isTemp = true;
1236
2067
  }
1237
2068
  else {
1238
- state.filePath = filePathForTitle(state.title);
2069
+ state.filePath = canonicalizePath(filePathForTitle(state.title));
1239
2070
  state.isTemp = false;
1240
2071
  }
1241
2072
  }
@@ -1243,6 +2074,9 @@ export function save() {
1243
2074
  }
1244
2075
  export function load() {
1245
2076
  ensureDataDir();
2077
+ // One-time sidecar repair MUST run before any doc loads so the file-walk
2078
+ // loop reads deduped sidecars, not corrupted ones. adr: adr/pending-overlay-model.md
2079
+ repairOverlaysOnStartup();
1246
2080
  // Restore external document registry from disk
1247
2081
  loadExternalDocs();
1248
2082
  // Migrate any .sw.json files to .md
@@ -1268,19 +2102,38 @@ export function load() {
1268
2102
  // Skip empty temp files — prefer a real document
1269
2103
  if (isTemp && isDocEmpty(parsed.document))
1270
2104
  continue;
1271
- state.document = parsed.document;
2105
+ // Route the parsed doc through the primary-state setter — splits into
2106
+ // canonical + overlay (handles legacy in-frontmatter pending) and
2107
+ // recomputes the merged view via state.document.
2108
+ setPrimaryFromMerged(parsed.document);
1272
2109
  state.title = parsed.title;
1273
2110
  state.metadata = parsed.metadata;
2111
+ // Legacy: strip any pre-architectural-fix `agentCreated` field that
2112
+ // survived on disk. The in-memory stub registry is the only authority.
2113
+ stripLegacyAgentCreated(state.metadata);
1274
2114
  state.lastModified = new Date(statSync(file.path).mtimeMs);
1275
- state.filePath = file.path;
2115
+ state.filePath = canonicalizePath(file.path);
1276
2116
  state.isTemp = isTemp;
2117
+ state.loadedMtime = statSync(file.path).mtimeMs;
1277
2118
  // Lazy docId migration: assign if missing, save to persist
1278
2119
  const hadDocId = !!state.metadata.docId;
1279
2120
  state.docId = ensureDocId(state.metadata);
1280
2121
  if (!hadDocId) {
1281
2122
  const md = tiptapToMarkdown(state.document, state.title, state.metadata);
1282
2123
  atomicWriteFileSync(state.filePath, md);
2124
+ try {
2125
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
2126
+ }
2127
+ catch { /* best-effort */ }
1283
2128
  }
2129
+ // Pending overlay merge: rehydrate pending decorations from the sidecar.
2130
+ // For legacy files (parser stamped pending attrs from old `meta.pending`),
2131
+ // capture those into the overlay format and write the sidecar as a one-
2132
+ // time migration. For migrated files (parser stamped nothing because
2133
+ // `meta.pending` is gone from frontmatter), the sidecar is the only
2134
+ // source.
2135
+ // adr: adr/pending-overlay-model.md
2136
+ mergeOverlayOnLoad();
1284
2137
  break;
1285
2138
  }
1286
2139
  catch {
@@ -1290,15 +2143,18 @@ export function load() {
1290
2143
  }
1291
2144
  // If nothing loaded (all files were empty temps or corrupt), start fresh
1292
2145
  if (!state.filePath) {
1293
- state.filePath = tempFilePath();
2146
+ state.filePath = canonicalizePath(tempFilePath());
1294
2147
  state.isTemp = true;
1295
2148
  }
1296
2149
  // Populate pending doc cache from disk (single scan on startup)
1297
2150
  populatePendingCache();
1298
2151
  // Overlay active doc's in-memory state (may have unsaved pending changes)
1299
2152
  updatePendingCacheForActiveDoc();
2153
+ // Subscribe the active-doc watcher so external writes route through the
2154
+ // unified reload pathway. adr: adr/active-doc-watcher.md
2155
+ startActiveDocWatcher();
1300
2156
  // Startup lock: block browser doc-updates briefly to prevent stale reconnect pushes
1301
- setAgentLock();
2157
+ setAgentLockGlobal();
1302
2158
  }
1303
2159
  /** Migrate legacy .sw.json files to .md format */
1304
2160
  function migrateSwJsonFiles() {
@@ -1388,9 +2244,13 @@ function cleanupEmptyTempFiles() {
1388
2244
  try {
1389
2245
  const raw = readFileSync(fullPath, 'utf-8');
1390
2246
  const parsed = markdownToTiptap(raw);
1391
- // Keep temp files that have meaningful metadata (templates, pending changes, tags)
2247
+ // Keep temp files that have meaningful metadata (templates, pending changes, tags).
2248
+ // Note: `agentCreated` used to be a disk-frontmatter signal here. Stub status is now
2249
+ // in-memory only — see agentStubFilenames. An empty temp file with no other meaningful
2250
+ // metadata that survived a server restart is by definition no longer a fresh stub and
2251
+ // can be cleaned up.
1392
2252
  const meta = parsed.metadata || {};
1393
- const hasMetadata = meta.tweetContext || meta.articleContext || meta.pending || meta.agentCreated
2253
+ const hasMetadata = meta.tweetContext || meta.articleContext || meta.pending
1394
2254
  || (Array.isArray(meta.tags) && meta.tags.length > 0);
1395
2255
  if (isDocEmpty(parsed.document) && !hasMetadata) {
1396
2256
  unlinkSync(fullPath);
@@ -1540,6 +2400,13 @@ export function removeDocTag(filename, tag) {
1540
2400
  /**
1541
2401
  * Save a browser doc-update to a specific file on disk.
1542
2402
  * Used when the browser sends a doc-update for a non-active document (race condition guard).
2403
+ *
2404
+ * Disk gets canonical (pending stripped by serialization). Any pending attrs
2405
+ * carried on `doc` (transferred from disk) are persisted to the overlay
2406
+ * sidecar in the same pass. Without the symmetric overlay save, pending
2407
+ * content would vanish silently between the strip-during-serialize and the
2408
+ * disk write.
2409
+ * adr: adr/pending-overlay-model.md
1543
2410
  */
1544
2411
  export function saveDocToFile(filename, doc) {
1545
2412
  const targetPath = resolveDocPath(filename);
@@ -1563,6 +2430,11 @@ export function saveDocToFile(filename, doc) {
1563
2430
  markdown = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
1564
2431
  }
1565
2432
  atomicWriteFileSync(targetPath, markdown);
2433
+ const docId = (parsed.metadata && typeof parsed.metadata.docId === 'string') ? parsed.metadata.docId : '';
2434
+ if (docId) {
2435
+ const overlay = extractOverlay(doc);
2436
+ saveOverlay(docId, overlay);
2437
+ }
1566
2438
  }
1567
2439
  catch { /* best-effort */ }
1568
2440
  }
@@ -1597,9 +2469,15 @@ export function setAutoAcceptOnFile(filename, enabled) {
1597
2469
  }
1598
2470
  /**
1599
2471
  * Strip pending attrs from a specific file on disk (not the active document).
1600
- * Optionally clears agentCreated metadata (on accept).
2472
+ *
2473
+ * The `_legacyClearAgentCreated` parameter is preserved for callsite-signature
2474
+ * stability but no longer does anything meaningful. Stub status is in-memory
2475
+ * only — there is no `agentCreated` field to clear on disk. The on-load
2476
+ * legacy-strip handles any residual occurrences from pre-architectural-fix
2477
+ * files.
2478
+ * adr: adr/agent-stub-model.md
1601
2479
  */
1602
- export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
2480
+ export function stripPendingAttrsFromFile(filename, _legacyClearAgentCreated) {
1603
2481
  const targetPath = resolveDocPath(filename);
1604
2482
  if (!existsSync(targetPath))
1605
2483
  return;
@@ -1621,9 +2499,9 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
1621
2499
  }
1622
2500
  }
1623
2501
  strip(parsed.document.content);
1624
- if (clearAgentCreated && parsed.metadata.agentCreated) {
1625
- delete parsed.metadata.agentCreated;
1626
- }
2502
+ // Belt-and-suspenders: strip any legacy on-disk agentCreated (e.g. an
2503
+ // old file that hasn't been re-saved since the migration).
2504
+ stripLegacyAgentCreated(parsed.metadata);
1627
2505
  let markdown;
1628
2506
  if (isExternalDoc(targetPath)) {
1629
2507
  const body = tiptapToBody(parsed.document);
@@ -1635,6 +2513,12 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
1635
2513
  markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
1636
2514
  }
1637
2515
  atomicWriteFileSync(targetPath, markdown);
2516
+ // Pending was just cleared on disk; the sidecar overlay must go too,
2517
+ // otherwise the next load would re-apply stale pending entries.
2518
+ // adr: adr/pending-overlay-model.md
2519
+ const docId = (parsed.metadata && typeof parsed.metadata.docId === 'string') ? parsed.metadata.docId : '';
2520
+ if (docId)
2521
+ deleteOverlay(docId);
1638
2522
  removePendingCacheEntry(filename);
1639
2523
  }
1640
2524
  catch { /* best-effort */ }
@@ -1658,10 +2542,25 @@ export function countPending(nodes) {
1658
2542
  return count;
1659
2543
  }
1660
2544
  /** Write a mutated doc back to disk and update the pending cache. */
2545
+ /** Write a mutated doc back to disk and update the pending cache.
2546
+ *
2547
+ * Disk gets canonical (pending stripped by `tiptapToMarkdown`). The
2548
+ * overlay sidecar gets the extracted pending entries — without this,
2549
+ * any pending content on `doc` vanishes between strip and disk write.
2550
+ * This mirrors writeToDisk's active-doc path; the foundation commit
2551
+ * established the contract there but missed this non-active-doc
2552
+ * callsite. Without symmetric overlay save, populate_document /
2553
+ * write_to_pad on a non-active doc silently dropped pending content.
2554
+ * adr: adr/pending-overlay-model.md */
1661
2555
  function flushDocToFile(filename, doc, title, metadata) {
1662
2556
  const targetPath = resolveDocPath(filename);
1663
2557
  const markdown = tiptapToMarkdown(doc, title, metadata);
1664
2558
  atomicWriteFileSync(targetPath, markdown);
2559
+ const docId = (metadata && typeof metadata.docId === 'string') ? metadata.docId : '';
2560
+ if (docId) {
2561
+ const overlay = extractOverlay(doc);
2562
+ saveOverlay(docId, overlay);
2563
+ }
1665
2564
  setPendingCacheEntry(filename, countPending(doc.content));
1666
2565
  }
1667
2566
  export function populateDocumentFile(filename, doc) {
@@ -1762,8 +2661,12 @@ export function applyTextEditsToFile(filename, nodeId, edits) {
1762
2661
  return { success: false, error: `Node ${nodeId} not found` };
1763
2662
  const originalNode = found.parent[found.index];
1764
2663
  const result = applyTextEditsToNode(originalNode, edits);
1765
- if (!result)
1766
- return { success: false, error: 'No edits matched' };
2664
+ if (!result) {
2665
+ const nodeText = extractText(originalNode.content || []);
2666
+ const truncated = nodeText.length > 240 ? nodeText.slice(0, 240) + '…' : nodeText;
2667
+ const searched = edits.map((e) => JSON.stringify(e.find)).join(', ');
2668
+ return { success: false, error: `No edits matched in node ${nodeId}. Searched: ${searched}. Node text starts: ${JSON.stringify(truncated)}` };
2669
+ }
1767
2670
  const autoAccept = isAutoAcceptActive(filename, metadata);
1768
2671
  // pendingTextEdits is the fine-grained inline-edit decoration — skip in autoAccept
1769
2672
  // since the change commits directly.