openwriter 0.25.0 → 0.27.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.
@@ -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
  }
@@ -229,13 +215,26 @@ let firstTruncationShown = false;
229
215
  export const TOOL_REGISTRY = [
230
216
  {
231
217
  name: 'read_pad',
232
- description: `Read a document by docId. Returns compact tagged-line format with [type:id] per node. Capped at ~${READ_PAD_MAX_WORDS} words per call for longer docs you get the opening slice plus a lastNodeId hint. Use peek_doc({ around: lastNodeId, after: N }) to continue linearly, outline_doc to jump by heading, or search_docs({ query, docId }) to find a specific passage. For docs under the cap, returns the full body as before.`,
218
+ description: `Read a document by docId. Returns compact tagged-line format with [type:id] per node. Default: first ~${READ_PAD_MAX_WORDS} words. Three knobs for longer docs:\n• \`slice: { from, to }\` read a percentile range (floats in [0,1]). \`{from:0.5, to:1}\` = back half, \`{from:0.25, to:0.75}\` = middle 50%, \`{from:0.0, to:0.1}\` then \`{from:0.1, to:0.2}\` … = sequential 10% chunks. Snaps to top-level node boundaries; subject to the word cap unless force is set.\n• \`force: true\` — bypass the cap, return the full requested region (whole doc or whole slice). Use for full-doc audits/rewrites where you've accepted the cost.\n• When the cap kicks in, the response includes \`lastNodeId\` + continuation hints to \`peek_doc\` / \`outline_doc\` / \`search_docs\` / another \`read_pad\` slice.`,
233
219
  schema: {
234
220
  docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
235
- },
236
- handler: async ({ docId }) => {
221
+ // Schemas use z.preprocess to coerce string inputs — some MCP clients
222
+ // serialize complex / boolean params as JSON strings rather than native
223
+ // types. Accepting both forms means agents work regardless of client.
224
+ slice: z.preprocess((v) => (typeof v === 'string' && v.trim().startsWith('{')) ? (() => { try {
225
+ return JSON.parse(v);
226
+ }
227
+ catch {
228
+ return v;
229
+ } })() : v, z.object({
230
+ from: z.number().min(0).max(1).describe('Start of the slice as a fraction of total word count (0 = beginning).'),
231
+ to: z.number().min(0).max(1).describe('End of the slice as a fraction of total word count (1 = end). Must be > from.'),
232
+ }).optional()).describe('Percentile range to read instead of the doc opening. Snaps to top-level node boundaries. Examples: { from: 0.5, to: 1 } = back half; { from: 0.25, to: 0.75 } = middle 50%.'),
233
+ force: z.preprocess((v) => (typeof v === 'string') ? v === 'true' : v, z.boolean().optional()).describe(`Bypass the ~${READ_PAD_MAX_WORDS}-word cap. Returns the full requested region in one call. Use for full-doc audits and rewrites where the cost is acknowledged.`),
234
+ },
235
+ handler: async ({ docId, slice, force }) => {
237
236
  const target = resolveDocTarget(docId);
238
- const trunc = truncateRead(target.document, READ_PAD_MAX_WORDS);
237
+ const trunc = truncateRead(target.document, { maxWords: READ_PAD_MAX_WORDS, slice, force });
239
238
  const compact = toCompactFormat(trunc.doc, target.title, trunc.returnedWords, target.pendingCount, target.docId, target.metadata);
240
239
  const localCount = getCommentCount(target.filename);
241
240
  const { totalComments: otherCount, docCount: otherDocs } = getGlobalCommentSummary(target.filename);
@@ -246,14 +245,29 @@ export const TOOL_REGISTRY = [
246
245
  hint += `\n[${otherCount} comment${otherCount !== 1 ? 's' : ''} on ${otherDocs} other document${otherDocs !== 1 ? 's' : ''}]`;
247
246
  if (hint)
248
247
  hint += '\n[call get_comments to review]';
249
- if (trunc.truncated) {
248
+ // Label forced / sliced reads so the response is self-describing.
249
+ if (trunc.forced) {
250
+ const region = trunc.slice
251
+ ? `forced slice ${(trunc.slice.from * 100).toFixed(0)}–${(trunc.slice.to * 100).toFixed(0)}%`
252
+ : 'forced full read';
253
+ hint += `\n[${region.toUpperCase()} — ${trunc.returnedWords.toLocaleString()} words returned of ${trunc.totalWords.toLocaleString()} total. Cap bypassed.]`;
254
+ }
255
+ else if (trunc.slice && !trunc.truncated) {
256
+ hint += `\n[SLICE ${(trunc.slice.from * 100).toFixed(0)}–${(trunc.slice.to * 100).toFixed(0)}% — ${trunc.returnedWords.toLocaleString()} of ${trunc.totalWords.toLocaleString()} words. Adjacent slices: read_pad({ docId: "${target.docId}", slice: { from: ${trunc.slice.to.toFixed(2)}, to: ${Math.min(1, trunc.slice.to + (trunc.slice.to - trunc.slice.from)).toFixed(2)} } }) for the next region.]`;
257
+ }
258
+ else if (trunc.truncated) {
250
259
  if (!firstTruncationShown) {
251
- hint += `\n\n[FYI: read_pad caps at ~${READ_PAD_MAX_WORDS} words to keep cost predictable. This notice shows once per session — future truncations skip the explanation.]`;
260
+ hint += `\n\n[FYI: read_pad caps at ~${READ_PAD_MAX_WORDS} words to keep cost predictable. Override per-call with force:true, or read a specific region with slice:{from,to}. This notice shows once per session.]`;
252
261
  firstTruncationShown = true;
253
262
  }
254
263
  const anchor = trunc.lastNodeId ?? '<no-id>';
255
- hint += `\n[TRUNCATED — ${trunc.totalWords.toLocaleString()} words total, ${trunc.returnedWords.toLocaleString()} returned, ${trunc.remaining.toLocaleString()} remain. Continue with:`
256
- + `\n peek_doc({ docId: "${target.docId}", target: { around: "${anchor}", after: 100 } }) — linear continuation`
264
+ const sliceLabel = trunc.slice
265
+ ? ` of slice ${(trunc.slice.from * 100).toFixed(0)}–${(trunc.slice.to * 100).toFixed(0)}%`
266
+ : '';
267
+ hint += `\n[TRUNCATED — ${trunc.totalWords.toLocaleString()} words total, ${trunc.returnedWords.toLocaleString()} returned${sliceLabel}, ${trunc.remaining.toLocaleString()} remain. Continue with:`
268
+ + `\n read_pad({ docId: "${target.docId}", slice: { from: 0.5, to: 1 } }) — read the back half (or any percentile range)`
269
+ + `\n read_pad({ docId: "${target.docId}", force: true }) — entire body, cap bypassed`
270
+ + `\n peek_doc({ docId: "${target.docId}", target: { around: "${anchor}", after: 100 } }) — linear continuation by node`
257
271
  + `\n outline_doc({ docId: "${target.docId}" }) — heading skeleton to jump to a section`
258
272
  + `\n search_docs({ query: "...", docId: "${target.docId}" }) — find a specific passage]`;
259
273
  }
@@ -274,18 +288,19 @@ export const TOOL_REGISTRY = [
274
288
  },
275
289
  handler: async ({ changes, docId }) => {
276
290
  const filename = resolveDocId(docId);
277
- const tweetMode = isTweetDoc(filename);
278
291
  const processed = changes.map((change) => {
279
292
  const resolved = { ...change };
280
293
  if (typeof resolved.content === 'string') {
281
- let nodes = parseMarkdownContent(resolved.content);
282
- if (tweetMode)
283
- nodes = mergeParagraphsToHardBreaks(nodes);
284
- resolved.content = nodes;
285
- }
286
- else if (tweetMode && Array.isArray(resolved.content)) {
287
- resolved.content = mergeParagraphsToHardBreaks(resolved.content);
294
+ resolved.content = parseMarkdownContent(resolved.content);
288
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.
289
304
  return resolved;
290
305
  });
291
306
  // Auto-clean: if doc has only a single empty paragraph and first change is
@@ -538,6 +553,14 @@ export const TOOL_REGISTRY = [
538
553
  broadcastDocumentsChanged();
539
554
  broadcastWorkspacesChanged();
540
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
+ });
541
564
  return {
542
565
  content: [{
543
566
  type: 'text',
@@ -564,6 +587,14 @@ export const TOOL_REGISTRY = [
564
587
  spinnerKey = result.filename;
565
588
  broadcastWritingStarted(title || 'Untitled', wsTarget, spinnerKey, result.filename, result.docId);
566
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
+ });
567
598
  return {
568
599
  content: [{
569
600
  type: 'text',
@@ -590,14 +621,15 @@ export const TOOL_REGISTRY = [
590
621
  try {
591
622
  let doc;
592
623
  if (typeof content === 'string') {
593
- let nodes = parseMarkdownContent(content);
594
- if (isTweetDoc(filename))
595
- nodes = mergeParagraphsToHardBreaks(nodes);
596
- 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) };
597
631
  }
598
632
  else if (content?.type === 'doc' && Array.isArray(content.content)) {
599
- if (isTweetDoc(filename))
600
- content.content = mergeParagraphsToHardBreaks(content.content);
601
633
  doc = content;
602
634
  }
603
635
  else {
@@ -628,15 +660,18 @@ export const TOOL_REGISTRY = [
628
660
  if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
629
661
  markAllNodesAsPending(doc, 'insert');
630
662
  }
631
- // Bug #1 fix (v0.20.0): preserve the stub's trailing canonical paragraph(s).
632
- // updateDocument(doc) overwrites state.canonical wholesale without this
633
- // merge, the create_document populate_document sequence loses the stub's
634
- // auto-generated trailing paragraph from canonical. When the browser later
635
- // accepts the inserts and sends a doc-update with its TipTap-rendered tree
636
- // (which also has a trailing empty paragraph, but a different ID), the
637
- // save-time matcher classifies the stub's original trailing as deleted →
638
- // graveyard, while the freshly added inserts have no previousNodes match.
639
- // 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
640
675
  const existingCanonical = getCanonical();
641
676
  if (existingCanonical?.content?.length) {
642
677
  const incomingIds = new Set(doc.content
@@ -644,7 +679,10 @@ export const TOOL_REGISTRY = [
644
679
  .filter((id) => typeof id === 'string'));
645
680
  const preserved = existingCanonical.content.filter((n) => {
646
681
  const id = n?.attrs?.id;
647
- 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;
648
686
  });
649
687
  if (preserved.length > 0) {
650
688
  doc.content = [...doc.content, ...preserved];
@@ -765,6 +803,13 @@ export const TOOL_REGISTRY = [
765
803
  },
766
804
  handler: async ({ docId }) => {
767
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 */ }
768
813
  const result = await deleteDocument(filename);
769
814
  removeDocFromAllWorkspaces(filename);
770
815
  if (result.switched && result.newDoc) {
@@ -772,6 +817,14 @@ export const TOOL_REGISTRY = [
772
817
  }
773
818
  broadcastDocumentsChanged();
774
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
+ });
775
828
  let text = `Deleted "${filename}" (moved to trash)`;
776
829
  if (result.switched && result.newDoc) {
777
830
  text += `. Switched to "${result.newDoc.title}"`;
@@ -847,6 +900,20 @@ export const TOOL_REGISTRY = [
847
900
  const cleaned = {};
848
901
  for (const key of setKeys)
849
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
+ }
850
917
  if (target.isActive) {
851
918
  // Active doc: use in-memory path
852
919
  if (Object.keys(cleaned).length > 0)
@@ -857,6 +924,7 @@ export const TOOL_REGISTRY = [
857
924
  save();
858
925
  broadcastMetadataChanged(getMetadata());
859
926
  if (cleaned.title) {
927
+ // Reached only on temp-file creation titling — hot promote.
860
928
  const promoted = promoteTempFile(cleaned.title);
861
929
  broadcastTitleChanged(cleaned.title);
862
930
  broadcastDocumentsChanged();
@@ -875,14 +943,16 @@ export const TOOL_REGISTRY = [
875
943
  }
876
944
  for (const key of removed)
877
945
  delete meta[key];
878
- 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;
879
949
  const markdown = tiptapToMarkdown(target.document, newTitle, meta);
880
950
  atomicWriteFileSync(target.filePath, markdown);
881
951
  invalidateDocCache(target.filePath);
882
- if (cleaned.title) {
883
- updateDocumentTitle(target.filename, cleaned.title);
884
- broadcastDocumentsChanged();
885
- }
952
+ }
953
+ if (stagedTitle) {
954
+ broadcastPendingMetadataChanged(docId, { title: stagedTitle });
955
+ broadcastPendingDocsChanged();
886
956
  }
887
957
  const keys = Object.keys(cleaned);
888
958
  const parts = [];
@@ -958,14 +1028,6 @@ export const TOOL_REGISTRY = [
958
1028
  atomicWriteFileSync(target.filePath, markdown);
959
1029
  invalidateDocCache(target.filePath);
960
1030
  }
961
- // Right-rail Activity: one entry per enriched doc. adr: adr/right-rail.md
962
- broadcastActivityEvent({
963
- kind: 'enrichment',
964
- headline: `Enrichment stamped ${target.title || target.filename}`,
965
- detail: item.logline,
966
- docId: item.docId,
967
- filename: target.filename,
968
- });
969
1031
  results.push({ docId: item.docId, ok: true });
970
1032
  }
971
1033
  catch (err) {
@@ -979,6 +1041,11 @@ export const TOOL_REGISTRY = [
979
1041
  const summary = failCount === 0
980
1042
  ? `Enriched ${okCount} doc${okCount === 1 ? '' : 's'}`
981
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
+ });
982
1049
  return { content: [{ type: 'text', text: `${summary}\n${JSON.stringify({ docs: results })}` }] };
983
1050
  },
984
1051
  },
@@ -1124,9 +1191,24 @@ export const TOOL_REGISTRY = [
1124
1191
  filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
1125
1192
  },
1126
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 */ }
1127
1200
  const result = await deleteWorkspace(filename);
1128
1201
  broadcastWorkspacesChanged();
1129
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
+ });
1130
1212
  let text = `Deleted workspace "${filename}" and ${result.deletedFiles.length} files: ${result.deletedFiles.join(', ')}`;
1131
1213
  if (result.skippedExternal.length > 0) {
1132
1214
  text += `\nSkipped ${result.skippedExternal.length} external files (not owned by OpenWriter): ${result.skippedExternal.join(', ')}`;
@@ -1377,13 +1459,17 @@ export const TOOL_REGISTRY = [
1377
1459
  if (type === 'document') {
1378
1460
  if (!docId)
1379
1461
  return { content: [{ type: 'text', text: 'Error: docId is required for document renames' }] };
1380
- const resolvedFilename = resolveDocId(docId);
1381
- updateDocumentTitle(resolvedFilename, newName);
1382
- broadcastDocumentsChanged();
1383
- if (resolvedFilename === getActiveFilename()) {
1384
- 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.` }] };
1385
1471
  }
1386
- 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.` }] };
1387
1473
  }
1388
1474
  return { content: [{ type: 'text', text: `Error: unknown type "${type}"` }] };
1389
1475
  },
@@ -2041,6 +2127,30 @@ export const TOOL_REGISTRY = [
2041
2127
  /** Live MCP server instance — used to register plugin tools dynamically. */
2042
2128
  let mcpServerInstance = null;
2043
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
+ }
2044
2154
  function jsonSchemaToZodShape(inputSchema) {
2045
2155
  const properties = (inputSchema.properties || {});
2046
2156
  const required = new Set((inputSchema.required || []));
@@ -2054,6 +2164,30 @@ function jsonSchemaToZodShape(inputSchema) {
2054
2164
  case 'boolean':
2055
2165
  field = z.boolean();
2056
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;
2057
2191
  default:
2058
2192
  field = z.string();
2059
2193
  break;
@@ -2069,6 +2203,11 @@ function jsonSchemaToZodShape(inputSchema) {
2069
2203
  /** Register MCP tools from plugins. Dynamically adds to the live MCP session. */
2070
2204
  export function registerPluginTools(tools) {
2071
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
+ }
2072
2211
  const zodShape = jsonSchemaToZodShape(tool.inputSchema);
2073
2212
  const toolDef = {
2074
2213
  name: tool.name,
@@ -2080,9 +2219,17 @@ export function registerPluginTools(tools) {
2080
2219
  },
2081
2220
  };
2082
2221
  TOOL_REGISTRY.push(toolDef);
2083
- // 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).
2084
2225
  if (mcpServerInstance) {
2085
- 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
+ }
2086
2233
  }
2087
2234
  }
2088
2235
  // Notify connected clients that the tool list changed
@@ -2098,7 +2245,25 @@ export function removePluginTools(names) {
2098
2245
  TOOL_REGISTRY.splice(i, 1);
2099
2246
  }
2100
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.
2101
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
+ }
2102
2267
  mcpServerInstance.server.sendToolListChanged().catch(() => { });
2103
2268
  }
2104
2269
  }