openwriter 0.13.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,19 +3,54 @@
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
- import { tiptapToMarkdown, tiptapToBody, markdownToTiptap } from './markdown.js';
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
+ import { isAutoAcceptInheritedForDoc } from './workspaces.js';
15
+ import { matchNodes } from './node-matcher.js';
16
+ import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
17
+ import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
18
+ /** Read the persisted identity graph (nodes + graveyard) from a file's
19
+ * frontmatter. This is the matcher's previousNodes baseline at save time —
20
+ * the disk is the source of truth, not a parallel in-memory cache. Returns
21
+ * empty arrays for a brand-new file or unreadable frontmatter. */
22
+ function readPersistedIdentity(filePath) {
23
+ if (!filePath || !existsSync(filePath))
24
+ return { previousNodes: [], graveyard: [] };
25
+ try {
26
+ const raw = readFileSync(filePath, 'utf-8');
27
+ const { data } = matter(raw);
28
+ return {
29
+ previousNodes: normalizeNodeEntries(data.nodes),
30
+ graveyard: normalizeNodeEntries(data.graveyard),
31
+ };
32
+ }
33
+ catch {
34
+ return { previousNodes: [], graveyard: [] };
35
+ }
36
+ }
37
+ /** Defensive parse of frontmatter node entries — drops any malformed rows.
38
+ * Mirrors the same-named helper in markdown-parse.ts so save and load apply
39
+ * identical validation. */
40
+ function normalizeNodeEntries(raw) {
41
+ if (!Array.isArray(raw))
42
+ return [];
43
+ return raw
44
+ .filter((entry) => entry && typeof entry === 'object' && entry.id && entry.fp)
45
+ .map((entry) => ({ id: String(entry.id), fingerprint: entry.fp }));
46
+ }
14
47
  const DEFAULT_DOC = {
15
48
  type: 'doc',
16
49
  content: [{ type: 'paragraph', content: [] }],
17
50
  };
18
51
  let state = {
52
+ canonical: DEFAULT_DOC,
53
+ overlay: new Map(),
19
54
  document: DEFAULT_DOC,
20
55
  title: 'Untitled',
21
56
  metadata: { title: 'Untitled' },
@@ -24,37 +59,396 @@ let state = {
24
59
  lastModified: new Date(),
25
60
  docId: '',
26
61
  originalFrontmatter: null,
62
+ loadedMtime: 0,
27
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
+ }
28
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
+ }
29
349
  // ============================================================================
30
350
  // EXTERNAL DOCUMENT REGISTRY
31
351
  // ============================================================================
