openwriter 0.26.0 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,22 +10,23 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { z } from 'zod';
12
12
  import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync } from './helpers.js';
13
- import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, setSortProposalOnFile, clearSortRequestOnFile, } from './state.js';
13
+ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, getIsTemp, extractText, countPending, addDocTag, removeDocTag, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, setSortProposalOnFile, clearSortRequestOnFile, } from './state.js';
14
14
  import { tiptapToBlocks } from './node-blocks.js';
15
15
  import { outline, peek, searchInDoc, truncateRead } from './peek-outline.js';
16
16
  import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
17
- import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions, listPendingSorts, sortFooter, buildSortInstructions } from './documents.js';
17
+ import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions, listPendingSorts, sortFooter, buildSortInstructions, stagePendingTitle } from './documents.js';
18
18
  import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBacklinksCache } from './backlinks.js';
19
19
  import { logger, generateRequestId, withRequestId } from './logger.js';
20
- import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged, broadcastActivityEvent } from './ws.js';
20
+ import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastPendingMetadataChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged, broadcastActivityEvent } from './ws.js';
21
21
  import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces, findWorkspacesContainingDoc, collectFilesInWorkspace } from './workspaces.js';
22
22
  import { findDocNode } from './workspace-tree.js';
23
23
  import { importGoogleDoc } from './gdoc-import.js';
24
- import { toCompactFormat, compactNodes, parseMarkdownContent, mergeParagraphsToHardBreaks } from './compact.js';
24
+ import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
25
25
  import matter from 'gray-matter';
26
26
  import { getUpdateInfo } from './update-check.js';
27
27
  import { listVersions, forceSnapshot, getVersionContent } from './versions.js';
28
- import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
28
+ import { tiptapToMarkdown } from './markdown.js';
29
+ import { loadDocFromDisk } from './pending-overlay.js';
29
30
  import { getComments, getCommentCount, getGlobalCommentSummary, resolveComments } from './comments.js';
30
31
  import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
31
32
  /** Map a content type string to its frontmatter metadata object. */
@@ -41,24 +42,6 @@ function resolveTypeMeta(type, url) {
41
42
  default: return undefined;
42
43
  }
43
44
  }
44
- /** Check if a document is in tweet compose mode (has tweetContext metadata). */
45
- function isTweetDoc(filename) {
46
- if (!filename || filename === getActiveFilename()) {
47
- return !!getMetadata()?.tweetContext;
48
- }
49
- const targetPath = resolveDocPath(filename);
50
- const cached = getCachedDocument(targetPath);
51
- if (cached)
52
- return !!cached.metadata?.tweetContext;
53
- try {
54
- const raw = readFileSync(targetPath, 'utf-8');
55
- const { data } = matter(raw);
56
- return !!data?.tweetContext;
57
- }
58
- catch {
59
- return false;
60
- }
61
- }
62
45
  /** Resolve a docId to a full document target. Fast path for active doc (zero I/O). */
63
46
  function resolveDocTarget(docId) {
64
47
  const filename = resolveDocId(docId);
@@ -96,24 +79,27 @@ function resolveDocTarget(docId) {
96
79
  lastModified: cached.lastModified,
97
80
  };
98
81
  }
99
- // Read from disk
82
+ // Read from disk — load the MERGED view (canonical body + sidecar overlay).
83
+ // The bare markdownToTiptap returns canonical-only; without applying the
84
+ // sidecar, a doc that has pending content (typical after populate_document
85
+ // or write_to_pad on a non-active doc) would surface here with 0 words and
86
+ // 0 pending — silently dropping the user's just-written content. The
87
+ // overlay-aware loader closes that asymmetry. adr: adr/pending-overlay-model.md
100
88
  if (!existsSync(filePath))
101
89
  throw new Error(`Document file not found: ${filename}`);
