openwriter 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-B3iORmCT.css +1 -0
- package/dist/client/assets/index-B5MXw2pg.js +212 -0
- package/dist/client/index.html +2 -2
- package/dist/server/comments.js +256 -0
- package/dist/server/documents.js +60 -18
- package/dist/server/helpers.js +63 -8
- package/dist/server/index.js +94 -40
- package/dist/server/logger.js +246 -0
- package/dist/server/markdown-serialize.js +122 -25
- package/dist/server/mcp.js +289 -77
- package/dist/server/node-blocks.js +22 -4
- package/dist/server/node-matcher.js +57 -5
- package/dist/server/pending-overlay.js +845 -0
- package/dist/server/state.js +981 -78
- package/dist/server/versions.js +18 -0
- package/dist/server/workspaces.js +15 -0
- package/dist/server/ws.js +184 -37
- package/package.json +1 -1
- package/skill/SKILL.md +30 -19
- package/dist/client/assets/index-BxI3DazW.js +0 -212
- package/dist/client/assets/index-OV13QtgQ.css +0 -1
package/dist/server/versions.js
CHANGED
|
@@ -108,6 +108,24 @@ export function forceSnapshot(docId, filePath) {
|
|
|
108
108
|
writeFileSync(join(docDir(docId), `${now}.md`), markdown, 'utf-8');
|
|
109
109
|
lastSnapshot.set(docId, { time: now, hash });
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Write a snapshot from an arbitrary markdown string (rather than copying
|
|
113
|
+
* the current file). Used by `restore_version` so the safety checkpoint
|
|
114
|
+
* can represent the canonical-only state (pending reverted) rather than the
|
|
115
|
+
* flattened pending+canonical body that lives on disk.
|
|
116
|
+
*
|
|
117
|
+
* Returns the timestamp the snapshot was written at.
|
|
118
|
+
*/
|
|
119
|
+
export function writeSnapshotMarkdown(docId, markdown) {
|
|
120
|
+
if (!docId)
|
|
121
|
+
return 0;
|
|
122
|
+
const hash = contentHash(markdown);
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
ensureDocDir(docId);
|
|
125
|
+
writeFileSync(join(docDir(docId), `${now}.md`), markdown, 'utf-8');
|
|
126
|
+
lastSnapshot.set(docId, { time: now, hash });
|
|
127
|
+
return now;
|
|
128
|
+
}
|
|
111
129
|
// ============================================================================
|
|
112
130
|
// LIST / GET
|
|
113
131
|
// ============================================================================
|
|
@@ -437,6 +437,21 @@ export function getWorkspaceAssignedFiles() {
|
|
|
437
437
|
}
|
|
438
438
|
return assigned;
|
|
439
439
|
}
|
|
440
|
+
/** Return every workspace manifest filename that contains this doc. A doc may
|
|
441
|
+
* appear in multiple workspaces; callers that want one usually pick the first. */
|
|
442
|
+
export function findWorkspacesContainingDoc(file) {
|
|
443
|
+
const workspaces = listWorkspaces();
|
|
444
|
+
const result = [];
|
|
445
|
+
for (const info of workspaces) {
|
|
446
|
+
try {
|
|
447
|
+
const ws = readWorkspace(info.filename);
|
|
448
|
+
if (collectAllFiles(ws.root).includes(file))
|
|
449
|
+
result.push(info);
|
|
450
|
+
}
|
|
451
|
+
catch { /* skip corrupt manifests */ }
|
|
452
|
+
}
|
|
453
|
+
return result;
|
|
454
|
+
}
|
|
440
455
|
/**
|
|
441
456
|
* Walk every workspace and return true if `file` is inside one where auto-accept
|
|
442
457
|
* is on at the workspace level or on any ancestor container. Returns false when
|
package/dist/server/ws.js
CHANGED
|
@@ -2,21 +2,42 @@
|
|
|
2
2
|
* WebSocket handler: pushes NodeChanges to browser, receives doc updates + signals.
|
|
3
3
|
*/
|
|
4
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
|
-
import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked,
|
|
5
|
+
import { updateDocument, syncBrowserDocUpdate, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, debouncedSave, cancelDebouncedSave, onChanges, onIdRewrites, isAgentLocked, setAgentLockActive, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, onExternalWriteConflict, onDocumentReloaded, isAgentStub, unmarkAgentStub, } from './state.js';
|
|
6
6
|
import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile } from './documents.js';
|
|
7
7
|
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
8
|
+
import { canonicalizeIdentifier } from './helpers.js';
|
|
9
|
+
import { nodeTextPreview, diagLog } from './pending-overlay.js';
|
|
10
|
+
import { generateRequestId, withRequestId } from './logger.js';
|
|
11
|
+
/** Walk a doc and return a per-pending-node summary for diagnostic logging.
|
|
12
|
+
* Produces lines like "nodeId/status text=\"...\" orig=\"...\"" — empty
|
|
13
|
+
* if the doc has no pending nodes. adr: adr/pending-overlay-model.md */
|
|
14
|
+
function pendingSummary(doc) {
|
|
15
|
+
if (!doc?.content)
|
|
16
|
+
return '<none>';
|
|
17
|
+
const lines = [];
|
|
18
|
+
function walk(nodes) {
|
|
19
|
+
if (!Array.isArray(nodes))
|
|
20
|
+
return;
|
|
21
|
+
for (const node of nodes) {
|
|
22
|
+
const status = node?.attrs?.pendingStatus;
|
|
23
|
+
if (status && node?.attrs?.id) {
|
|
24
|
+
const text = nodeTextPreview(node);
|
|
25
|
+
const orig = node.attrs.pendingOriginalContent ? nodeTextPreview(node.attrs.pendingOriginalContent) : '<none>';
|
|
26
|
+
const identity = (status === 'rewrite' && text === orig) ? ' IDENTITY!' : '';
|
|
27
|
+
lines.push(`${node.attrs.id}/${status} text="${text}" orig="${orig}"${identity}`);
|
|
28
|
+
}
|
|
29
|
+
if (node.content)
|
|
30
|
+
walk(node.content);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
walk(doc.content);
|
|
34
|
+
return lines.length === 0 ? '<none>' : lines.join(' | ');
|
|
35
|
+
}
|
|
8
36
|
const clients = new Set();
|
|
9
37
|
let currentAgentConnected = false;
|
|
10
|
-
// Debounced auto-save
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (saveTimer)
|
|
14
|
-
clearTimeout(saveTimer);
|
|
15
|
-
saveTimer = setTimeout(() => {
|
|
16
|
-
save();
|
|
17
|
-
console.log('[WS] Auto-saved to disk');
|
|
18
|
-
}, 2000);
|
|
19
|
-
}
|
|
38
|
+
// Debounced auto-save lives in state.ts now — one timer for the whole process.
|
|
39
|
+
// Both browser doc-update (here) and MCP write paths (state.ts) call into the
|
|
40
|
+
// same timer so a single TTL governs all save activity.
|
|
20
41
|
// Debounced sidebar refresh: notify clients after title changes settle
|
|
21
42
|
let docsChangedTimer = null;
|
|
22
43
|
function debouncedBroadcastDocumentsChanged() {
|
|
@@ -64,7 +85,7 @@ export function setupWebSocket(server) {
|
|
|
64
85
|
// Re-set agent lock so the 3s window starts NOW, not from the original insert.
|
|
65
86
|
// Tweet thread resyncs recreate all editors which fire onUpdate → stale doc-updates.
|
|
66
87
|
// Without this reset, the lock expires before the browser finishes recreating editors.
|
|
67
|
-
|
|
88
|
+
setAgentLockActive();
|
|
68
89
|
const filePath = getFilePath();
|
|
69
90
|
const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
|
|
70
91
|
const msg = JSON.stringify({
|
|
@@ -90,6 +111,76 @@ export function setupWebSocket(server) {
|
|
|
90
111
|
// Notify browser of updated pending docs list (debounced)
|
|
91
112
|
broadcastPendingDocsChanged();
|
|
92
113
|
});
|
|
114
|
+
// Push matcher-driven ID rewrites to clients so their in-memory TipTap state
|
|
115
|
+
// tracks the server's authoritative ID assignment. Without this, the browser
|
|
116
|
+
// can hold stale IDs after a save-time matcher reassignment, subsequent
|
|
117
|
+
// server→browser messages targeting the new IDs silently fail to resolve at
|
|
118
|
+
// the bridge anchor lookup, and the browser's debounced autosave eventually
|
|
119
|
+
// overwrites fresh server state with the stale view (the v0.14.0 bug pattern).
|
|
120
|
+
// adr: adr/node-identity-matcher.md
|
|
121
|
+
onIdRewrites((rewrites) => {
|
|
122
|
+
if (rewrites.length === 0)
|
|
123
|
+
return;
|
|
124
|
+
const msg = JSON.stringify({ type: 'id-rewrites', rewrites });
|
|
125
|
+
for (const ws of clients) {
|
|
126
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
127
|
+
ws.send(msg);
|
|
128
|
+
}
|
|
129
|
+
console.log(`[WS] Broadcast id-rewrites (${rewrites.length} block(s))`);
|
|
130
|
+
});
|
|
131
|
+
// Push-based: the fs.watch on the active doc fires this listener when an
|
|
132
|
+
// external writer (Edit tool, VSCode, a script) modifies the file. The
|
|
133
|
+
// server has already reloaded its in-memory state from disk and bumped
|
|
134
|
+
// docVersion — we just need to swap the browser's TipTap view so it
|
|
135
|
+
// matches and surface a toast. Stale autosaves from before the external
|
|
136
|
+
// write are rejected by the existing version check in the doc-update
|
|
137
|
+
// handler.
|
|
138
|
+
//
|
|
139
|
+
// Version sync: we include the freshly-bumped docVersion in the message.
|
|
140
|
+
// The browser adopts it as its new baseline so subsequent autosaves (from
|
|
141
|
+
// the user typing on top of the reloaded content) pass the isVersionCurrent
|
|
142
|
+
// check. Without this, browser sits at version 0 while the server is at
|
|
143
|
+
// N+1, and every browser edit gets BLOCKED — typing silently fails to save.
|
|
144
|
+
// adr: adr/active-doc-watcher.md
|
|
145
|
+
onDocumentReloaded((event) => {
|
|
146
|
+
const msg = JSON.stringify({
|
|
147
|
+
type: 'document-reloaded',
|
|
148
|
+
filename: event.filename,
|
|
149
|
+
document: event.document,
|
|
150
|
+
title: event.title,
|
|
151
|
+
docId: event.docId,
|
|
152
|
+
metadata: event.metadata,
|
|
153
|
+
version: getDocVersion(),
|
|
154
|
+
orphanCount: event.orphans.length,
|
|
155
|
+
staleBaselineCount: event.staleBaseline.length,
|
|
156
|
+
});
|
|
157
|
+
for (const ws of clients) {
|
|
158
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
159
|
+
ws.send(msg);
|
|
160
|
+
}
|
|
161
|
+
// Pending count may have shifted (orphan rewrites convert to inserts).
|
|
162
|
+
broadcastPendingDocsChanged();
|
|
163
|
+
});
|
|
164
|
+
// Legacy: surface external-write conflicts when writeToDisk's mtime
|
|
165
|
+
// guard fires. With the active-doc watcher in place, the watcher should
|
|
166
|
+
// reload before any save races, but the guard remains as a backstop —
|
|
167
|
+
// if it fires, we still want to tell the user.
|
|
168
|
+
// adr: adr/external-write-guard.md
|
|
169
|
+
onExternalWriteConflict((conflict) => {
|
|
170
|
+
const filename = conflict.filePath.split(/[/\\]/).pop() || conflict.filePath;
|
|
171
|
+
const msg = JSON.stringify({
|
|
172
|
+
type: 'external-write-conflict',
|
|
173
|
+
filePath: conflict.filePath,
|
|
174
|
+
filename,
|
|
175
|
+
diskMtime: conflict.diskMtime,
|
|
176
|
+
loadedMtime: conflict.loadedMtime,
|
|
177
|
+
});
|
|
178
|
+
for (const ws of clients) {
|
|
179
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
180
|
+
ws.send(msg);
|
|
181
|
+
}
|
|
182
|
+
console.warn(`[WS] Broadcast external-write-conflict for ${filename}`);
|
|
183
|
+
});
|
|
93
184
|
wss.on('connection', (ws) => {
|
|
94
185
|
clients.add(ws);
|
|
95
186
|
console.log(`[WS] Client connected (total: ${clients.size})`);
|
|
@@ -103,9 +194,12 @@ export function setupWebSocket(server) {
|
|
|
103
194
|
// (prevents stale browser tabs from displaying old content)
|
|
104
195
|
const filePath = getFilePath();
|
|
105
196
|
const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
|
|
197
|
+
const docOnConnect = getDocument();
|
|
198
|
+
const pendingOnConnect = pendingSummary(docOnConnect);
|
|
199
|
+
diagLog(`[WS] document-switched SEND on-connect docId=${getDocId()} v=${getDocVersion()} pending=[${pendingOnConnect}]`);
|
|
106
200
|
ws.send(JSON.stringify({
|
|
107
201
|
type: 'document-switched',
|
|
108
|
-
document:
|
|
202
|
+
document: docOnConnect,
|
|
109
203
|
title: getTitle(),
|
|
110
204
|
filename,
|
|
111
205
|
docId: getDocId(),
|
|
@@ -124,22 +218,57 @@ export function setupWebSocket(server) {
|
|
|
124
218
|
ws.on('message', async (data) => {
|
|
125
219
|
try {
|
|
126
220
|
const msg = JSON.parse(data.toString());
|
|
221
|
+
// Every WS message gets a request ID — every downstream log emitted
|
|
222
|
+
// while handling this message inherits it. Trace one request through
|
|
223
|
+
// the whole system with: jq 'select(.requestId=="ws-xxxxxx")'.
|
|
224
|
+
// adr: adr/logging-system.md
|
|
225
|
+
const reqId = generateRequestId(`ws-${msg.type || 'unknown'}`);
|
|
226
|
+
return await withRequestId(reqId, async () => { return await handleMessage(msg); });
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
console.error('[WS] Message error:', err.message);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
/** Inner handler — body extracted so it runs inside the withRequestId
|
|
233
|
+
* scope above. Returns void. */
|
|
234
|
+
async function handleMessage(msg) {
|
|
235
|
+
try {
|
|
127
236
|
if (msg.type === 'doc-update' && msg.document) {
|
|
128
237
|
const docContent = msg.document?.content || [];
|
|
129
238
|
const nodeCount = docContent.length;
|
|
130
239
|
const currentNodeCount = getDocument()?.content?.length || 0;
|
|
131
240
|
const browserVersion = typeof msg.version === 'number' ? msg.version : -1;
|
|
132
241
|
const serverVersion = getDocVersion();
|
|
133
|
-
|
|
134
|
-
|
|
242
|
+
// Canonicalize the browser-sent filename so comparison against
|
|
243
|
+
// getActiveFilename() doesn't trip on separator/case differences
|
|
244
|
+
// for the same file. Without this, a browser that cached the
|
|
245
|
+
// pre-canonicalization spelling sends doc-updates that look like
|
|
246
|
+
// they're for a different doc, triggering saveDocToFile() to
|
|
247
|
+
// write to the old non-canonical path — re-creating the
|
|
248
|
+
// duplicate-document bug we just fixed.
|
|
249
|
+
// adr: adr/path-canonicalization.md
|
|
250
|
+
const browserFilename = msg.filename ? canonicalizeIdentifier(msg.filename) : msg.filename;
|
|
251
|
+
if (isAgentLocked(browserFilename || getActiveFilename())) {
|
|
252
|
+
diagLog(`[WS] doc-update BLOCKED by agent lock (browser: ${nodeCount} nodes, server: ${currentNodeCount} nodes) filename=${browserFilename || '<active>'} browserPending=[${pendingSummary(msg.document)}]`);
|
|
135
253
|
}
|
|
136
254
|
else if (browserVersion >= 0 && !isVersionCurrent(browserVersion)) {
|
|
137
|
-
|
|
255
|
+
// Stale-version doc-update: instead of rejecting (which silently
|
|
256
|
+
// discarded the user's typing), MERGE — keep browser's view of
|
|
257
|
+
// canonical, union the overlays with server-recent additions
|
|
258
|
+
// preserved. adr: adr/pending-overlay-model.md
|
|
259
|
+
if (msg.document.content) {
|
|
260
|
+
msg.document.content = msg.document.content.filter((n) => n.type !== 'imageLoading');
|
|
261
|
+
}
|
|
262
|
+
const result = syncBrowserDocUpdate(msg.document, browserVersion);
|
|
263
|
+
diagLog(`[WS] doc-update SYNC-MERGED stale v${browserVersion}→v${serverVersion} preservedServerEntries=${result.preservedServerEntries}`);
|
|
264
|
+
updatePendingCacheForActiveDoc();
|
|
265
|
+
debouncedSave();
|
|
138
266
|
}
|
|
139
|
-
else if (
|
|
267
|
+
else if (browserFilename && browserFilename !== getActiveFilename()) {
|
|
140
268
|
// Browser sent a doc-update for a different document (race: server switched away).
|
|
141
269
|
// Save directly to that file on disk instead of corrupting the active doc.
|
|
142
|
-
|
|
270
|
+
diagLog(`[WS] doc-update ROUTED-TO-NON-ACTIVE filename=${browserFilename} browserPending=[${pendingSummary(msg.document)}]`);
|
|
271
|
+
saveDocToFile(browserFilename, msg.document);
|
|
143
272
|
}
|
|
144
273
|
else {
|
|
145
274
|
// Strip ephemeral imageLoading nodes — they're transient placeholders that should
|
|
@@ -148,7 +277,16 @@ export function setupWebSocket(server) {
|
|
|
148
277
|
msg.document.content = msg.document.content.filter((n) => n.type !== 'imageLoading');
|
|
149
278
|
}
|
|
150
279
|
const cleanedCount = msg.document.content?.length || 0;
|
|
151
|
-
|
|
280
|
+
// Capture server's BEFORE state so we can diff against the incoming.
|
|
281
|
+
// adr: adr/pending-overlay-model.md
|
|
282
|
+
const serverBefore = pendingSummary(getDocument());
|
|
283
|
+
const browserAfter = pendingSummary(msg.document);
|
|
284
|
+
if (serverBefore !== browserAfter) {
|
|
285
|
+
diagLog(`[WS] doc-update PENDING-DIFF v${browserVersion}→v${serverVersion}`);
|
|
286
|
+
diagLog(` BEFORE(server): ${serverBefore}`);
|
|
287
|
+
diagLog(` AFTER (browser): ${browserAfter}`);
|
|
288
|
+
}
|
|
289
|
+
diagLog(`[WS] doc-update ACCEPTED (browser: ${nodeCount} nodes, cleaned: ${cleanedCount}, server: ${currentNodeCount} nodes)`);
|
|
152
290
|
updateDocument(msg.document);
|
|
153
291
|
updatePendingCacheForActiveDoc(); // Keep cache in sync after browser edits/reject-all
|
|
154
292
|
debouncedSave();
|
|
@@ -256,17 +394,22 @@ export function setupWebSocket(server) {
|
|
|
256
394
|
const action = msg.action; // 'accept' or 'reject'
|
|
257
395
|
const resolvedFilename = msg.filename;
|
|
258
396
|
const isActiveDoc = resolvedFilename === getActiveFilename();
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
397
|
+
// Stub-cleanup: when the user rejects all pending decorations on a
|
|
398
|
+
// doc that's still a fresh agent stub, delete the file. The stub
|
|
399
|
+
// had no real content; the user said no to the populated content;
|
|
400
|
+
// there's nothing left to keep.
|
|
401
|
+
//
|
|
402
|
+
// Stub status is consulted from the in-memory registry — NEVER
|
|
403
|
+
// from disk frontmatter. The previous on-disk `agentCreated: true`
|
|
404
|
+
// model was a silent-data-loss landmine: the flag survived across
|
|
405
|
+
// sessions, restarts, and the doc's entire useful lifetime, so a
|
|
406
|
+
// reject-all years later would destroy real work. The in-memory
|
|
407
|
+
// model can only mark a doc as a stub during the brief window
|
|
408
|
+
// between create_document and the first accepted save.
|
|
409
|
+
// adr: adr/agent-stub-model.md
|
|
410
|
+
if (action === 'reject' && isAgentStub(resolvedFilename)) {
|
|
411
|
+
cancelDebouncedSave();
|
|
268
412
|
try {
|
|
269
|
-
// Remove from any workspace manifests before deleting the file
|
|
270
413
|
removeDocFromAllWorkspaces(resolvedFilename);
|
|
271
414
|
const result = await deleteDocument(resolvedFilename);
|
|
272
415
|
if (result.switched && result.newDoc) {
|
|
@@ -278,15 +421,17 @@ export function setupWebSocket(server) {
|
|
|
278
421
|
return; // File deleted — no strip/save needed
|
|
279
422
|
}
|
|
280
423
|
catch (err) {
|
|
281
|
-
console.error('[WS] Failed to delete rejected agent
|
|
424
|
+
console.error('[WS] Failed to delete rejected agent stub:', err.message);
|
|
282
425
|
// Fall through to normal strip+save (e.g. only doc remaining)
|
|
283
426
|
}
|
|
284
427
|
}
|
|
285
428
|
if (isActiveDoc) {
|
|
286
|
-
// Normal path: resolved doc is the active one
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
429
|
+
// Normal path: resolved doc is the active one. Accept-all
|
|
430
|
+
// graduates the doc out of stub status (it now has accepted
|
|
431
|
+
// content); the writeToDisk graduation does the same for
|
|
432
|
+
// saves with mixed pending+accepted content.
|
|
433
|
+
if (action === 'accept')
|
|
434
|
+
unmarkAgentStub(resolvedFilename);
|
|
290
435
|
stripPendingAttrs();
|
|
291
436
|
save();
|
|
292
437
|
updatePendingCacheForActiveDoc(); // Sync cache after strip (prevents stale "has changes" indicator)
|
|
@@ -294,6 +439,8 @@ export function setupWebSocket(server) {
|
|
|
294
439
|
else {
|
|
295
440
|
// Race path: resolved doc is NOT the active one (server switched away).
|
|
296
441
|
// Strip pending attrs directly from the file on disk.
|
|
442
|
+
if (action === 'accept')
|
|
443
|
+
unmarkAgentStub(resolvedFilename);
|
|
297
444
|
stripPendingAttrsFromFile(resolvedFilename, action === 'accept');
|
|
298
445
|
}
|
|
299
446
|
broadcastPendingDocsChanged();
|
|
@@ -302,7 +449,7 @@ export function setupWebSocket(server) {
|
|
|
302
449
|
catch {
|
|
303
450
|
// Ignore malformed messages
|
|
304
451
|
}
|
|
305
|
-
}
|
|
452
|
+
}
|
|
306
453
|
ws.on('close', () => {
|
|
307
454
|
clients.delete(ws);
|
|
308
455
|
console.log(`[WS] Client disconnected (total: ${clients.size})`);
|
|
@@ -457,8 +604,8 @@ export function getPendingWritesSnapshot() {
|
|
|
457
604
|
startedAt,
|
|
458
605
|
}));
|
|
459
606
|
}
|
|
460
|
-
export function
|
|
461
|
-
const msg = JSON.stringify({ type: '
|
|
607
|
+
export function broadcastCommentsChanged(filename) {
|
|
608
|
+
const msg = JSON.stringify({ type: 'comments-changed', filename });
|
|
462
609
|
for (const ws of clients) {
|
|
463
610
|
if (ws.readyState === WebSocket.OPEN)
|
|
464
611
|
ws.send(msg);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/skill/SKILL.md
CHANGED
|
@@ -16,7 +16,7 @@ description: |
|
|
|
16
16
|
Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
|
|
17
17
|
metadata:
|
|
18
18
|
author: travsteward
|
|
19
|
-
version: "0.7.
|
|
19
|
+
version: "0.7.3"
|
|
20
20
|
repository: https://github.com/travsteward/openwriter
|
|
21
21
|
license: MIT
|
|
22
22
|
---
|
|
@@ -118,7 +118,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
118
118
|
| Tool | Key Params | Description |
|
|
119
119
|
|------|-----------|-------------|
|
|
120
120
|
| `list_documents` | — | List all documents with title, docId, word count, active status |
|
|
121
|
-
| `switch_document` | `docId` |
|
|
121
|
+
| `switch_document` | `docId` | Change the user's view to a different document. **Rarely needed** — every tool targets docs by docId directly, so reads, writes, and creations never require switching. Use ONLY when you want to pull the user's attention to a specific doc (e.g. "I've loaded this up for your review"). The user may be perusing other docs — don't yank their view as part of normal work. |
|
|
122
122
|
| `create_document` | `content_type`, `title?`, ... | Create a new document. `content_type` is required: "document", "tweet", "reply", "quote", "article", "linkedin", "newsletter", or "blog" |
|
|
123
123
|
| `open_file` | `path` | Open an existing .md file from any location on disk |
|
|
124
124
|
| `delete_document` | `docId` | Delete a document file (moves to OS trash, recoverable) |
|
|
@@ -153,12 +153,14 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
153
153
|
| `move_item` | Move or reorder a doc, container, or workspace (type: doc/container/workspace) |
|
|
154
154
|
| `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
|
|
155
155
|
|
|
156
|
-
###
|
|
156
|
+
### Comments
|
|
157
157
|
|
|
158
158
|
| Tool | Key Params | Description |
|
|
159
159
|
|------|-----------|-------------|
|
|
160
|
-
| `
|
|
161
|
-
| `
|
|
160
|
+
| `get_comments` | `docId?`, `scope?` | Get comments left by the user. Default scope is `workspace` when a docId is given (returns comments for every doc in the same project); pass `scope: "document"` to narrow, or `scope: "all"` for every doc on disk |
|
|
161
|
+
| `resolve_comments` | `comment_ids` | Remove comments after addressing feedback (pass comment IDs) |
|
|
162
|
+
|
|
163
|
+
The older names `get_agent_marks` and `resolve_agent_marks` remain as deprecated aliases.
|
|
162
164
|
|
|
163
165
|
### Task Management
|
|
164
166
|
|
|
@@ -233,7 +235,7 @@ The user can turn on **auto-accept** on a per-doc basis (right-click the doc in
|
|
|
233
235
|
**Rules:**
|
|
234
236
|
- `create_document` does NOT accept a `content` parameter — it always creates an empty doc
|
|
235
237
|
- Step 1 (`create_document`) — shows spinner, creates empty doc, does NOT switch the editor
|
|
236
|
-
- Step 2 (`populate_document`) —
|
|
238
|
+
- Step 2 (`populate_document`) — pass the `docId` from step 1 to write content directly to that doc, marks as pending decorations, clears the spinner. Does NOT switch the user's view — they keep working wherever they are.
|
|
237
239
|
- Never use `write_to_pad` for the initial population — use `populate_document` exclusively
|
|
238
240
|
|
|
239
241
|
### Workspace-Integrated Creation
|
|
@@ -301,7 +303,7 @@ For voice-matched drafting without a custom Author's Voice profile, install the
|
|
|
301
303
|
|
|
302
304
|
```
|
|
303
305
|
1. list_documents → see all docs with title + [docId]
|
|
304
|
-
2. read_pad
|
|
306
|
+
2. read_pad({ docId: "e5f6a7b8" }) → reads that doc directly, no switch needed
|
|
305
307
|
3. write_to_pad({ docId: "e5f6a7b8", changes: [...] })
|
|
306
308
|
→ edits go to the identified doc, no view switch needed
|
|
307
309
|
```
|
|
@@ -333,21 +335,23 @@ For voice-matched drafting without a custom Author's Voice profile, install the
|
|
|
333
335
|
|
|
334
336
|
The workspace and containers are auto-created on the first `create_document` call. Subsequent calls reuse the existing workspace/containers (matched case-insensitively).
|
|
335
337
|
|
|
336
|
-
###
|
|
338
|
+
### Comments (inline feedback)
|
|
337
339
|
|
|
338
|
-
Users can select text in the browser, right-click, and leave
|
|
340
|
+
Users can select text in the browser, right-click, and leave a comment — a note attached to a specific text range. Comments appear as dotted underlines in the editor. This is the user's way of marking up a document with feedback for you to address.
|
|
339
341
|
|
|
340
342
|
```
|
|
341
|
-
1. User says "check my
|
|
342
|
-
2.
|
|
343
|
-
3. Address each
|
|
344
|
-
4.
|
|
343
|
+
1. User says "check my comments" (or you see the hint in read_pad output)
|
|
344
|
+
2. get_comments({ docId }) → comments for the current workspace by default
|
|
345
|
+
3. Address each comment → rewrite, insert, delete via write_to_pad (use docId)
|
|
346
|
+
4. resolve_comments([ids]) → clears decorations in browser
|
|
345
347
|
```
|
|
346
348
|
|
|
347
|
-
- `read_pad` automatically shows
|
|
348
|
-
-
|
|
349
|
-
-
|
|
350
|
-
-
|
|
349
|
+
- `read_pad` automatically shows comment counts: this doc + other docs
|
|
350
|
+
- Default scope is `workspace` when a docId is provided — you see comments across every doc in the user's current project, not just the one they're viewing
|
|
351
|
+
- Pass `scope: "document"` to narrow to one doc, `scope: "all"` to span everything on disk
|
|
352
|
+
- Always resolve comments after addressing them — `resolve_comments` is a state change ("addressed, archive it"), not a destructive delete. The record stays in storage; only the decoration disappears. `get_comments` skips resolved ones by default
|
|
353
|
+
- A comment with an empty note means "fix this" — use your judgment
|
|
354
|
+
- A comment with a note is specific feedback — follow the instruction
|
|
351
355
|
|
|
352
356
|
### Book workspace guidelines
|
|
353
357
|
|
|
@@ -638,7 +642,14 @@ Then restart your Claude Code session (`/mcp` to reconnect).
|
|
|
638
642
|
|
|
639
643
|
**MCP tools not available** — The OpenWriter MCP server isn't configured yet. Follow the [setup instructions](#mcp-tools-are-not-available-skill-first-install) above. After adding the MCP config, the user must restart their Claude Code session.
|
|
640
644
|
|
|
641
|
-
**Browser dies mid-session** — The MCP stdio pipe can break during context compaction or session resets. The HTTP server survives (crash guards), but MCP tools stop working.
|
|
645
|
+
**Browser dies mid-session** — The MCP stdio pipe can break during context compaction or session resets. The HTTP server survives (crash guards), but MCP tools stop working. Reconnect by [restarting the MCP server](#restarting-the-mcp-server) (see below). The new process enters client mode and proxies MCP calls to the surviving HTTP server. The browser will auto-reconnect.
|
|
646
|
+
|
|
647
|
+
### Restarting the MCP server
|
|
648
|
+
|
|
649
|
+
Depends on how OpenWriter is configured:
|
|
650
|
+
|
|
651
|
+
- **Claude Code with `~/.claude.json`** — run `/mcp` to reconnect.
|
|
652
|
+
- **Claude Desktop with settings → developer** — there's no explicit restart button. Call `list_documents` (zero params, read-only, fast). If the previous process is dead, Claude auto-spawns a fresh one to satisfy the call. After code changes, kill the old process (`taskkill /F /PID <pid>` on Windows, `kill <pid>` on macOS/Linux) first so the spawn picks up the new build.
|
|
642
653
|
|
|
643
654
|
**Port 5050 busy** — Another OpenWriter instance owns the port. New sessions auto-enter client mode (proxying via HTTP) — tools still work. No action needed.
|
|
644
655
|
|
|
@@ -648,6 +659,6 @@ Then restart your Claude Code session (`/mcp` to reconnect).
|
|
|
648
659
|
|
|
649
660
|
**Server not starting** — Ensure `openwriter` works from your terminal (`npm install -g openwriter` first). If on Windows and the global command isn't found, the MCP config may need `"command": "cmd"` with `"args": ["/c", "openwriter", "--no-open"]`.
|
|
650
661
|
|
|
651
|
-
**After code changes** — Run `npm run build` in `packages/openwriter`, then `/mcp`
|
|
662
|
+
**After code changes** — Run `npm run build` in `packages/openwriter`, kill the running openwriter process, then [restart the MCP server](#restarting-the-mcp-server). `/mcp` alone only reconnects to the existing process; it won't pick up new code unless the old process dies first.
|
|
652
663
|
|
|
653
664
|
**Slow to load / loads last** — MCP servers load sequentially in config order. Move `openwriter` to the first position in `mcpServers` in `~/.claude.json`. See setup instructions above.
|