32
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 */
33
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
+ }
34
409
  function persistExternalDocs() {
35
410
  try {
36
411
  atomicWriteFileSync(getExternalDocsFile(), JSON.stringify([...externalDocs]));
37
412
  }
38
413
  catch { /* best-effort */ }
39
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 */
40
422
  function loadExternalDocs() {
41
423
  try {
42
- if (existsSync(getExternalDocsFile())) {
43
- const paths = JSON.parse(readFileSync(getExternalDocsFile(), 'utf-8'));
44
- for (const p of paths) {
45
- if (existsSync(p))
46
- 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;
47
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();
48
442
  }
49
443
  }
50
444
  catch { /* corrupt file — start fresh */ }
51
445
  }
52
446
  export function registerExternalDoc(fullPath) {
53
- externalDocs.add(fullPath);
447
+ externalDocs.add(canonicalizePath(fullPath));
54
448
  persistExternalDocs();
55
449
  }
56
450
  export function unregisterExternalDoc(fullPath) {
57
- externalDocs.delete(fullPath);
451
+ externalDocs.delete(canonicalizePath(fullPath));
58
452
  persistExternalDocs();
59
453
  }
60
454
  export function getExternalDocs() {
@@ -78,6 +472,20 @@ function isDocEmpty(doc) {
78
472
  export function getDocument() {
79
473
  return state.document;
80
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
+ }
81
489
  export function getTitle() {
82
490
  return state.title;
83
491
  }
@@ -377,7 +785,10 @@ export function updateDocument(doc) {
377
785
  if (serverHadPending) {
378
786
  transferPendingAttrs(state.document, doc);
379
787
  }
380
- 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);
381
792
  state.lastModified = new Date();
382
793
  // Validate: if server had pending changes, verify they survived the transfer
383
794
  if (serverHadPending && !hasPendingChanges()) {
@@ -447,15 +858,58 @@ function transferPendingAttrs(source, target) {
447
858
  // ============================================================================
448
859
  // AGENT WRITE LOCK
449
860
  // ============================================================================
861
+ // adr: adr/agent-lock-per-doc.md
450
862
  const AGENT_LOCK_MS = 3000; // Block browser doc-updates for 3s after agent write
451
- let lastAgentWriteTime = 0;
452
- /** Set the agent write lock (called after agent changes). */
453
- export function setAgentLock() {
454
- lastAgentWriteTime = Date.now();
455
- }
456
- /** Check if the agent write lock is active. */
457
- export function isAgentLocked() {
458
- 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;
459
913
  }
460
914
  // ---- Document version counter: prevents stale browser doc-updates ----
461
915
  let docVersion = 0;
@@ -476,9 +930,16 @@ export function resetDocVersion() {
476
930
  docVersion = 0;
477
931
  }
478
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
479
940
  let saveTimer = null;
480
941
  const SAVE_DEBOUNCE_MS = 500;
481
- function debouncedSave() {
942
+ export function debouncedSave() {
482
943
  if (saveTimer)
483
944
  clearTimeout(saveTimer);
484
945
  saveTimer = setTimeout(() => {
@@ -498,7 +959,7 @@ export function applyChanges(changes) {
498
959
  const processed = applyChangesToDocument(changes);
499
960
  // Bump version + lock browser doc-updates to prevent stale state overwrite
500
961
  const version = bumpDocVersion();
501
- setAgentLock();
962
+ setAgentLockActive();
502
963
  // Broadcast processed changes (with server-assigned IDs + version) to browser clients
503
964
  for (const listener of listeners) {
504
965
  listener(processed, version);
@@ -522,6 +983,10 @@ export function applyChanges(changes) {
522
983
  }
523
984
  return { count: processed.length, lastNodeId };
524
985
  }
986
+ export function onIdRewrites(listener) {
987
+ idRewriteListeners.add(listener);
988
+ return () => idRewriteListeners.delete(listener);
989
+ }
525
990
  export function onChanges(listener) {
526
991
  listeners.add(listener);
527
992
  return () => listeners.delete(listener);
@@ -530,6 +995,24 @@ export function onChanges(listener) {
530
995
  // SERVER-SIDE DOCUMENT MUTATIONS
531
996
  // ============================================================================
532
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']);
533
1016
  /**
534
1017
  * Find a node by ID in any document tree.
535
1018
  * topLevel is used to resolve the "end" sentinel.
@@ -545,6 +1028,11 @@ function findNode(nodes, id, topLevel) {
545
1028
  if (nodes[i].attrs?.id === id) {
546
1029
  return { parent: nodes, index: i };
547
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;
548
1036
  if (nodes[i].content && Array.isArray(nodes[i].content)) {
549
1037
  const result = findNode(nodes[i].content, id, topLevel);
550
1038
  if (result)
@@ -577,43 +1065,109 @@ function applyChangesToDoc(doc, changes, autoAccept = false) {
577
1065
  const found = findNode(doc.content, change.nodeId, doc.content);
578
1066
  if (!found)
579
1067
  continue;
580
- const contentArray = Array.isArray(change.content) ? change.content : [change.content];
1068
+ let contentArray = Array.isArray(change.content) ? change.content : [change.content];
581
1069
  const originalNode = structuredClone(found.parent[found.index]);
1070
+ // Preserve target node type when plain text would otherwise demote it.
1071
+ // Markdown-it parses plain text as a paragraph, so rewriting a heading or
1072
+ // list item with plain prose silently changes the type. Two adaptations:
1073
+ // - Block wrappers (listItem, blockquote) wrap the parsed paragraph as
1074
+ // their child, keeping the wrapper's type and attrs.
1075
+ // - Inline-content leaves (heading, codeBlock) take the paragraph's
1076
+ // inline text and host it inside the original type, preserving level
1077
+ // and other attrs.
1078
+ // Explicit markdown (e.g. "## Foo", "- bar") still wins because the
1079
+ // parser produces a matching node type before we get here.
1080
+ const targetType = originalNode.type;
1081
+ const parsedType = contentArray[0]?.type;
1082
+ const BLOCK_WRAPPERS = new Set(['listItem', 'blockquote']);
1083
+ const INLINE_LEAVES = new Set(['heading', 'codeBlock']);
1084
+ let isWrappedRewrite = false;
1085
+ if (parsedType === 'paragraph' && targetType !== 'paragraph') {
1086
+ if (BLOCK_WRAPPERS.has(targetType)) {
1087
+ contentArray = [{
1088
+ type: targetType,
1089
+ attrs: { ...originalNode.attrs },
1090
+ content: contentArray,
1091
+ }];
1092
+ isWrappedRewrite = true;
1093
+ }
1094
+ else if (INLINE_LEAVES.has(targetType)) {
1095
+ // Standard stamping handles the leaf case — heading/codeBlock are
1096
+ // themselves the decoration target, so no special branch needed below.
1097
+ contentArray = [{
1098
+ type: targetType,
1099
+ attrs: { ...originalNode.attrs },
1100
+ content: contentArray[0].content || [],
1101
+ }];
1102
+ }
1103
+ }
582
1104
  // Empty node rewrite → treat as insert (green, not blue)
583
1105
  const originalText = extractText(originalNode.content || []);
584
1106
  const isEmptyNode = !originalText.trim();
585
1107
  // Only store original on first rewrite (preserve baseline for reject)
586
1108
  const existingOriginal = found.parent[found.index].attrs?.pendingOriginalContent;
587
1109
  // Detect partial change: if only a sub-range of the node text changed,
588
- // attach selection range attrs so the frontend decorates only that part
1110
+ // attach selection range attrs so the frontend decorates only that part.
1111
+ // For wrapped rewrites (listItem), compare paragraph content against the
1112
+ // listItem's inner paragraph so offsets align with what the user sees.
589
1113
  let partialRange = null;
590
1114
  if (!isEmptyNode && contentArray.length === 1 && !autoAccept) {
591
- // Use true original for partial range when a prior pending rewrite exists,
592
- // so offsets align with pendingOriginalContent
593
- const baseContent = existingOriginal?.content || originalNode.content || [];
594
- partialRange = computePartialRange(baseContent, contentArray[0].content || []);
1115
+ const baseContent = isWrappedRewrite
1116
+ ? (existingOriginal?.content?.[0]?.content || originalNode.content?.[0]?.content || [])
1117
+ : (existingOriginal?.content || originalNode.content || []);
1118
+ const newContent = isWrappedRewrite
1119
+ ? (contentArray[0].content?.[0]?.content || [])
1120
+ : (contentArray[0].content || []);
1121
+ partialRange = computePartialRange(baseContent, newContent);
1122
+ }
1123
+ // Build first node. For wrapped rewrites, pendingStatus and related attrs
1124
+ // belong on the inner leaf (paragraph) so the decoration renderer — which
1125
+ // keys off LEAF_BLOCK_TYPES — picks them up. The wrapper keeps the original
1126
+ // node's id/attrs so subsequent calls can still target it.
1127
+ let firstNode;
1128
+ if (isWrappedRewrite && !autoAccept) {
1129
+ const innerLeaf = contentArray[0].content?.[0] || { type: 'paragraph', content: [] };
1130
+ const innerWithPending = {
1131
+ ...innerLeaf,
1132
+ attrs: {
1133
+ ...innerLeaf.attrs,
1134
+ id: innerLeaf.attrs?.id || generateNodeId(),
1135
+ pendingStatus: isEmptyNode ? 'insert' : 'rewrite',
1136
+ ...(isEmptyNode ? {} : { pendingOriginalContent: existingOriginal || originalNode }),
1137
+ ...(partialRange ? {
1138
+ pendingSelectionFrom: partialRange.selectionFrom,
1139
+ pendingSelectionTo: partialRange.selectionTo,
1140
+ pendingOriginalFrom: partialRange.originalFrom,
1141
+ pendingOriginalTo: partialRange.originalTo,
1142
+ } : {}),
1143
+ },
1144
+ };
1145
+ firstNode = {
1146
+ type: 'listItem',
1147
+ attrs: { ...contentArray[0].attrs, id: change.nodeId },
1148
+ content: [innerWithPending, ...contentArray[0].content.slice(1)],
1149
+ };
1150
+ }
1151
+ else {
1152
+ firstNode = {
1153
+ ...contentArray[0],
1154
+ attrs: autoAccept ? {
1155
+ ...contentArray[0].attrs,
1156
+ id: change.nodeId,
1157
+ } : {
1158
+ ...contentArray[0].attrs,
1159
+ id: change.nodeId,
1160
+ pendingStatus: isEmptyNode ? 'insert' : 'rewrite',
1161
+ ...(isEmptyNode ? {} : { pendingOriginalContent: existingOriginal || originalNode }),
1162
+ ...(partialRange ? {
1163
+ pendingSelectionFrom: partialRange.selectionFrom,
1164
+ pendingSelectionTo: partialRange.selectionTo,
1165
+ pendingOriginalFrom: partialRange.originalFrom,
1166
+ pendingOriginalTo: partialRange.originalTo,
1167
+ } : {}),
1168
+ },
1169
+ };
595
1170
  }
596
- // First node replaces the target (rewrite or insert if empty).
597
- // In autoAccept mode, omit all pendingStatus/pendingOriginalContent attrs
598
- // so the change commits cleanly with no review surface.
599
- const firstNode = {
600
- ...contentArray[0],
601
- attrs: autoAccept ? {
602
- ...contentArray[0].attrs,
603
- id: change.nodeId,
604
- } : {
605
- ...contentArray[0].attrs,
606
- id: change.nodeId,
607
- pendingStatus: isEmptyNode ? 'insert' : 'rewrite',
608
- ...(isEmptyNode ? {} : { pendingOriginalContent: existingOriginal || originalNode }),
609
- ...(partialRange ? {
610
- pendingSelectionFrom: partialRange.selectionFrom,
611
- pendingSelectionTo: partialRange.selectionTo,
612
- pendingOriginalFrom: partialRange.originalFrom,
613
- pendingOriginalTo: partialRange.originalTo,
614
- } : {}),
615
- },
616
- };
617
1171
  // Additional nodes get inserted after — as pending inserts in normal mode,
618
1172
  // as plain blocks in autoAccept mode.
619
1173
  const extraNodes = contentArray.slice(1).map((node) => ({
@@ -741,24 +1295,29 @@ function applyChangesToDoc(doc, changes, autoAccept = false) {
741
1295
  export function isAutoAcceptActive(filename, metadata) {
742
1296
  if (metadata?.autoAccept === true)
743
1297
  return true;
1298
+ if (metadata?.autoAccept === false)
1299
+ return false; // explicit doc-level override of inheritance
744
1300
  if (!filename)
745
1301
  return false;
746
- // Lazy import to avoid circular dep between state.ts and workspaces.ts
747
- try {
748
- // eslint-disable-next-line @typescript-eslint/no-var-requires
749
- const { isAutoAcceptInheritedForDoc } = require('./workspaces.js');
750
- return isAutoAcceptInheritedForDoc(filename);
751
- }
752
- catch {
753
- return false;
754
- }
1302
+ return isAutoAcceptInheritedForDoc(filename);
755
1303
  }
756
- /** 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. */
757
1312
  function applyChangesToDocument(changes) {
758
1313
  const autoAccept = isAutoAcceptActive(activeDocFilename(), state.metadata);
759
1314
  const processed = applyChangesToDoc(state.document, changes, autoAccept);
760
1315
  if (processed.length > 0) {
761
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);
762
1321
  }
763
1322
  return processed;
764
1323
  }
@@ -772,8 +1331,16 @@ export function applyTextEdits(nodeId, edits) {
772
1331
  return { success: false, error: `Node ${nodeId} not found` };
773
1332
  const originalNode = found.parent[found.index];
774
1333
  const result = applyTextEditsToNode(originalNode, edits);
775
- if (!result)
776
- 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
+ }
777
1344
  // Inline edit decoration only matters when there's a review surface — skip in autoAccept.
778
1345
  if (!isAutoAcceptActive(activeDocFilename(), state.metadata)) {
779
1346
  result.node.attrs = {
@@ -789,16 +1356,49 @@ export function applyTextEdits(nodeId, edits) {
789
1356
  }]);
790
1357
  return { success: true };
791
1358
  }
792
- /** Set the active document state. Used by documents.ts for multi-doc operations. */
1359
+ /** Set the active document state. Used by documents.ts for multi-doc operations.
1360
+ *
1361
+ * Identity tracking is NOT cached on PadState — the save-time matcher reads
1362
+ * previousNodes + graveyard directly from disk frontmatter every write
1363
+ * (Option B in adr/node-identity-matcher.md). Markdown is the source of
1364
+ * truth; memory is an ephemeral working copy. */
793
1365
  export function setActiveDocument(doc, title, filePath, isTemp, lastModified, metadata, originalFrontmatter) {
794
- 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);
795
1370
  state.title = title;
796
1371
  state.metadata = metadata || { title };
797
- 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) : '';
798
1383
  state.isTemp = isTemp;
799
1384
  state.lastModified = lastModified || new Date();
800
1385
  state.docId = ensureDocId(state.metadata);
801
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();
802
1402
  }
803
1403
  // ============================================================================
804
1404
  // PENDING DOCUMENT CACHE (avoids disk scans on every broadcast)
@@ -868,8 +1468,17 @@ function populatePendingCache() {
868
1468
  catch { /* skip unreadable files */ }
869
1469
  }
870
1470
  }
871
- const docCache = new Map(); // key = filePath
872
- /** Cache the active document's full state, keyed by filePath. Call after save(). */
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();
1477
+ /** Cache the active document's full state, keyed by filePath. Call after save().
1478
+ *
1479
+ * Identity (nodes + graveyard) is NOT cached — the save-time matcher reads
1480
+ * it from disk frontmatter each write, so the cache stays a pure content
1481
+ * snapshot. */
873
1482
  export function cacheActiveDocument() {
874
1483
  if (!state.filePath)
875
1484
  return;
@@ -878,8 +1487,14 @@ export function cacheActiveDocument() {
878
1487
  fileMtime = statSync(state.filePath).mtimeMs;
879
1488
  }
880
1489
  catch { /* file may not exist yet */ }
881
- docCache.set(state.filePath, {
882
- 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),
883
1498
  metadata: structuredClone(state.metadata),
884
1499
  title: state.title,
885
1500
  isTemp: state.isTemp,
@@ -891,27 +1506,28 @@ export function cacheActiveDocument() {
891
1506
  }
892
1507
  /** Get a cached document if the file hasn't been modified externally. Returns null on miss or stale. */
893
1508
  export function getCachedDocument(filePath) {
894
- const cached = docCache.get(filePath);
1509
+ const key = canonicalizePath(filePath);
1510
+ const cached = docCache.get(key);
895
1511
  if (!cached)
896
1512
  return null;
897
1513
  try {
898
1514
  const currentMtime = statSync(filePath).mtimeMs;
899
1515
  if (currentMtime !== cached.fileMtime) {
900
1516
  // File changed on disk — invalidate cache
901
- docCache.delete(filePath);
1517
+ docCache.delete(key);
902
1518
  return null;
903
1519
  }
904
1520
  }
905
1521
  catch {
906
1522
  // File doesn't exist or can't be read — invalidate
907
- docCache.delete(filePath);
1523
+ docCache.delete(key);
908
1524
  return null;
909
1525
  }
910
1526
  return cached;
911
1527
  }
912
1528
  /** Remove a specific file from the document cache. */
913
1529
  export function invalidateDocCache(filePath) {
914
- docCache.delete(filePath);
1530
+ docCache.delete(canonicalizePath(filePath));
915
1531
  }
916
1532
  /** Update the cache entry for a file after writing changes (without cloning the active state). */
917
1533
  export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
@@ -920,10 +1536,18 @@ export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId)
920
1536
  fileMtime = statSync(filePath).mtimeMs;
921
1537
  }
922
1538
  catch { /* best-effort */ }
1539
+ const key = canonicalizePath(filePath);
923
1540
  // Preserve originalFrontmatter from existing cache entry (if any)
924
- const existing = docCache.get(filePath);
925
- docCache.set(filePath, {
926
- 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),
927
1551
  metadata: structuredClone(metadata),
928
1552
  title,
929
1553
  isTemp,
@@ -935,10 +1559,15 @@ export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId)
935
1559
  }
936
1560
  /** Reset all in-memory caches. Called on profile switch. */
937
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();
938
1565
  docCache.clear();
939
1566
  pendingDocCache.clear();
940
1567
  externalDocs.clear();
941
1568
  state = {
1569
+ canonical: DEFAULT_DOC,
1570
+ overlay: new Map(),
942
1571
  document: DEFAULT_DOC,
943
1572
  title: 'Untitled',
944
1573
  metadata: { title: 'Untitled' },
@@ -947,6 +1576,7 @@ export function clearAllCaches() {
947
1576
  lastModified: new Date(),
948
1577
  docId: '',
949
1578
  originalFrontmatter: null,
1579
+ loadedMtime: 0,
950
1580
  };
951
1581
  }
952
1582
  // ============================================================================
@@ -968,23 +1598,225 @@ export function hasPendingChanges(doc) {
968
1598
  }
969
1599
  return scan(target.content);
970
1600
  }
971
- /** 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). */
972
1604
  export function stripPendingAttrs() {
973
- function strip(nodes) {
974
- 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))
975
1728
  return;
976
1729
  for (const node of nodes) {
977
- if (node.attrs?.pendingStatus) {
978
- delete node.attrs.pendingStatus;
979
- delete node.attrs.pendingOriginalContent;
980
- delete node.attrs.pendingTextEdits;
1730
+ const oldId = node?.attrs?.id;
1731
+ if (oldId && translation.has(oldId)) {
1732
+ node.attrs.id = translation.get(oldId);
981
1733
  }
982
- if (node.content)
983
- strip(node.content);
1734
+ if (node?.content)
1735
+ walk(node.content);
984
1736
  }
985
1737
  }
986
- strip(state.document.content);
987
- 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 || []);
988
1820
  }
989
1821
  /**
990
1822
  * Mark leaf block nodes as pending within a node array.
@@ -1045,25 +1877,145 @@ function writeToDisk() {
1045
1877
  }
1046
1878
  catch { /* best-effort */ }
1047
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);
1048
1891
  let markdown;
1049
1892
  if (isExternalDoc(state.filePath)) {
1050
- // 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).
1051
1896
  const body = tiptapToBody(state.document).replace(/(?:\s*<!-- -->\s*)+$/, '\n');
1052
1897
  markdown = state.originalFrontmatter
1053
1898
  ? `---\n${state.originalFrontmatter}\n---\n\n${body}`
1054
1899
  : body;
1055
1900
  }
1056
1901
  else {
1057
- markdown = tiptapToMarkdown(state.document, state.title, state.metadata);
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)
1908
+ //
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.
1914
+ //
1915
+ // adr: adr/node-identity-matcher.md · adr: adr/pending-overlay-model.md
1916
+ const canonical = cloneWithPendingReverted(state.document);
1917
+ const { previousNodes, graveyard } = readPersistedIdentity(state.filePath);
1918
+ let nextGraveyard = graveyard;
1919
+ const idTranslation = new Map();
1920
+ if (previousNodes.length > 0) {
1921
+ const newBlocks = tiptapToBlocks(canonical);
1922
+ const beforeIds = newBlocks.map((b) => b.id);
1923
+ const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
1924
+ const pinnedByPosition = new Map();
1925
+ for (const p of matchResult.pinned)
1926
+ pinnedByPosition.set(p.position, p.id);
1927
+ applyIdsToTiptap(canonical, pinnedByPosition);
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()));
1966
+ }
1967
+ // Pass graveyard through metadata so the serializer can emit it in frontmatter.
1968
+ const metaWithGraveyard = nextGraveyard.length > 0
1969
+ ? { ...state.metadata, graveyard: nextGraveyard.map((g) => ({ id: g.id, fp: g.fingerprint })) }
1970
+ : state.metadata;
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);
1974
+ markdown = result.markdown;
1058
1975
  }