102
- const raw = readFileSync(filePath, 'utf-8');
103
- const parsed = markdownToTiptap(raw);
104
- const meta = parsed.metadata || {};
105
- const resolvedDocId = meta.docId || docId;
106
- const text = extractText(parsed.document.content);
90
+ const loaded = loadDocFromDisk(filename);
91
+ const resolvedDocId = loaded.docId || docId;
92
+ const text = extractText(loaded.document.content);
107
93
  return {
108
94
  filename,
109
95
  filePath,
110
96
  docId: resolvedDocId,
111
97
  isActive: false,
112
- document: parsed.document,
113
- title: parsed.title,
114
- metadata: parsed.metadata || {},
98
+ document: loaded.document,
99
+ title: loaded.title,
100
+ metadata: loaded.metadata || {},
115
101
  wordCount: text.trim() ? text.trim().split(/\s+/).length : 0,
116
- pendingCount: countPending(parsed.document.content),
102
+ pendingCount: countPending(loaded.document.content),
117
103
  lastModified: statSync(filePath).mtime,
118
104
  };
119
105
  }
@@ -302,18 +288,19 @@ export const TOOL_REGISTRY = [
302
288
  },
303
289
  handler: async ({ changes, docId }) => {
304
290
  const filename = resolveDocId(docId);
305
- const tweetMode = isTweetDoc(filename);
306
291
  const processed = changes.map((change) => {
307
292
  const resolved = { ...change };
308
293
  if (typeof resolved.content === 'string') {
309
- let nodes = parseMarkdownContent(resolved.content);
310
- if (tweetMode)
311
- nodes = mergeParagraphsToHardBreaks(nodes);
312
- resolved.content = nodes;
313
- }
314
- else if (tweetMode && Array.isArray(resolved.content)) {
315
- resolved.content = mergeParagraphsToHardBreaks(resolved.content);
294
+ resolved.content = parseMarkdownContent(resolved.content);
316
295
  }
296
+ // Tweet docs used to collapse multi-paragraph content into a single
297
+ // paragraph with hardBreaks (mergeParagraphsToHardBreaks). That made
298
+ // every multi-paragraph write land as ONE pending-insert decoration
299
+ // for review, which destroys per-paragraph accept/reject. Each
300
+ // paragraph the agent writes is a distinct review unit; preserve
301
+ // that structure. Thread separation (multiple tweets in a thread)
302
+ // is signaled explicitly by horizontalRule nodes from the agent,
303
+ // not implied by paragraph count.
317
304
  return resolved;
318
305
  });
319
306
  // Auto-clean: if doc has only a single empty paragraph and first change is
@@ -566,6 +553,14 @@ export const TOOL_REGISTRY = [
566
553
  broadcastDocumentsChanged();
567
554
  broadcastWorkspacesChanged();
568
555
  broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename(), getMetadata());
556
+ // Right-rail Activity: one entry per agent-created doc. adr: adr/right-rail.md
557
+ broadcastActivityEvent({
558
+ kind: 'doc-created',
559
+ headline: `Created ${result.title || 'Untitled'}`,
560
+ detail: content_type && content_type !== 'document' ? content_type : undefined,
561
+ docId: newDocId,
562
+ filename: result.filename,
563
+ });
569
564
  return {
570
565
  content: [{
571
566
  type: 'text',
@@ -592,6 +587,14 @@ export const TOOL_REGISTRY = [
592
587
  spinnerKey = result.filename;
593
588
  broadcastWritingStarted(title || 'Untitled', wsTarget, spinnerKey, result.filename, result.docId);
594
589
  broadcastDocumentsChanged();
590
+ // Right-rail Activity: one entry per agent-created doc. adr: adr/right-rail.md
591
+ broadcastActivityEvent({
592
+ kind: 'doc-created',
593
+ headline: `Created ${result.title || 'Untitled'}`,
594
+ detail: content_type && content_type !== 'document' ? content_type : undefined,
595
+ docId: result.docId,
596
+ filename: result.filename,
597
+ });
595
598
  return {
596
599
  content: [{
597
600
  type: 'text',
@@ -618,14 +621,15 @@ export const TOOL_REGISTRY = [
618
621
  try {
619
622
  let doc;
620
623
  if (typeof content === 'string') {
621
- let nodes = parseMarkdownContent(content);
622
- if (isTweetDoc(filename))
623
- nodes = mergeParagraphsToHardBreaks(nodes);
624
- doc = { type: 'doc', content: nodes };
624
+ // Don't collapse multi-paragraph content to a single hardBreak-
625
+ // separated paragraph for tweet docs. Each paragraph is a
626
+ // distinct review unit (its own pending-insert decoration); the
627
+ // user accepts/rejects per paragraph. Explicit thread structure
628
+ // (multiple tweets in a thread) comes from horizontalRule nodes
629
+ // in the agent's input, not from paragraph count.
630
+ doc = { type: 'doc', content: parseMarkdownContent(content) };
625
631
  }
626
632
  else if (content?.type === 'doc' && Array.isArray(content.content)) {
627
- if (isTweetDoc(filename))
628
- content.content = mergeParagraphsToHardBreaks(content.content);
629
633
  doc = content;
630
634
  }
631
635
  else {
@@ -656,15 +660,18 @@ export const TOOL_REGISTRY = [
656
660
  if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
657
661
  markAllNodesAsPending(doc, 'insert');
658
662
  }
659
- // Bug #1 fix (v0.20.0): preserve the stub's trailing canonical paragraph(s).
660
- // updateDocument(doc) overwrites state.canonical wholesale without this
661
- // merge, the create_document populate_document sequence loses the stub's
662
- // auto-generated trailing paragraph from canonical. When the browser later
663
- // accepts the inserts and sends a doc-update with its TipTap-rendered tree
664
- // (which also has a trailing empty paragraph, but a different ID), the
665
- // save-time matcher classifies the stub's original trailing as deleted →
666
- // graveyard, while the freshly added inserts have no previousNodes match.
667
- // Cascading state corruption observed in live test 2026-05-22.
663
+ // Preserve any pre-existing real content from canonical that the
664
+ // incoming populate doesn't already cover, so a re-populate doesn't
665
+ // clobber prior content. updateDocument(doc) overwrites
666
+ // state.canonical wholesale; without this preserve step, anything
667
+ // not in `doc.content` after this point disappears.
668
+ //
669
+ // Empty paragraphs are explicitly NOT preserved. createDocumentFile
670
+ // mints a trailing empty paragraph as a TipTap "doc must have at
671
+ // least one node" stub, and TipTap itself maintains a trailing
672
+ // empty paragraph for cursor-landing. Preserving those during
673
+ // populate leaves phantom empty paragraphs accumulating at the
674
+ // end of the doc. adr: adr/pending-overlay-model.md
668
675
  const existingCanonical = getCanonical();
669
676
  if (existingCanonical?.content?.length) {
670
677
  const incomingIds = new Set(doc.content
@@ -672,7 +679,10 @@ export const TOOL_REGISTRY = [
672
679
  .filter((id) => typeof id === 'string'));
673
680
  const preserved = existingCanonical.content.filter((n) => {
674
681
  const id = n?.attrs?.id;
675
- return id && !incomingIds.has(id);
682
+ if (!id || incomingIds.has(id))
683
+ return false;
684
+ const isEmptyParagraph = n.type === 'paragraph' && (!n.content || n.content.length === 0);
685
+ return !isEmptyParagraph;
676
686
  });
677
687
  if (preserved.length > 0) {
678
688
  doc.content = [...doc.content, ...preserved];
@@ -793,6 +803,13 @@ export const TOOL_REGISTRY = [
793
803
  },
794
804
  handler: async ({ docId }) => {
795
805
  const filename = resolveDocId(docId);
806
+ // Capture title before the file is moved to trash — resolveDocTarget reads
807
+ // from disk and won't work post-delete.
808
+ let deletedTitle = filename.replace(/\.md$/, '');
809
+ try {
810
+ deletedTitle = resolveDocTarget(docId).title || deletedTitle;
811
+ }
812
+ catch { /* fallback to filename */ }
796
813
  const result = await deleteDocument(filename);
797
814
  removeDocFromAllWorkspaces(filename);
798
815
  if (result.switched && result.newDoc) {
@@ -800,6 +817,14 @@ export const TOOL_REGISTRY = [
800
817
  }
801
818
  broadcastDocumentsChanged();
802
819
  broadcastWorkspacesChanged();
820
+ // Right-rail Activity: one entry per agent-deleted doc. docId is recorded
821
+ // but the client treats doc-deleted entries as non-clickable. adr: adr/right-rail.md
822
+ broadcastActivityEvent({
823
+ kind: 'doc-deleted',
824
+ headline: `Deleted ${deletedTitle}`,
825
+ docId,
826
+ filename,
827
+ });
803
828
  let text = `Deleted "${filename}" (moved to trash)`;
804
829
  if (result.switched && result.newDoc) {
805
830
  text += `. Switched to "${result.newDoc.title}"`;
@@ -875,6 +900,20 @@ export const TOOL_REGISTRY = [
875
900
  const cleaned = {};
876
901
  for (const key of setKeys)
877
902
  cleaned[key] = updates[key];
903
+ // Title is gated through pending-overlay unless this is a temp file
904
+ // being titled for the first time (creation path — promoteTempFile
905
+ // must run synchronously so the file gets a real filename on disk).
906
+ // adr: adr/pending-overlay-model.md
907
+ let stagedTitle = null;
908
+ const isCreationTitleSet = cleaned.title && target.isActive && getIsTemp();
909
+ const wantsTitlePending = cleaned.title && !isCreationTitleSet;
910
+ if (wantsTitlePending) {
911
+ const staged = stagePendingTitle(docId, cleaned.title);
912
+ if (staged.from !== staged.to) {
913
+ stagedTitle = { from: staged.from, to: staged.to };
914
+ }
915
+ delete cleaned.title; // remove from the hot-write path
916
+ }
878
917
  if (target.isActive) {
879
918
  // Active doc: use in-memory path
880
919
  if (Object.keys(cleaned).length > 0)
@@ -885,6 +924,7 @@ export const TOOL_REGISTRY = [
885
924
  save();
886
925
  broadcastMetadataChanged(getMetadata());
887
926
  if (cleaned.title) {
927
+ // Reached only on temp-file creation titling — hot promote.
888
928
  const promoted = promoteTempFile(cleaned.title);
889
929
  broadcastTitleChanged(cleaned.title);
890
930
  broadcastDocumentsChanged();
@@ -903,14 +943,16 @@ export const TOOL_REGISTRY = [
903
943
  }
904
944
  for (const key of removed)
905
945
  delete meta[key];
906
- const newTitle = cleaned.title || meta.title || target.title;
946
+ // Title may have been stripped out for pending-staging; preserve the
947
+ // canonical (on-disk) title in the rewrite.
948
+ const newTitle = meta.title || target.title;
907
949
  const markdown = tiptapToMarkdown(target.document, newTitle, meta);
908
950
  atomicWriteFileSync(target.filePath, markdown);
909
951
  invalidateDocCache(target.filePath);
910
- if (cleaned.title) {
911
- updateDocumentTitle(target.filename, cleaned.title);
912
- broadcastDocumentsChanged();
913
- }
952
+ }
953
+ if (stagedTitle) {
954
+ broadcastPendingMetadataChanged(docId, { title: stagedTitle });
955
+ broadcastPendingDocsChanged();
914
956
  }
915
957
  const keys = Object.keys(cleaned);
916
958
  const parts = [];
@@ -986,14 +1028,6 @@ export const TOOL_REGISTRY = [
986
1028
  atomicWriteFileSync(target.filePath, markdown);
987
1029
  invalidateDocCache(target.filePath);
988
1030
  }
989
- // Right-rail Activity: one entry per enriched doc. adr: adr/right-rail.md
990
- broadcastActivityEvent({
991
- kind: 'enrichment',
992
- headline: `Enrichment stamped ${target.title || target.filename}`,
993
- detail: item.logline,
994
- docId: item.docId,
995
- filename: target.filename,
996
- });
997
1031
  results.push({ docId: item.docId, ok: true });
998
1032
  }
999
1033
  catch (err) {
@@ -1007,6 +1041,11 @@ export const TOOL_REGISTRY = [
1007
1041
  const summary = failCount === 0
1008
1042
  ? `Enriched ${okCount} doc${okCount === 1 ? '' : 's'}`
1009
1043
  : `Enriched ${okCount} doc${okCount === 1 ? '' : 's'}, ${failCount} failed`;
1044
+ // Right-rail Activity: single summary line for the whole batch. adr: adr/right-rail.md
1045
+ broadcastActivityEvent({
1046
+ kind: 'enrichment',
1047
+ headline: summary,
1048
+ });
1010
1049
  return { content: [{ type: 'text', text: `${summary}\n${JSON.stringify({ docs: results })}` }] };
1011
1050
  },
1012
1051
  },
@@ -1152,9 +1191,24 @@ export const TOOL_REGISTRY = [
1152
1191
  filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
1153
1192
  },
1154
1193
  handler: async ({ filename }) => {
1194
+ // Capture workspace title before delete so the activity entry can name it.
1195
+ let wsTitle = filename.replace(/\.json$/, '');
1196
+ try {
1197
+ wsTitle = getWorkspace(filename).title || wsTitle;
1198
+ }
1199
+ catch { /* fallback to filename */ }
1155
1200
  const result = await deleteWorkspace(filename);
1156
1201
  broadcastWorkspacesChanged();
1157
1202
  broadcastDocumentsChanged();
1203
+ // Right-rail Activity: single summary entry for the cascade. Per-doc
1204
+ // entries would flood the log for large workspaces; one entry naming
1205
+ // the workspace + doc count is the better default. adr: adr/right-rail.md
1206
+ const n = result.deletedFiles.length;
1207
+ broadcastActivityEvent({
1208
+ kind: 'doc-deleted',
1209
+ headline: `Deleted workspace ${wsTitle}`,
1210
+ detail: `${n} doc${n === 1 ? '' : 's'}`,
1211
+ });
1158
1212
  let text = `Deleted workspace "${filename}" and ${result.deletedFiles.length} files: ${result.deletedFiles.join(', ')}`;
1159
1213
  if (result.skippedExternal.length > 0) {
1160
1214
  text += `\nSkipped ${result.skippedExternal.length} external files (not owned by OpenWriter): ${result.skippedExternal.join(', ')}`;
@@ -1405,13 +1459,17 @@ export const TOOL_REGISTRY = [
1405
1459
  if (type === 'document') {
1406
1460
  if (!docId)
1407
1461
  return { content: [{ type: 'text', text: 'Error: docId is required for document renames' }] };
1408
- const resolvedFilename = resolveDocId(docId);
1409
- updateDocumentTitle(resolvedFilename, newName);
1410
- broadcastDocumentsChanged();
1411
- if (resolvedFilename === getActiveFilename()) {
1412
- broadcastTitleChanged(newName);
1462
+ // Agent-initiated rename: stage as pending. The user accepts/rejects
1463
+ // via the title-bar inline diff in the browser. The .md file on disk
1464
+ // is NOT modified until accept. adr: adr/pending-overlay-model.md
1465
+ const staged = stagePendingTitle(docId, newName);
1466
+ broadcastPendingMetadataChanged(docId, { title: { from: staged.from, to: staged.to } });
1467
+ // Sidebar / docs list shows a pending indicator for this doc.
1468
+ broadcastPendingDocsChanged();
1469
+ if (staged.from === newName) {
1470
+ return { content: [{ type: 'text', text: `Document [${docId}] already titled "${newName}" — no change staged.` }] };
1413
1471
  }
1414
- return { content: [{ type: 'text', text: `Renamed document [${docId}] to "${newName}"` }] };
1472
+ return { content: [{ type: 'text', text: `Staged title rename for [${docId}]: "${staged.from}" "${newName}". User will accept or reject in the editor.` }] };
1415
1473
  }
1416
1474
  return { content: [{ type: 'text', text: `Error: unknown type "${type}"` }] };
1417
1475
  },
@@ -2069,6 +2127,30 @@ export const TOOL_REGISTRY = [
2069
2127
  /** Live MCP server instance — used to register plugin tools dynamically. */
2070
2128
  let mcpServerInstance = null;
2071
2129
  /** Convert a JSON Schema properties object to a Zod shape for MCP tool registration. */
2130
+ /**
2131
+ * Some MCP clients (notably Claude Code's tool-call serialization for
2132
+ * nested object/array parameters) send JSON-encoded strings instead of
2133
+ * the actual structured value. Wrap object/array schemas with a
2134
+ * preprocess step that opportunistically parses string input as JSON
2135
+ * before validation.
2136
+ */
2137
+ function jsonCoerce(inner) {
2138
+ return z.preprocess((val) => {
2139
+ if (typeof val !== 'string')
2140
+ return val;
2141
+ const trimmed = val.trim();
2142
+ if (!trimmed)
2143
+ return val;
2144
+ if (!(trimmed.startsWith('{') || trimmed.startsWith('[')))
2145
+ return val;
2146
+ try {
2147
+ return JSON.parse(trimmed);
2148
+ }
2149
+ catch {
2150
+ return val;
2151
+ }
2152
+ }, inner);
2153
+ }
2072
2154
  function jsonSchemaToZodShape(inputSchema) {
2073
2155
  const properties = (inputSchema.properties || {});
2074
2156
  const required = new Set((inputSchema.required || []));
@@ -2082,6 +2164,30 @@ function jsonSchemaToZodShape(inputSchema) {
2082
2164
  case 'boolean':
2083
2165
  field = z.boolean();
2084
2166
  break;
2167
+ case 'object':
2168
+ // Pass-through any record-like object. Inner key validation is
2169
+ // the handler's job — keep this loose so plugins can ship typed
2170
+ // shapes through MCP without per-key zod schemas.
2171
+ // Wrap in jsonCoerce so JSON-string-encoded objects are auto-parsed.
2172
+ field = jsonCoerce(z.record(z.string(), z.any()));
2173
+ break;
2174
+ case 'array':
2175
+ // Use items.type for inner validation when provided
2176
+ switch (prop.items?.type) {
2177
+ case 'string':
2178
+ field = jsonCoerce(z.array(z.string()));
2179
+ break;
2180
+ case 'number':
2181
+ field = jsonCoerce(z.array(z.number()));
2182
+ break;
2183
+ case 'boolean':
2184
+ field = jsonCoerce(z.array(z.boolean()));
2185
+ break;
2186
+ default:
2187
+ field = jsonCoerce(z.array(z.any()));
2188
+ break;
2189
+ }
2190
+ break;
2085
2191
  default:
2086
2192
  field = z.string();
2087
2193
  break;
@@ -2097,6 +2203,11 @@ function jsonSchemaToZodShape(inputSchema) {
2097
2203
  /** Register MCP tools from plugins. Dynamically adds to the live MCP session. */
2098
2204
  export function registerPluginTools(tools) {
2099
2205
  for (const tool of tools) {
2206
+ // Skip if already in TOOL_REGISTRY (e.g. called twice due to a bug)
2207
+ if (TOOL_REGISTRY.some((t) => t.name === tool.name)) {
2208
+ console.warn(`[PluginManager] registerPluginTools: skipping duplicate tool "${tool.name}"`);
2209
+ continue;
2210
+ }
2100
2211
  const zodShape = jsonSchemaToZodShape(tool.inputSchema);
2101
2212
  const toolDef = {
2102
2213
  name: tool.name,
@@ -2108,9 +2219,17 @@ export function registerPluginTools(tools) {
2108
2219
  },
2109
2220
  };
2110
2221
  TOOL_REGISTRY.push(toolDef);
2111
- // Register on live MCP server so existing sessions see it immediately
2222
+ // Register on live MCP server so existing sessions see it immediately.
2223
+ // Guard against "already registered" throws — can happen when two plugins
2224
+ // ship a tool with the same name (e.g. an old stale dist vs a new dist).
2112
2225
  if (mcpServerInstance) {
2113
- mcpServerInstance.tool(tool.name, tool.description, zodShape, toolDef.handler);
2226
+ const registered = mcpServerInstance._registeredTools;
2227
+ if (registered && registered[tool.name]) {
2228
+ console.warn(`[PluginManager] registerPluginTools: skipping MCP-duplicate tool "${tool.name}"`);
2229
+ }
2230
+ else {
2231
+ mcpServerInstance.tool(tool.name, tool.description, zodShape, toolDef.handler);
2232
+ }
2114
2233
  }
2115
2234
  }
2116
2235
  // Notify connected clients that the tool list changed
@@ -2126,7 +2245,25 @@ export function removePluginTools(names) {
2126
2245
  TOOL_REGISTRY.splice(i, 1);
2127
2246
  }
2128
2247
  }
2248
+ // Also remove from the live MCP server's internal registry so re-enable
2249
+ // doesn't hit "Tool already registered". The SDK doesn't expose a public
2250
+ // unregister API — use the registered tool handle's .remove() if available,
2251
+ // otherwise fall back to deleting from _registeredTools directly.
2129
2252
  if (mcpServerInstance) {
2253
+ const registered = mcpServerInstance._registeredTools;
2254
+ if (registered) {
2255
+ for (const name of nameSet) {
2256
+ const handle = registered[name];
2257
+ if (handle) {
2258
+ if (typeof handle.remove === 'function') {
2259
+ handle.remove();
2260
+ }
2261
+ else {
2262
+ delete registered[name];
2263
+ }
2264
+ }
2265
+ }
2266
+ }
2130
2267
  mcpServerInstance.server.sendToolListChanged().catch(() => { });
2131
2268
  }
2132
2269
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pending metadata — sibling of pending-overlay.ts for frontmatter-level
3
+ * staging. Where the overlay holds proposed BLOCK changes (insert / rewrite
4
+ * / delete on TipTap nodes), this module holds proposed METADATA changes —
5
+ * for now just the document title.
6
+ *
7
+ * Architectural model:
8
+ * - Pending metadata lives in the SAME sidecar file as the block overlay
9
+ * (_pending/{docId}.json), under a top-level `metadata:` key. The
10
+ * pending-overlay module owns the `entries:` key; this module owns the
11
+ * `metadata:` key. Each preserves the other's slot when it writes.
12
+ * - The canonical disk .md file's frontmatter is NEVER modified by a
13
+ * pending stage — the title only changes on disk after the user accepts.
14
+ * Reject discards the proposal; nothing on disk moves.
15
+ * - The active document's pending metadata is mirrored into state.ts as
16
+ * `state.pendingMetadata` so WS broadcasts and getters expose it without
17
+ * a disk read on every poll.
18
+ *
19
+ * Why a separate module from pending-overlay.ts:
20
+ * - The overlay model is keyed by nodeId and assumes a TipTap tree. Title
21
+ * is not in the tree — it's a YAML frontmatter field. Forcing it into a
22
+ * fake "node" would corrupt the overlay invariants (nodeId stability,
23
+ * splitMergedDoc tree-walk, applyOverlayPure idempotency).
24
+ * - Future metadata-staging (tags, status, custom frontmatter fields) lands
25
+ * here without further churn to the overlay code path.
26
+ *
27
+ * Scope (phase 1): document title only. tag_doc / untag_doc / set_metadata
28
+ * for non-title fields / mark_enriched still write hot. Workspace and
29
+ * container renames also still write hot — they live in workspace manifests,
30
+ * not per-doc sidecars, and need a separate decision.
31
+ *
32
+ * adr: adr/pending-overlay-model.md
33
+ */
34
+ import { readSidecarRaw, writeSidecarRaw } from './pending-overlay.js';
35
+ import { logger } from './logger.js';
36
+ // ============================================================================
37
+ // SIDECAR I/O
38
+ // ============================================================================
39
+ /** Read the pending-metadata slot from the per-doc sidecar. Returns null if
40
+ * the sidecar is missing or has no metadata slot. */
41
+ export function loadPendingMetadata(docId) {
42
+ if (!docId)
43
+ return null;
44
+ const raw = readSidecarRaw(docId);
45
+ if (!raw?.metadata || Object.keys(raw.metadata).length === 0)
46
+ return null;
47
+ return raw.metadata;
48
+ }
49
+ /** Persist the pending-metadata slot. Preserves the sidecar's existing
50
+ * `entries:` slot. Passing null (or an empty object) clears the metadata
51
+ * and may delete the sidecar entirely if entries are also empty. */
52
+ export function savePendingMetadata(docId, meta) {
53
+ if (!docId)
54
+ return;
55
+ const raw = readSidecarRaw(docId);
56
+ const entries = Array.isArray(raw?.entries) ? raw.entries : [];
57
+ const cleaned = meta && Object.keys(meta).length > 0 ? meta : null;
58
+ writeSidecarRaw(docId, { entries, metadata: cleaned || undefined });
59
+ if (cleaned?.title) {
60
+ logger.info('overlay', 'meta-stage', `docId=${docId} field=title from="${cleaned.title.from}" to="${cleaned.title.to}"`);
61
+ }
62
+ else {
63
+ logger.info('overlay', 'meta-clear', `docId=${docId}`);
64
+ }
65
+ }