openwriter 0.4.0 → 0.5.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,8 +10,8 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-DiDoklNt.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-CqeJ7cMy.css">
13
+ <script type="module" crossorigin src="/assets/index-BwT1KW6a.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-Be3gaGeo.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -9,9 +9,27 @@ import matter from 'gray-matter';
9
9
  import trash from 'trash';
10
10
  import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
11
11
  import { parseMarkdownContent } from './compact.js';
12
- import { getDocument, getTitle, getFilePath, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, } from './state.js';
13
- import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc } from './helpers.js';
12
+ import { getDocument, getTitle, getFilePath, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, } from './state.js';
13
+ import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
14
14
  import { ensureDocId } from './versions.js';
15
+ const DOC_ORDER_FILE = join(DATA_DIR, '_doc-order.json');
16
+ function readDocOrder() {
17
+ try {
18
+ if (!existsSync(DOC_ORDER_FILE))
19
+ return [];
20
+ return JSON.parse(readFileSync(DOC_ORDER_FILE, 'utf-8'));
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ function writeDocOrder(order) {
27
+ ensureDataDir();
28
+ writeFileSync(DOC_ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8');
29
+ }
30
+ export function reorderDocs(orderedFilenames) {
31
+ writeDocOrder(orderedFilenames);
32
+ }
15
33
  export function listDocuments() {
16
34
  ensureDataDir();
17
35
  const currentPath = getFilePath();
@@ -68,14 +86,32 @@ export function listDocuments() {
68
86
  }
69
87
  catch { /* skip unreadable external files */ }
70
88
  }
71
- // Most recently modified first new docs appear at top (matches spinner position)
72
- files.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
89
+ // Sort by persisted order; docs not in manifest prepend (newest first by mtime)
90
+ const order = readDocOrder();
91
+ if (order.length > 0) {
92
+ const orderIndex = new Map(order.map((f, i) => [f, i]));
93
+ files.sort((a, b) => {
94
+ const ai = orderIndex.get(a.filename) ?? -1;
95
+ const bi = orderIndex.get(b.filename) ?? -1;
96
+ // Both unknown → newest first by mtime
97
+ if (ai === -1 && bi === -1)
98
+ return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime();
99
+ // Unknown docs sort before known (prepend)
100
+ if (ai === -1)
101
+ return -1;
102
+ if (bi === -1)
103
+ return 1;
104
+ return ai - bi;
105
+ });
106
+ }
73
107
  return files;
74
108
  }
75
109
  export function switchDocument(filename) {
76
- // Cancel any pending debounced save, then save current doc immediately
110
+ // Cancel any pending debounced save, then save current doc immediately.
77
111
  cancelDebouncedSave();
78
112
  save();
113
+ // Cache current doc before switching (preserves node IDs)
114
+ cacheActiveDocument();
79
115
  // Read target from disk — markdownToTiptap rehydrates pending state
80
116
  const targetPath = resolveDocPath(filename);
81
117
  if (!existsSync(targetPath)) {
@@ -85,6 +121,12 @@ export function switchDocument(filename) {
85
121
  if (isExternalDoc(filename)) {
86
122
  registerExternalDoc(targetPath);
87
123
  }
124
+ // Check cache first — preserves stable node IDs across switches
125
+ const cached = getCachedDocument(targetPath);
126
+ if (cached) {
127
+ setActiveDocument(cached.document, cached.title, targetPath, cached.isTemp, cached.lastModified, cached.metadata);
128
+ return { document: getDocument(), title: getTitle(), filename };
129
+ }
88
130
  const raw = readFileSync(targetPath, 'utf-8');
89
131
  const parsed = markdownToTiptap(raw);
90
132
  const mtime = new Date(statSync(targetPath).mtimeMs);
@@ -98,6 +140,8 @@ export function createDocument(title, content, path) {
98
140
  // Cancel any pending debounced save, then save current doc immediately
99
141
  cancelDebouncedSave();
100
142
  save();
143
+ // Cache current doc before switching to new one
144
+ cacheActiveDocument();
101
145
  const docTitle = title || 'Untitled';
102
146
  let filePath;
103
147
  let isTemp;
@@ -116,7 +160,19 @@ export function createDocument(title, content, path) {
116
160
  }
117
161
  else {
118
162
  isTemp = !title;
119
- filePath = isTemp ? tempFilePath() : filePathForTitle(docTitle);
163
+ if (isTemp) {
164
+ filePath = tempFilePath();
165
+ }
166
+ else {
167
+ filePath = filePathForTitle(docTitle);
168
+ // Deduplicate: append counter if file already exists
169
+ if (existsSync(filePath)) {
170
+ let counter = 2;
171
+ while (existsSync(filePathForTitle(`${docTitle} ${counter}`)))
172
+ counter++;
173
+ filePath = filePathForTitle(`${docTitle} ${counter}`);
174
+ }
175
+ }
120
176
  filename = filePath.split(/[/\\]/).pop();
121
177
  }
122
178
  let newDoc;
@@ -138,12 +194,14 @@ export function createDocument(title, content, path) {
138
194
  // Write doc to disk
139
195
  const markdown = tiptapToMarkdown(newDoc, docTitle, metadata);
140
196
  ensureDataDir();
141
- writeFileSync(filePath, markdown, 'utf-8');
197
+ atomicWriteFileSync(filePath, markdown);
142
198
  return { document: getDocument(), title: getTitle(), filename };
143
199
  }
144
200
  export async function deleteDocument(filename) {
145
201
  ensureDataDir();
146
202
  const targetPath = resolveDocPath(filename);
203
+ // Invalidate cache for deleted doc
204
+ invalidateDocCache(targetPath);
147
205
  // Unregister if external
148
206
  if (isExternalDoc(filename)) {
149
207
  unregisterExternalDoc(targetPath);
@@ -176,6 +234,8 @@ export function reloadDocument() {
176
234
  if (!existsSync(filePath)) {
177
235
  throw new Error('Active document file not found on disk');
178
236
  }
237
+ // Force fresh parse — invalidate any cached version
238
+ invalidateDocCache(filePath);
179
239
  const filename = filePath.split(/[/\\]/).pop();
180
240
  const raw = readFileSync(filePath, 'utf-8');
181
241
  const parsed = markdownToTiptap(raw);
@@ -193,7 +253,7 @@ export function updateDocumentTitle(filename, newTitle) {
193
253
  const parsed = markdownToTiptap(raw);
194
254
  const metadata = { ...parsed.metadata, title: newTitle };
195
255
  const markdown = tiptapToMarkdown(parsed.document, newTitle, metadata);
196
- writeFileSync(filePath, markdown, 'utf-8');
256
+ atomicWriteFileSync(filePath, markdown);
197
257
  // Update state if this is the active document
198
258
  const baseName = filePath.split(/[/\\]/).pop() || '';
199
259
  if (getFilePath() === filePath) {
@@ -208,10 +268,19 @@ export function openFile(fullPath) {
208
268
  // Cancel any pending debounced save, then save current doc immediately
209
269
  cancelDebouncedSave();
210
270
  save();
271
+ // Cache current doc before switching
272
+ cacheActiveDocument();
211
273
  // Register as external if not in DATA_DIR
212
274
  if (isExternalDoc(fullPath)) {
213
275
  registerExternalDoc(fullPath);
214
276
  }
277
+ // Check cache first — preserves stable node IDs
278
+ const cached = getCachedDocument(fullPath);
279
+ if (cached) {
280
+ setActiveDocument(cached.document, cached.title, fullPath, cached.isTemp, cached.lastModified, cached.metadata);
281
+ const filename = isExternalDoc(fullPath) ? fullPath : (fullPath.split(/[/\\]/).pop() || '');
282
+ return { document: getDocument(), title: getTitle(), filename };
283
+ }
215
284
  const raw = readFileSync(fullPath, 'utf-8');
216
285
  const parsed = markdownToTiptap(raw);
217
286
  const mtime = new Date(statSync(fullPath).mtimeMs);
@@ -222,6 +291,34 @@ export function openFile(fullPath) {
222
291
  const filename = isExternalDoc(fullPath) ? fullPath : baseName;
223
292
  return { document: getDocument(), title: getTitle(), filename };
224
293
  }
294
+ export function duplicateDocument(filename) {
295
+ // Cancel any pending debounced save, then save current doc immediately
296
+ cancelDebouncedSave();
297
+ save();
298
+ const sourcePath = resolveDocPath(filename);
299
+ if (!existsSync(sourcePath)) {
300
+ throw new Error(`Document not found: ${filename}`);
301
+ }
302
+ const raw = readFileSync(sourcePath, 'utf-8');
303
+ const parsed = markdownToTiptap(raw);
304
+ // Generate deduplicated title
305
+ let newTitle = `${parsed.title} (Copy)`;
306
+ let filePath = filePathForTitle(newTitle);
307
+ if (existsSync(filePath)) {
308
+ let counter = 2;
309
+ while (existsSync(filePathForTitle(`${parsed.title} (Copy ${counter})`)))
310
+ counter++;
311
+ newTitle = `${parsed.title} (Copy ${counter})`;
312
+ filePath = filePathForTitle(newTitle);
313
+ }
314
+ const metadata = { ...parsed.metadata, title: newTitle, docId: generateNodeId() };
315
+ setActiveDocument(parsed.document, newTitle, filePath, false, undefined, metadata);
316
+ const markdown = tiptapToMarkdown(parsed.document, newTitle, metadata);
317
+ ensureDataDir();
318
+ atomicWriteFileSync(filePath, markdown);
319
+ const newFilename = filePath.split(/[/\\]/).pop();
320
+ return { document: getDocument(), title: getTitle(), filename: newFilename };
321
+ }
225
322
  export function getActiveFilename() {
226
323
  const filePath = getFilePath();
227
324
  // For external docs, return the full path as the identifier
@@ -6,7 +6,7 @@ import { execFile } from 'child_process';
6
6
  import { existsSync, writeFileSync } from 'fs';
7
7
  import { join } from 'path';
8
8
  import { DATA_DIR, readConfig, saveConfig } from './helpers.js';
9
- import { save } from './state.js';
9
+ import { save, cancelDebouncedSave } from './state.js';
10
10
  const GITIGNORE_CONTENT = `config.json\n.versions/\n`;
11
11
  const NETWORK_TIMEOUT = 30000;
12
12
  let currentSyncState = 'unconfigured';
@@ -242,7 +242,8 @@ export async function pushSync(onStatus) {
242
242
  lastError = undefined;
243
243
  onStatus({ state: 'syncing' });
244
244
  try {
245
- // Flush current document to disk first
245
+ // Flush current document to disk first (cancel debounce to ensure immediate write)
246
+ cancelDebouncedSave();
246
247
  save();
247
248
  ensureGitignore();
248
249
  await exec('git', ['add', '-A'], DATA_DIR);
@@ -3,7 +3,7 @@
3
3
  * Both state.ts and documents.ts import from here to avoid duplication.
4
4
  */
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
6
- import { join, isAbsolute, basename, dirname } from 'path';
6
+ import { join, isAbsolute, basename, dirname, resolve, sep } from 'path';
7
7
  import { homedir } from 'os';
8
8
  import { randomUUID } from 'crypto';
9
9
  export const DATA_DIR = join(homedir(), '.openwriter');
@@ -40,9 +40,16 @@ export function tempFilePath() {
40
40
  // ---- Path resolution for external documents ----
41
41
  /** Resolve a filename to a full path. Basenames resolve to DATA_DIR; absolute paths pass through. */
42
42
  export function resolveDocPath(filename) {
43
- if (isAbsolute(filename) || /[/\\]/.test(filename))
43
+ // External docs use absolute paths as identifiers — pass through
44
+ if (isAbsolute(filename))
44
45
  return filename;
45
- return join(DATA_DIR, filename);
46
+ // Internal docs must resolve within DATA_DIR — block path traversal
47
+ const resolved = resolve(DATA_DIR, filename);
48
+ const dataDir = resolve(DATA_DIR);
49
+ if (resolved !== dataDir && !resolved.startsWith(dataDir + sep)) {
50
+ throw new Error(`Path traversal blocked: ${filename}`);
51
+ }
52
+ return resolved;
46
53
  }
47
54
  /** Returns true if filename is a full path (not a simple basename in DATA_DIR). */
48
55
  export function isExternalDoc(filename) {
@@ -62,6 +69,12 @@ export function getParentDirName(filename) {
62
69
  return '';
63
70
  return basename(dirname(filename));
64
71
  }
72
+ /** Atomic write: write to temp file + rename to prevent corruption on crash. */
73
+ export function atomicWriteFileSync(filePath, data) {
74
+ const tmpPath = filePath + '.tmp';
75
+ writeFileSync(tmpPath, data, 'utf-8');
76
+ renameSync(tmpPath, filePath);
77
+ }
65
78
  /** Generate an 8-char hex node ID for TipTap block nodes. */
66
79
  export function generateNodeId() {
67
80
  return randomUUID().replace(/-/g, '').slice(0, 8);
@@ -83,5 +96,5 @@ export function saveConfig(updates) {
83
96
  ensureDataDir();
84
97
  const current = readConfig();
85
98
  const merged = { ...current, ...updates };
86
- writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), 'utf-8');
99
+ atomicWriteFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
87
100
  }
@@ -6,13 +6,14 @@ import express from 'express';
6
6
  import { createServer } from 'http';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { dirname, join } from 'path';
9
- import { existsSync } from 'fs';
10
- import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastSyncStatus } from './ws.js';
9
+ import { existsSync, readFileSync } from 'fs';
10
+ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastSyncStatus, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
11
11
  import { TOOL_REGISTRY } from './mcp.js';
12
12
  import { z } from 'zod';
13
13
  import { zodToJsonSchema } from 'zod-to-json-schema';
14
- import { save, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag } from './state.js';
15
- import { listDocuments, switchDocument, createDocument, deleteDocument, reloadDocument, updateDocumentTitle, openFile } from './documents.js';
14
+ import { save, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc } from './state.js';
15
+ import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs } from './documents.js';
16
+ import { writePromptDebug } from './prompt-debug.js';
16
17
  import { createWorkspaceRouter } from './workspace-routes.js';
17
18
  import { createLinkRouter } from './link-routes.js';
18
19
  import { createTweetRouter } from './tweet-routes.js';
@@ -21,6 +22,7 @@ import { importGoogleDoc } from './gdoc-import.js';
21
22
  import { createVersionRouter } from './version-routes.js';
22
23
  import { createSyncRouter } from './sync-routes.js';
23
24
  import { removeDocFromAllWorkspaces } from './workspaces.js';
25
+ import { resolveDocPath } from './helpers.js';
24
26
  import { createImageRouter } from './image-upload.js';
25
27
  import { createExportRouter } from './export-routes.js';
26
28
  import { PluginManager } from './plugin-manager.js';
@@ -97,15 +99,15 @@ export async function startHttpServer(options = {}) {
97
99
  res.json({ success: true });
98
100
  });
99
101
  // Beacon-based flush: browser sends this on beforeunload/visibilitychange
100
- // sendBeacon sends as text/plain, so we parse the JSON manually
101
- app.post('/api/flush', express.text({ type: '*/*', limit: '10mb' }), (req, res) => {
102
+ // Client sends as application/json Blob (non-CORS-safelisted, so cross-origin sendBeacon is blocked)
103
+ app.post('/api/flush', (req, res) => {
102
104
  try {
103
105
  if (isAgentLocked()) {
104
106
  console.log('[Flush] Blocked (agent write lock active)');
105
107
  res.status(204).end();
106
108
  return;
107
109
  }
108
- const msg = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
110
+ const msg = req.body;
109
111
  if (msg.document) {
110
112
  updateDocument(msg.document);
111
113
  save();
@@ -127,10 +129,58 @@ export async function startHttpServer(options = {}) {
127
129
  app.get('/api/documents', (_req, res) => {
128
130
  res.json(listDocuments());
129
131
  });
132
+ app.put('/api/documents/reorder', (req, res) => {
133
+ try {
134
+ const { order } = req.body;
135
+ if (!Array.isArray(order))
136
+ return res.status(400).json({ error: 'order must be an array' });
137
+ reorderDocs(order);
138
+ broadcastDocumentsChanged();
139
+ res.json({ success: true });
140
+ }
141
+ catch (err) {
142
+ res.status(400).json({ error: err.message });
143
+ }
144
+ });
130
145
  app.post('/api/documents', (req, res) => {
131
146
  try {
132
147
  const result = createDocument(req.body.title, req.body.content, req.body.path);
148
+ // Apply metadata if provided (e.g. tweetContext for threadified docs)
149
+ if (req.body.metadata) {
150
+ setMetadata(req.body.metadata);
151
+ save();
152
+ }
153
+ // Plugin flags: mark all content as pending + tag as agent-created
154
+ if (req.body.markPending) {
155
+ markAllNodesAsPending(getDocument(), 'insert');
156
+ updatePendingCacheForActiveDoc();
157
+ save();
158
+ }
159
+ if (req.body.agentCreated) {
160
+ setMetadata({ agentCreated: true });
161
+ save();
162
+ }
133
163
  broadcastDocumentSwitched(result.document, result.title, result.filename);
164
+ if (req.body.markPending || req.body.agentCreated) {
165
+ broadcastDocumentsChanged();
166
+ broadcastPendingDocsChanged();
167
+ }
168
+ res.json(result);
169
+ }
170
+ catch (err) {
171
+ res.status(400).json({ error: err.message });
172
+ }
173
+ });
174
+ app.post('/api/documents/duplicate', (req, res) => {
175
+ try {
176
+ const { filename } = req.body;
177
+ if (!filename) {
178
+ res.status(400).json({ error: 'filename is required' });
179
+ return;
180
+ }
181
+ const result = duplicateDocument(filename);
182
+ broadcastDocumentSwitched(result.document, result.title, result.filename);
183
+ broadcastDocumentsChanged();
134
184
  res.json(result);
135
185
  }
136
186
  catch (err) {
@@ -172,6 +222,25 @@ export async function startHttpServer(options = {}) {
172
222
  res.status(500).json({ error: err.message });
173
223
  }
174
224
  });
225
+ app.get('/api/documents/:filename/content', (req, res) => {
226
+ try {
227
+ const targetPath = resolveDocPath(req.params.filename);
228
+ if (!existsSync(targetPath)) {
229
+ res.status(404).json({ error: 'Document not found' });
230
+ return;
231
+ }
232
+ const raw = readFileSync(targetPath, 'utf-8');
233
+ const parsed = markdownToTiptap(raw);
234
+ res.json({
235
+ title: parsed.title,
236
+ document: parsed.document,
237
+ metadata: parsed.metadata,
238
+ });
239
+ }
240
+ catch (err) {
241
+ res.status(500).json({ error: err.message });
242
+ }
243
+ });
175
244
  app.delete('/api/documents/:filename', async (req, res) => {
176
245
  try {
177
246
  removeDocFromAllWorkspaces(req.params.filename);
@@ -252,6 +321,22 @@ export async function startHttpServer(options = {}) {
252
321
  res.status(500).json({ error: err.message });
253
322
  }
254
323
  });
324
+ // Prompt debug: write full prompt to a timestamped .md file for inspection
325
+ app.post('/api/prompt-debug', (req, res) => {
326
+ try {
327
+ const { action, debug, metadata } = req.body;
328
+ if (!debug) {
329
+ res.status(400).json({ error: 'debug payload is required' });
330
+ return;
331
+ }
332
+ const filename = writePromptDebug(action, debug, metadata);
333
+ broadcastDocumentsChanged();
334
+ res.json({ success: true, filename });
335
+ }
336
+ catch (err) {
337
+ res.status(500).json({ error: err.message });
338
+ }
339
+ });
255
340
  // Google Doc import
256
341
  app.post('/api/import/gdoc', (req, res) => {
257
342
  try {
@@ -340,7 +425,7 @@ export async function startHttpServer(options = {}) {
340
425
  // Sidebar context menu action dispatch — routes to plugin's registered HTTP routes
341
426
  app.post('/api/plugins/sidebar-action', async (req, res) => {
342
427
  try {
343
- const { action, filename, title } = req.body;
428
+ const { action, filename, title, instructions, label } = req.body;
344
429
  if (!action || !filename) {
345
430
  res.status(400).json({ error: 'action and filename are required' });
346
431
  return;
@@ -353,15 +438,35 @@ export async function startHttpServer(options = {}) {
353
438
  }
354
439
  const prefix = action.slice(0, colonIdx);
355
440
  const actionName = action.slice(colonIdx + 1);
441
+ // Read document content so plugins don't need to call back
442
+ let docContent = '';
443
+ try {
444
+ const targetPath = resolveDocPath(filename);
445
+ if (existsSync(targetPath)) {
446
+ docContent = readFileSync(targetPath, 'utf-8');
447
+ }
448
+ }
449
+ catch { /* content stays empty */ }
450
+ // Show sidebar spinner while plugin processes
451
+ const spinnerTitle = label ? `${label}: ${title}` : title;
452
+ broadcastWritingStarted(spinnerTitle);
453
+ // Intercept res.json to clear spinner when plugin handler responds
454
+ const origJson = res.json.bind(res);
455
+ res.json = (body) => {
456
+ broadcastWritingFinished();
457
+ return origJson(body);
458
+ };
356
459
  // Forward to plugin route: POST /api/{prefix}/sidebar-action
357
460
  // Re-route the request through Express's internal router
358
461
  req.url = `/api/${prefix}/sidebar-action`;
359
- req.body = { action: actionName, filename, title };
462
+ req.body = { action: actionName, filename, title, instructions, content: docContent };
360
463
  app.handle(req, res, () => {
464
+ broadcastWritingFinished();
361
465
  res.status(404).json({ error: `No handler registered for action "${action}"` });
362
466
  });
363
467
  }
364
468
  catch (err) {
469
+ broadcastWritingFinished();
365
470
  res.status(500).json({ error: err.message });
366
471
  }
367
472
  });
@@ -393,7 +498,7 @@ export async function startHttpServer(options = {}) {
393
498
  setupWebSocket(server);
394
499
  // Broadcast agent status now that WS is ready
395
500
  broadcastAgentStatus(true);
396
- server.listen(port, () => {
501
+ server.listen(port, '127.0.0.1', () => {
397
502
  console.log(`OpenWriter running at http://localhost:${port}`);
398
503
  });
399
504
  // Open browser unless --no-open or running as MCP stdio pipe
@@ -85,6 +85,18 @@ function rehydratePendingState(doc, pending) {
85
85
  if (entry.o) {
86
86
  target.attrs.pendingOriginalContent = entry.o;
87
87
  }
88
+ if (entry.g) {
89
+ target.attrs.pendingGroupId = entry.g;
90
+ }
91
+ // Selection range attrs (sub-paragraph enhance)
92
+ if (entry.sf != null)
93
+ target.attrs.pendingSelectionFrom = entry.sf;
94
+ if (entry.st != null)
95
+ target.attrs.pendingSelectionTo = entry.st;
96
+ if (entry.of != null)
97
+ target.attrs.pendingOriginalFrom = entry.of;
98
+ if (entry.ot != null)
99
+ target.attrs.pendingOriginalTo = entry.ot;
88
100
  }
89
101
  }
90
102
  }
@@ -30,6 +30,18 @@ function collectPendingState(doc) {
30
30
  if (node.attrs.pendingOriginalContent) {
31
31
  entry.o = node.attrs.pendingOriginalContent;
32
32
  }
33
+ if (node.attrs.pendingGroupId) {
34
+ entry.g = node.attrs.pendingGroupId;
35
+ }
36
+ // Selection range attrs (sub-paragraph enhance)
37
+ if (node.attrs.pendingSelectionFrom != null)
38
+ entry.sf = node.attrs.pendingSelectionFrom;
39
+ if (node.attrs.pendingSelectionTo != null)
40
+ entry.st = node.attrs.pendingSelectionTo;
41
+ if (node.attrs.pendingOriginalFrom != null)
42
+ entry.of = node.attrs.pendingOriginalFrom;
43
+ if (node.attrs.pendingOriginalTo != null)
44
+ entry.ot = node.attrs.pendingOriginalTo;
33
45
  const t = nodeText(node);
34
46
  if (t)
35
47
  entry.t = t;