1059
1976
  if (existsSync(state.filePath)) {
1060
1977
  // Skip write if content is identical (prevents phantom git changes on doc switch)
1061
1978
  try {
1062
1979
  const existing = readFileSync(state.filePath, 'utf-8');
1063
- 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 */ }
1064
1987
  return;
1988
+ }
1065
1989
  }
1066
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
+ }
1067
2019
  // Safety: don't overwrite a file with substantial content using near-empty content.
1068
2020
  // Prevents save cascades where empty editor state destroys chapter files.
1069
2021
  // Exception: docs with pending changes may legitimately be smaller (agent replaced content).
@@ -1079,6 +2031,12 @@ function writeToDisk() {
1079
2031
  }
1080
2032
  }
1081
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 */ }
1082
2040
  // Best-effort version snapshot — never blocks saves
1083
2041
  try {
1084
2042
  snapshotIfNeeded(state.docId, state.filePath);
@@ -1098,14 +2056,17 @@ function writeToDisk() {
1098
2056
  }
1099
2057
  export function save() {
1100
2058
  if (!state.filePath) {
1101
- // 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
1102
2063
  ensureDataDir();
1103
2064
  if (state.title === 'Untitled') {
1104
- state.filePath = tempFilePath();
2065
+ state.filePath = canonicalizePath(tempFilePath());
1105
2066
  state.isTemp = true;
1106
2067
  }
1107
2068
  else {
1108
- state.filePath = filePathForTitle(state.title);
2069
+ state.filePath = canonicalizePath(filePathForTitle(state.title));
1109
2070
  state.isTemp = false;
1110
2071
  }
1111
2072
  }
@@ -1113,6 +2074,9 @@ export function save() {
1113
2074
  }
1114
2075
  export function load() {
1115
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();
1116
2080
  // Restore external document registry from disk
1117
2081
  loadExternalDocs();
1118
2082
  // Migrate any .sw.json files to .md
@@ -1138,19 +2102,38 @@ export function load() {
1138
2102
  // Skip empty temp files — prefer a real document
1139
2103
  if (isTemp && isDocEmpty(parsed.document))
1140
2104
  continue;
1141
- 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);
1142
2109
  state.title = parsed.title;
1143
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);
1144
2114
  state.lastModified = new Date(statSync(file.path).mtimeMs);
1145
- state.filePath = file.path;
2115
+ state.filePath = canonicalizePath(file.path);
1146
2116
  state.isTemp = isTemp;
2117
+ state.loadedMtime = statSync(file.path).mtimeMs;
1147
2118
  // Lazy docId migration: assign if missing, save to persist
1148
2119
  const hadDocId = !!state.metadata.docId;
1149
2120
  state.docId = ensureDocId(state.metadata);
1150
2121
  if (!hadDocId) {
1151
2122
  const md = tiptapToMarkdown(state.document, state.title, state.metadata);
1152
2123
  atomicWriteFileSync(state.filePath, md);
2124
+ try {
2125
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
2126
+ }
2127
+ catch { /* best-effort */ }
1153
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();
1154
2137
  break;
1155
2138
  }
1156
2139
  catch {
@@ -1160,15 +2143,18 @@ export function load() {
1160
2143
  }
1161
2144
  // If nothing loaded (all files were empty temps or corrupt), start fresh
1162
2145
  if (!state.filePath) {
1163
- state.filePath = tempFilePath();
2146
+ state.filePath = canonicalizePath(tempFilePath());
1164
2147
  state.isTemp = true;
1165
2148
  }
1166
2149
  // Populate pending doc cache from disk (single scan on startup)
1167
2150
  populatePendingCache();
1168
2151
  // Overlay active doc's in-memory state (may have unsaved pending changes)
1169
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();
1170
2156
  // Startup lock: block browser doc-updates briefly to prevent stale reconnect pushes
1171
- setAgentLock();
2157
+ setAgentLockGlobal();
1172
2158
  }
1173
2159
  /** Migrate legacy .sw.json files to .md format */
1174
2160
  function migrateSwJsonFiles() {
@@ -1258,9 +2244,13 @@ function cleanupEmptyTempFiles() {
1258
2244
  try {
1259
2245
  const raw = readFileSync(fullPath, 'utf-8');
1260
2246
  const parsed = markdownToTiptap(raw);
1261
- // 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.
1262
2252
  const meta = parsed.metadata || {};
1263
- const hasMetadata = meta.tweetContext || meta.articleContext || meta.pending || meta.agentCreated
2253
+ const hasMetadata = meta.tweetContext || meta.articleContext || meta.pending
1264
2254
  || (Array.isArray(meta.tags) && meta.tags.length > 0);
1265
2255
  if (isDocEmpty(parsed.document) && !hasMetadata) {
1266
2256
  unlinkSync(fullPath);
@@ -1410,6 +2400,13 @@ export function removeDocTag(filename, tag) {
1410
2400
  /**
1411
2401
  * Save a browser doc-update to a specific file on disk.
1412
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
1413
2410
  */
1414
2411
  export function saveDocToFile(filename, doc) {
1415
2412
  const targetPath = resolveDocPath(filename);
@@ -1433,6 +2430,11 @@ export function saveDocToFile(filename, doc) {
1433
2430
  markdown = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
1434
2431
  }
1435
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
+ }
1436
2438
  }
1437
2439
  catch { /* best-effort */ }
1438
2440
  }
@@ -1448,12 +2450,8 @@ export function setAutoAcceptOnFile(filename, enabled) {
1448
2450
  try {
1449
2451
  const raw = readFileSync(targetPath, 'utf-8');
1450
2452
  const parsed = markdownToTiptap(raw);
1451
- if (enabled) {
1452
- parsed.metadata.autoAccept = true;
1453
- }
1454
- else {
1455
- delete parsed.metadata.autoAccept;
1456
- }
2453
+ // Explicit false (not delete) so the user's "off" overrides any workspace inheritance.
2454
+ parsed.metadata.autoAccept = enabled;
1457
2455
  let markdown;
1458
2456
  if (isExternalDoc(targetPath)) {
1459
2457
  const body = tiptapToBody(parsed.document);
@@ -1471,9 +2469,15 @@ export function setAutoAcceptOnFile(filename, enabled) {
1471
2469
  }
1472
2470
  /**
1473
2471
  * Strip pending attrs from a specific file on disk (not the active document).
1474
- * 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
1475
2479
  */
1476
- export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
2480
+ export function stripPendingAttrsFromFile(filename, _legacyClearAgentCreated) {
1477
2481
  const targetPath = resolveDocPath(filename);
1478
2482
  if (!existsSync(targetPath))
1479
2483
  return;
@@ -1495,9 +2499,9 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
1495
2499
  }
1496
2500
  }
1497
2501
  strip(parsed.document.content);
1498
- if (clearAgentCreated && parsed.metadata.agentCreated) {
1499
- delete parsed.metadata.agentCreated;
1500
- }
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);
1501
2505
  let markdown;
1502
2506
  if (isExternalDoc(targetPath)) {
1503
2507
  const body = tiptapToBody(parsed.document);
@@ -1509,6 +2513,12 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
1509
2513
  markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
1510
2514
  }
1511
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);
1512
2522
  removePendingCacheEntry(filename);
1513
2523
  }
1514
2524
  catch { /* best-effort */ }
@@ -1532,10 +2542,25 @@ export function countPending(nodes) {
1532
2542
  return count;
1533
2543
  }
1534
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 */
1535
2555
  function flushDocToFile(filename, doc, title, metadata) {
1536
2556
  const targetPath = resolveDocPath(filename);
1537
2557
  const markdown = tiptapToMarkdown(doc, title, metadata);
1538
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
+ }
1539
2564
  setPendingCacheEntry(filename, countPending(doc.content));
1540
2565
  }
1541
2566
  export function populateDocumentFile(filename, doc) {
@@ -1636,8 +2661,12 @@ export function applyTextEditsToFile(filename, nodeId, edits) {
1636
2661
  return { success: false, error: `Node ${nodeId} not found` };
1637
2662
  const originalNode = found.parent[found.index];
1638
2663
  const result = applyTextEditsToNode(originalNode, edits);
1639
- if (!result)
1640
- 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
+ }
1641
2670
  const autoAccept = isAutoAcceptActive(filename, metadata);
1642
2671
  // pendingTextEdits is the fine-grained inline-edit decoration — skip in autoAccept
1643
2672
  // since the change commits directly.