clay-server 3.1.0 → 3.2.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.
@@ -316,6 +316,7 @@ function attachConnection(ctx) {
316
316
  }
317
317
  tm.detachAll(ws);
318
318
  clients.delete(ws);
319
+ stopFileWatch(ws);
319
320
  if (clients.size === 0) {
320
321
  stopFileWatch();
321
322
  stopAllDirWatches();
@@ -5,56 +5,93 @@ var path = require("path");
5
5
  * Attach file/directory watcher engine to a project context.
6
6
  *
7
7
  * ctx fields:
8
- * cwd, send, safePath, BINARY_EXTS, FS_MAX_SIZE, IGNORED_DIRS
8
+ * cwd, send, sendTo, safePath, BINARY_EXTS, FS_MAX_SIZE, IGNORED_DIRS
9
9
  */
10
10
  function attachFileWatch(ctx) {
11
11
  var cwd = ctx.cwd;
12
12
  var send = ctx.send;
13
+ var sendTo = ctx.sendTo;
13
14
  var safePath = ctx.safePath;
14
15
  var BINARY_EXTS = ctx.BINARY_EXTS;
15
16
  var FS_MAX_SIZE = ctx.FS_MAX_SIZE;
16
17
  var IGNORED_DIRS = ctx.IGNORED_DIRS;
17
18
 
18
19
  // --- File watcher ---
19
- var fileWatcher = null;
20
- var watchedPath = null;
21
- var watchDebounce = null;
20
+ // One open file per websocket client. A project-wide singleton watcher made
21
+ // one browser tab silently replace another tab's live preview subscription.
22
+ var fileWatchers = new Map();
22
23
 
23
- function startFileWatch(relPath) {
24
+ function closeFileWatch(key) {
25
+ var entry = fileWatchers.get(key);
26
+ if (!entry) return;
27
+ clearTimeout(entry.debounce);
28
+ try { entry.watcher.close(); } catch (e) {}
29
+ fileWatchers.delete(key);
30
+ }
31
+
32
+ function sendFileChanged(client, message) {
33
+ if (client && typeof sendTo === "function") {
34
+ sendTo(client, message);
35
+ } else {
36
+ send(message);
37
+ }
38
+ }
39
+
40
+ function startFileWatch(client, relPath) {
41
+ // Preserve the old single-argument API for callers outside the websocket
42
+ // file browser. They share one legacy subscription.
43
+ if (typeof relPath !== "string") {
44
+ relPath = client;
45
+ client = null;
46
+ }
24
47
  var absPath = safePath(cwd, relPath);
25
48
  if (!absPath) return;
26
- if (watchedPath === relPath) return;
27
- stopFileWatch();
28
- watchedPath = relPath;
49
+ var key = client || "_legacy";
50
+ var existing = fileWatchers.get(key);
51
+ if (existing && existing.relPath === relPath) return;
52
+ closeFileWatch(key);
53
+
54
+ // Watch the parent directory rather than the file inode. Editors and agent
55
+ // tools commonly save with write-temp + rename; watching the old inode then
56
+ // misses later edits even though the path still exists.
57
+ var parentPath = path.dirname(absPath);
58
+ var baseName = path.basename(absPath);
29
59
  try {
30
- fileWatcher = fs.watch(absPath, function () {
31
- clearTimeout(watchDebounce);
32
- watchDebounce = setTimeout(function () {
60
+ var watcher = fs.watch(parentPath, function (eventType, filename) {
61
+ if (filename && String(filename) !== baseName) return;
62
+ var active = fileWatchers.get(key);
63
+ if (!active || active.relPath !== relPath) return;
64
+ clearTimeout(active.debounce);
65
+ active.debounce = setTimeout(function () {
66
+ var latest = fileWatchers.get(key);
67
+ if (!latest || latest.relPath !== relPath) return;
33
68
  try {
34
69
  var stat = fs.statSync(absPath);
35
70
  var ext = path.extname(absPath).toLowerCase();
36
71
  if (stat.size > FS_MAX_SIZE || BINARY_EXTS.has(ext)) return;
37
72
  var content = fs.readFileSync(absPath, "utf8");
38
- send({ type: "fs_file_changed", path: relPath, content: content, size: stat.size });
73
+ sendFileChanged(client, { type: "fs_file_changed", path: relPath, content: content, size: stat.size });
39
74
  } catch (e) {
40
- stopFileWatch();
75
+ // Atomic saves can briefly remove the destination path between
76
+ // rename events. Keep the parent watcher alive for the next event.
77
+ if (e.code !== "ENOENT") closeFileWatch(key);
41
78
  }
42
79
  }, 200);
43
80
  });
44
- fileWatcher.on("error", function () { stopFileWatch(); });
81
+ fileWatchers.set(key, { watcher: watcher, relPath: relPath, debounce: null });
82
+ watcher.on("error", function () { closeFileWatch(key); });
45
83
  } catch (e) {
46
- watchedPath = null;
84
+ closeFileWatch(key);
47
85
  }
48
86
  }
49
87
 
50
- function stopFileWatch() {
51
- if (fileWatcher) {
52
- try { fileWatcher.close(); } catch (e) {}
53
- fileWatcher = null;
88
+ function stopFileWatch(client) {
89
+ if (arguments.length > 0) {
90
+ closeFileWatch(client || "_legacy");
91
+ return;
54
92
  }
55
- clearTimeout(watchDebounce);
56
- watchDebounce = null;
57
- watchedPath = null;
93
+ var keys = Array.from(fileWatchers.keys());
94
+ for (var i = 0; i < keys.length; i++) closeFileWatch(keys[i]);
58
95
  }
59
96
 
60
97
  // --- Directory watcher ---
@@ -317,12 +317,12 @@ function attachFilesystem(ctx) {
317
317
 
318
318
  // --- File watcher ---
319
319
  if (msg.type === "fs_watch") {
320
- if (msg.path) startFileWatch(msg.path);
320
+ if (msg.path) startFileWatch(ws, msg.path);
321
321
  return true;
322
322
  }
323
323
 
324
324
  if (msg.type === "fs_unwatch") {
325
- stopFileWatch();
325
+ stopFileWatch(ws);
326
326
  return true;
327
327
  }
328
328
 
@@ -0,0 +1,107 @@
1
+ var fs = require("fs");
2
+ var path = require("path");
3
+ var sessionDocumentMcp = require("./session-document-mcp-server");
4
+
5
+ var DOCUMENT_PROMPT = "When the user's primary request is to create or revise Markdown, call present_markdown_edit once per targeted document with its resolved .md/.mdx path, immediately before that document's first Edit or Write. Do not call it for incidental Markdown changes during coding, maintenance, tests, or refactoring.";
6
+
7
+ function toolResult(value) {
8
+ return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] });
9
+ }
10
+
11
+ function toolError(message) {
12
+ return Promise.resolve({
13
+ content: [{ type: "text", text: "Error: " + message }],
14
+ isError: true,
15
+ });
16
+ }
17
+
18
+ function isInside(root, target) {
19
+ return target === root || target.startsWith(root + path.sep);
20
+ }
21
+
22
+ function attachSessionDocument(ctx) {
23
+ var cwd = fs.realpathSync(ctx.cwd);
24
+ var isMate = ctx.isMate;
25
+ var sendToSession = ctx.sendToSession;
26
+ var fsMaxSize = ctx.FS_MAX_SIZE || 512 * 1024;
27
+ var getOsUserInfoForSession = ctx.getOsUserInfoForSession || function () { return null; };
28
+ var fsAsUser = ctx.fsAsUser;
29
+
30
+ function resolveMarkdownPath(requested) {
31
+ if (typeof requested !== "string" || !requested.trim()) return null;
32
+ var target = path.resolve(cwd, requested.trim());
33
+ if (!isInside(cwd, target) || !/\.mdx?$/i.test(target)) return null;
34
+ try {
35
+ var realTarget = fs.realpathSync(target);
36
+ return isInside(cwd, realTarget) ? realTarget : null;
37
+ } catch (e) {
38
+ try {
39
+ var realParent = fs.realpathSync(path.dirname(target));
40
+ return isInside(cwd, realParent) ? path.join(realParent, path.basename(target)) : null;
41
+ } catch (parentError) {
42
+ return null;
43
+ }
44
+ }
45
+ }
46
+
47
+ function readSnapshot(target, caller) {
48
+ if (!fs.existsSync(target)) return { content: "", exists: false, size: 0 };
49
+ var osUserInfo = getOsUserInfoForSession(caller);
50
+ if (osUserInfo && typeof fsAsUser === "function") {
51
+ var statResult = fsAsUser("stat", { file: target }, osUserInfo);
52
+ if (statResult.size > fsMaxSize) throw new Error("Markdown file is too large to present live");
53
+ var readResult = fsAsUser("read", { file: target, readContent: true }, osUserInfo);
54
+ return { content: readResult.content || "", exists: true, size: statResult.size };
55
+ }
56
+ var stat = fs.statSync(target);
57
+ if (stat.size > fsMaxSize) throw new Error("Markdown file is too large to present live");
58
+ return { content: fs.readFileSync(target, "utf8"), exists: true, size: stat.size };
59
+ }
60
+
61
+ function present(args, caller) {
62
+ if (!caller) return toolError("present_markdown_edit requires a session-bound tool server");
63
+ var target = resolveMarkdownPath(args.path);
64
+ if (!target) return toolError("path must be a .md or .mdx file inside the project");
65
+ try {
66
+ var snapshot = readSnapshot(target, caller);
67
+ var relativePath = path.relative(cwd, target).split(path.sep).join("/");
68
+ sendToSession(caller.localId, {
69
+ type: "markdown_edit_present",
70
+ path: relativePath,
71
+ content: snapshot.content,
72
+ exists: snapshot.exists,
73
+ size: snapshot.size,
74
+ });
75
+ return toolResult({ ready: true, path: relativePath });
76
+ } catch (e) {
77
+ return toolError(e.message || String(e));
78
+ }
79
+ }
80
+
81
+ function getToolDefs(boundSession) {
82
+ if (isMate) return [];
83
+ return sessionDocumentMcp.getToolDefs({
84
+ present: function (args) { return present(args, boundSession || null); },
85
+ });
86
+ }
87
+
88
+ function createMcpServer(adapter, boundSession) {
89
+ if (isMate || !adapter || typeof adapter.createToolServer !== "function") return null;
90
+ return adapter.createToolServer({
91
+ name: "clay-documents",
92
+ version: "1.0.0",
93
+ tools: getToolDefs(boundSession || null),
94
+ });
95
+ }
96
+
97
+ return {
98
+ createMcpServer: createMcpServer,
99
+ getSystemPrompt: function () { return isMate ? "" : DOCUMENT_PROMPT; },
100
+ getToolDefs: getToolDefs,
101
+ };
102
+ }
103
+
104
+ module.exports = {
105
+ DOCUMENT_PROMPT: DOCUMENT_PROMPT,
106
+ attachSessionDocument: attachSessionDocument,
107
+ };
@@ -6,7 +6,7 @@ var MAX_PROMPT_CHARS = 4000;
6
6
  var MAX_NOTE_PROMPT_CHARS = 800;
7
7
  var NOTES_LABEL = "--- Project sticky notes (cross-session work memory; manage via clay-notes tools) ---";
8
8
  var NOTES_HINT = "- More notes are available; call list_notes for the rest.";
9
- var PROACTIVE_POLICY = "The board persists across sessions and is shared with the user and every Clay agent working on this project. People may use it freely. As a Clay agent, use it specifically as cross-session work memory: checklists and to-do lists, work goals, handoffs, unfinished work, durable decisions and constraints, and knowledge that should remain available after this session ends. Create or update a note proactively when the user asks for a handoff, establishes something future sessions must remember, or when preserving the current goal and next actions would make continuation easier. Put a concise plain-text title on the first line. Add a detailed body with the relevant background, rationale, decisions, completed work, current state, validation, next steps, blockers, and references. If an injected preview is relevant or truncated, call list_notes and read the full note before acting. Do not use notes as a transcript, routine progress log, or self-announcement. Keep one coherent topic per note and consolidate or remove stale notes instead of creating duplicates.";
9
+ var PROACTIVE_POLICY = sessionNotesMcp.MEMORY_CONTRACT + " If an injected preview is relevant or truncated, call list_notes before acting or writing so you do not create a duplicate.";
10
10
  var NOTE_COLORS = ["yellow", "blue", "green", "pink", "orange", "purple"];
11
11
 
12
12
  function toolResult(value) {
package/lib/project.js CHANGED
@@ -33,6 +33,7 @@ var { attachEmail: attachEmailModule } = require("./project-email");
33
33
  var { attachSessionSpawn } = require("./project-session-spawn");
34
34
  var { attachSessionPair } = require("./project-session-pair");
35
35
  var { attachSessionNotes, composeSystemPrompts } = require("./project-session-notes");
36
+ var { attachSessionDocument } = require("./project-session-document");
36
37
  var { attachSplitGroups } = require("./session-split-groups");
37
38
  // project-notifications is attached globally in server.js, passed via opts.notificationsModule
38
39
 
@@ -385,6 +386,7 @@ function createProjectContext(opts) {
385
386
  var _fileWatch = attachFileWatch({
386
387
  cwd: cwd,
387
388
  send: send,
389
+ sendTo: sendTo,
388
390
  safePath: safePath,
389
391
  BINARY_EXTS: BINARY_EXTS,
390
392
  FS_MAX_SIZE: FS_MAX_SIZE,
@@ -507,6 +509,17 @@ function createProjectContext(opts) {
507
509
  }
508
510
  },
509
511
  });
512
+ var _sessionDocument = attachSessionDocument({
513
+ cwd: cwd,
514
+ isMate: isMate,
515
+ sendToSession: sendToSession,
516
+ FS_MAX_SIZE: FS_MAX_SIZE,
517
+ getOsUserInfoForSession: function (session) {
518
+ var linuxUser = getLinuxUserForSession(session);
519
+ return linuxUser ? resolveOsUserInfo(linuxUser) : null;
520
+ },
521
+ fsAsUser: fsAsUser,
522
+ });
510
523
 
511
524
  // The SDK bridge is created after local MCP servers. Session spawning uses
512
525
  // a getter so tool handlers see the initialized bridge when they run.
@@ -565,6 +578,16 @@ function createProjectContext(opts) {
565
578
  }
566
579
  }
567
580
 
581
+ // Explicit agent signal for user-requested Markdown presentation.
582
+ if (!isMate) {
583
+ try {
584
+ var sessionDocumentMcpConfig = _sessionDocument.createMcpServer(adapter);
585
+ if (sessionDocumentMcpConfig) servers[sessionDocumentMcpConfig.name || "clay-documents"] = sessionDocumentMcpConfig;
586
+ } catch (e) {
587
+ console.error("[project] Failed to create session document MCP server:", e.message);
588
+ }
589
+ }
590
+
568
591
  // Debate MCP server (available to both mates and main project)
569
592
  try {
570
593
  var debateMcp = require("./debate-mcp-server");
@@ -728,9 +751,9 @@ function createProjectContext(opts) {
728
751
  // clay-email -> only when the user has an account or server SMTP
729
752
  //
730
753
  // forSession (optional): the session whose query these servers are mounted
731
- // into. clay-sessions and clay-notes must know their caller for depth and
732
- // ownership rules, so they are re-instantiated bound to that session; the
733
- // static instances only serve descriptor listing and fail closed on calls.
754
+ // into. Session, notes, and document tools must know their caller, so they
755
+ // are re-instantiated bound to that session; static instances only serve
756
+ // descriptor listing and fail closed on calls.
734
757
  function getLocalMcpServers(forSession) {
735
758
  var extWs = browserState._extensionWs;
736
759
  var extConnected = !!(extWs && extWs.readyState === 1);
@@ -760,6 +783,15 @@ function createProjectContext(opts) {
760
783
  }
761
784
  continue;
762
785
  }
786
+ if (name === "clay-documents" && forSession) {
787
+ try {
788
+ var boundDocuments = _sessionDocument.createMcpServer(adapter, forSession);
789
+ if (boundDocuments) { filtered[name] = boundDocuments; hasAny = true; }
790
+ } catch (e) {
791
+ console.error("[project] Failed to bind session document MCP server:", e.message);
792
+ }
793
+ continue;
794
+ }
763
795
  filtered[name] = mcpServers[name];
764
796
  hasAny = true;
765
797
  }
@@ -807,10 +839,13 @@ function createProjectContext(opts) {
807
839
  return composeSystemPrompts([
808
840
  _sessionPair.getSystemPrompt(session),
809
841
  _sessionNotes.getSystemPrompt(session),
842
+ _sessionDocument.getSystemPrompt(session),
810
843
  ]);
811
844
  },
812
845
  getSessionToolDefs: function (session) {
813
- return _sessionPair.getToolDefs(session).concat(_sessionNotes.getToolDefs(session));
846
+ return _sessionPair.getToolDefs(session)
847
+ .concat(_sessionNotes.getToolDefs(session))
848
+ .concat(_sessionDocument.getToolDefs(session));
814
849
  },
815
850
  });
816
851
 
@@ -1482,6 +1517,15 @@ function createProjectContext(opts) {
1482
1517
  inputSchema: normalizeToolSchema(noteTools[nti].inputSchema),
1483
1518
  });
1484
1519
  }
1520
+ var documentTools = _sessionDocument.getToolDefs(boundSession);
1521
+ for (var dti = 0; dti < documentTools.length; dti++) {
1522
+ tools.push({
1523
+ server: "clay-documents",
1524
+ name: documentTools[dti].name,
1525
+ description: documentTools[dti].description || documentTools[dti].name,
1526
+ inputSchema: normalizeToolSchema(documentTools[dti].inputSchema),
1527
+ });
1528
+ }
1485
1529
  }
1486
1530
 
1487
1531
  if (sessionOnly) return Promise.resolve(tools);
@@ -1493,6 +1537,7 @@ function createProjectContext(opts) {
1493
1537
  if (localMcp) {
1494
1538
  var inAppNames = Object.keys(localMcp);
1495
1539
  for (var i = 0; i < inAppNames.length; i++) {
1540
+ if (inAppNames[i] === "clay-sessions" || inAppNames[i] === "clay-notes" || inAppNames[i] === "clay-documents") continue;
1496
1541
  extractServerTools(inAppNames[i], localMcp[inAppNames[i]]);
1497
1542
  }
1498
1543
  }
@@ -1527,6 +1572,17 @@ function createProjectContext(opts) {
1527
1572
  }
1528
1573
  }
1529
1574
  }
1575
+ if (boundSession && serverName === "clay-documents") {
1576
+ var documentTools = _sessionDocument.getToolDefs(boundSession);
1577
+ for (var dti = 0; dti < documentTools.length; dti++) {
1578
+ if (documentTools[dti].name === toolName && typeof documentTools[dti].handler === "function") {
1579
+ return Promise.resolve(documentTools[dti].handler(args || {}));
1580
+ }
1581
+ }
1582
+ }
1583
+ if (serverName === "clay-sessions" || serverName === "clay-notes" || serverName === "clay-documents") {
1584
+ return Promise.reject(new Error("Session-bound tool requires a valid Clay session: " + serverName + "/" + toolName));
1585
+ }
1530
1586
  if (sessionOnly) return Promise.reject(new Error("Session tool not found: " + serverName + "/" + toolName));
1531
1587
  // Try in-app servers first (gated by extension connectivity for clay-browser).
1532
1588
  var localMcp = getLocalMcpServers();
@@ -1036,6 +1036,41 @@
1036
1036
  .file-viewer-btn.hidden { display: none; }
1037
1037
  .file-viewer-btn .lucide { width: 16px; height: 16px; }
1038
1038
 
1039
+ .file-viewer-live-status {
1040
+ display: inline-flex;
1041
+ align-items: center;
1042
+ gap: 6px;
1043
+ flex-shrink: 0;
1044
+ padding: 4px 8px;
1045
+ border: 1px solid var(--accent-25);
1046
+ border-radius: 999px;
1047
+ background: var(--accent-8);
1048
+ color: var(--accent);
1049
+ font-size: 11px;
1050
+ font-weight: 600;
1051
+ letter-spacing: 0.01em;
1052
+ }
1053
+
1054
+ .file-viewer-live-status.hidden { display: none; }
1055
+
1056
+ .file-viewer-live-dot {
1057
+ width: 6px;
1058
+ height: 6px;
1059
+ border-radius: 50%;
1060
+ background: currentColor;
1061
+ box-shadow: 0 0 0 0 var(--accent-25);
1062
+ }
1063
+
1064
+ .file-viewer-live-status.editing .file-viewer-live-dot {
1065
+ animation: markdown-live-pulse 1.15s ease-out infinite;
1066
+ }
1067
+
1068
+ .file-viewer-live-status.updated {
1069
+ border-color: var(--success-25);
1070
+ background: var(--success-8);
1071
+ color: var(--success);
1072
+ }
1073
+
1039
1074
  /* --- Markdown rendered view --- */
1040
1075
  .file-viewer-markdown {
1041
1076
  padding: 20px 24px;
@@ -1113,6 +1148,84 @@
1113
1148
 
1114
1149
  .file-viewer-markdown img { max-width: 100%; border-radius: 8px; }
1115
1150
 
1151
+ .file-viewer-markdown > .markdown-live-added,
1152
+ .file-viewer-markdown > .markdown-live-changed {
1153
+ position: relative;
1154
+ margin-left: -10px;
1155
+ margin-right: -10px;
1156
+ padding-left: 10px;
1157
+ padding-right: 10px;
1158
+ border-radius: 6px;
1159
+ background: var(--success-12);
1160
+ box-shadow: inset 2px 0 0 var(--success);
1161
+ animation: markdown-live-arrive 0.46s cubic-bezier(0.2, 0.8, 0.2, 1) both;
1162
+ transition: background 0.3s ease, box-shadow 0.3s ease, opacity 0.3s ease;
1163
+ }
1164
+
1165
+ .file-viewer-markdown > .markdown-live-removed {
1166
+ margin-left: -10px;
1167
+ margin-right: -10px;
1168
+ padding-left: 10px;
1169
+ padding-right: 10px;
1170
+ border-radius: 6px;
1171
+ background: var(--error-8);
1172
+ box-shadow: inset 2px 0 0 var(--error);
1173
+ color: color-mix(in srgb, var(--text-secondary) 72%, transparent);
1174
+ text-decoration-color: var(--error);
1175
+ transition: background 0.3s ease, box-shadow 0.3s ease, opacity 0.3s ease;
1176
+ }
1177
+
1178
+ .file-viewer-markdown > .markdown-live-focus {
1179
+ z-index: 1;
1180
+ opacity: 1;
1181
+ box-shadow: 0 10px 30px rgba(var(--shadow-rgb), 0.22);
1182
+ }
1183
+
1184
+ .file-viewer-markdown > .markdown-live-focus.markdown-live-added,
1185
+ .file-viewer-markdown > .markdown-live-focus.markdown-live-changed {
1186
+ box-shadow: inset 3px 0 0 var(--success), 0 0 0 1px var(--success), 0 10px 30px rgba(var(--shadow-rgb), 0.22);
1187
+ }
1188
+
1189
+ .file-viewer-markdown > .markdown-live-focus.markdown-live-removed {
1190
+ box-shadow: inset 3px 0 0 var(--error), 0 0 0 1px var(--error), 0 10px 30px rgba(var(--shadow-rgb), 0.22);
1191
+ }
1192
+
1193
+ .file-viewer-markdown > .markdown-live-seen.markdown-live-added,
1194
+ .file-viewer-markdown > .markdown-live-seen.markdown-live-changed {
1195
+ background: var(--success-8);
1196
+ box-shadow: inset 2px 0 0 var(--success-25);
1197
+ }
1198
+
1199
+ .file-viewer-markdown > .markdown-live-seen.markdown-live-removed {
1200
+ animation: markdown-live-depart 0.42s ease both;
1201
+ }
1202
+
1203
+ @keyframes markdown-live-pulse {
1204
+ 0% { box-shadow: 0 0 0 0 var(--accent-25); }
1205
+ 70% { box-shadow: 0 0 0 5px transparent; }
1206
+ 100% { box-shadow: 0 0 0 0 transparent; }
1207
+ }
1208
+
1209
+ @keyframes markdown-live-arrive {
1210
+ from { opacity: 0; transform: translateY(5px); }
1211
+ to { opacity: 1; transform: translateY(0); }
1212
+ }
1213
+
1214
+ @keyframes markdown-live-depart {
1215
+ 0%, 58% { opacity: 0.82; max-height: 800px; }
1216
+ 100% { opacity: 0; max-height: 0; margin-top: 0; margin-bottom: 0; overflow: hidden; }
1217
+ }
1218
+
1219
+ @media (prefers-reduced-motion: reduce) {
1220
+ .file-viewer-live-status.editing .file-viewer-live-dot,
1221
+ .file-viewer-markdown > .markdown-live-added,
1222
+ .file-viewer-markdown > .markdown-live-changed,
1223
+ .file-viewer-markdown > .markdown-live-removed,
1224
+ .file-viewer-markdown > .markdown-live-seen.markdown-live-removed {
1225
+ animation: none;
1226
+ }
1227
+ }
1228
+
1116
1229
  .file-viewer-body {
1117
1230
  flex: 1;
1118
1231
  overflow: auto;
@@ -636,11 +636,16 @@
636
636
  <div id="file-viewer" class="hidden">
637
637
  <div class="file-viewer-header">
638
638
  <span class="file-viewer-path" id="file-viewer-path"></span>
639
+ <span class="file-viewer-live-status hidden" id="file-viewer-live-status" aria-live="polite">
640
+ <span class="file-viewer-live-dot"></span>
641
+ <span class="file-viewer-live-label">Editing</span>
642
+ </span>
639
643
  <button class="file-viewer-btn hidden" id="file-viewer-render" title="Toggle rendered view"><i data-lucide="book-open"></i></button>
640
644
  <button class="file-viewer-btn hidden" id="file-viewer-pdf" title="Export PDF"><i data-lucide="file-down"></i></button>
641
645
  <button class="file-viewer-btn hidden" id="file-viewer-history" title="Edit history"><i data-lucide="clock"></i></button>
642
646
  <button class="file-viewer-btn" id="file-viewer-refresh" title="Refresh"><i data-lucide="refresh-cw"></i></button>
643
- <button class="file-viewer-btn" id="file-viewer-copy" title="Copy contents"><i data-lucide="copy"></i></button>
647
+ <button class="file-viewer-btn" id="file-viewer-copy" title="Copy contents" aria-label="Copy contents"><i data-lucide="copy"></i></button>
648
+ <button class="file-viewer-btn hidden" id="file-viewer-copy-formatted" title="Copy Markdown formatting" aria-label="Copy Markdown formatting"><i data-lucide="clipboard-copy"></i></button>
644
649
  <button class="file-viewer-btn" id="file-viewer-fullscreen" title="Toggle fullscreen"><i data-lucide="maximize-2"></i></button>
645
650
  <button class="file-viewer-btn" id="file-viewer-close" title="Close"><i data-lucide="x"></i></button>
646
651
  </div>
@@ -24,7 +24,8 @@ import { showPairDialog, handlePairCreated, handleSplitDelegation, showWorkerDel
24
24
  import { handleInputSync, autoResize, builtinCommands, setScheduleBtnDisabled } from './input.js';
25
25
  import { startThinking, appendThinking, stopThinking, resetThinkingGroup, createToolItem, updateToolExecuting, updateToolResult, markAllToolsDone, closeToolGroup, removeToolFromGroup, resetToolState, getTools, getPlanContent, setPlanContent, renderPlanBanner, renderPlanCard, getTodoTools, handleTodoWrite, handleTaskCreate, handleTaskUpdate, applyDeadSessionTodoCompaction, isPlanFilePath, enableMainInput, addTurnMeta, updateSubagentActivity, addSubagentToolEntry, markSubagentDone, initSubagentStop, updateSubagentProgress, updateSubagentTaskStatus, renderAskUserQuestion, markAskUserAnswered, renderPermissionRequest, markPermissionCancelled, markPermissionResolved, renderElicitationRequest, markElicitationResolved, renderUserDialogRequest, markUserDialogResolved, updateThinkingTokens } from './tools.js';
26
26
  import { showDoneNotification, playDoneSound, isNotifAlertEnabled, isNotifSoundEnabled } from './notifications.js';
27
- import { handleFsList, handleFsRead, handleFileChanged, handleDirChanged, handleFileHistory, handleGitDiff, handleFileAt, refreshIfOpen, getPendingNavigate, handleFsSearch } from './filebrowser.js';
27
+ import { handleFsList, handleFsRead, handleFileChanged, handleDirChanged, handleFileHistory, handleGitDiff, handleFileAt, refreshIfOpen, getPendingNavigate, handleFsSearch, presentMarkdownEdit } from './filebrowser.js';
28
+ import { beginMarkdownTurn, finishMarkdownTurn, markdownPathFromToolInput } from './markdown-live-edit.js';
28
29
  import { isProjectSettingsOpen, handleInstructionsRead, handleInstructionsWrite, handleProjectEnv, handleProjectEnvSaved, handleProjectSharedEnv, handleProjectSharedEnvSaved, handleProjectOwnerChanged } from './project-settings.js';
29
30
  import { updateSettingsModels, updateSettingsStats, updateDaemonConfig, handleSetPinResult, handleKeepAwakeChanged, handleInheritGroupsChanged, handleAutoContinueChanged, handleRestartResult, handleShutdownResult, handleSharedEnv, handleSharedEnvSaved, handleGlobalClaudeMdRead, handleGlobalClaudeMdWrite } from './server-settings.js';
30
31
  import { handleTermList, handleTermCreated, sendTerminalCommand, handleTermOutput, handleTermResized, handleTermExited, handleTermClosed } from './terminal.js';
@@ -783,6 +784,7 @@ export function processMessage(msg) {
783
784
 
784
785
  case "user_message":
785
786
  if (msg._internal) break;
787
+ if (!store.get('replayingHistory')) beginMarkdownTurn();
786
788
  resetThinkingGroup();
787
789
  if (msg.planContent) {
788
790
  setPlanContent(msg.planContent);
@@ -934,6 +936,8 @@ export function processMessage(msg) {
934
936
  getTools()[msg.id] = { el: null, name: msg.name, input: null, done: true, hidden: true };
935
937
  } else if (msg.name === "ask_user_questions") {
936
938
  getTools()[msg.id] = { el: null, name: msg.name, input: null, done: true, hidden: true };
939
+ } else if (msg.name === "present_markdown_edit" || (msg.name && msg.name.indexOf("present_markdown_edit") !== -1)) {
940
+ getTools()[msg.id] = { el: null, name: msg.name, input: null, done: true, hidden: true };
937
941
  } else if (getTodoTools()[msg.name]) {
938
942
  getTools()[msg.id] = { el: null, name: msg.name, input: null, done: true, hidden: true };
939
943
  } else {
@@ -997,9 +1001,12 @@ export function processMessage(msg) {
997
1001
  if (msg.content != null || msg.images || (tr && tr.name === "Edit" && tr.input && tr.input.old_string)) {
998
1002
  updateToolResult(msg.id, msg.content || "", msg.is_error || false, msg.images);
999
1003
  }
1000
- // Refresh file browser if an Edit/Write tool modified the open file
1001
- if (!msg.is_error && tr && (tr.name === "Edit" || tr.name === "Write") && tr.input && tr.input.file_path) {
1002
- refreshIfOpen(tr.input.file_path);
1004
+ // Refresh file browser if an Edit/Write tool modified the open file.
1005
+ // Multi-file Codex changes expose file_paths instead of file_path.
1006
+ var changedMarkdownPath = tr && tr.input ? markdownPathFromToolInput(tr.input) : null;
1007
+ var changedFilePath = tr && tr.input ? (tr.input.file_path || changedMarkdownPath) : null;
1008
+ if (!msg.is_error && tr && (tr.name === "Edit" || tr.name === "Write") && changedFilePath) {
1009
+ refreshIfOpen(changedFilePath);
1003
1010
  }
1004
1011
  }
1005
1012
  break;
@@ -1121,6 +1128,7 @@ export function processMessage(msg) {
1121
1128
  setStatus("connected");
1122
1129
  if (!store.get('loopActive')) enableMainInput();
1123
1130
  resetToolState();
1131
+ finishMarkdownTurn();
1124
1132
  stopUrgentBlink();
1125
1133
  if (document.hidden) {
1126
1134
  if (isNotifAlertEnabled() && !window._pushSubscription) showDoneNotification();
@@ -1326,6 +1334,10 @@ export function processMessage(msg) {
1326
1334
  handleFileChanged(msg);
1327
1335
  break;
1328
1336
 
1337
+ case "markdown_edit_present":
1338
+ if (!store.get('replayingHistory') && !store.get('dmMode')) presentMarkdownEdit(msg);
1339
+ break;
1340
+
1329
1341
  case "fs_dir_changed":
1330
1342
  handleDirChanged(msg);
1331
1343
  break;
@@ -5,6 +5,8 @@ import { closeSidebar } from './sidebar.js';
5
5
  import { closeTerminal } from './terminal.js';
6
6
  import { renderUnifiedDiff, renderSplitDiff } from './diff.js';
7
7
  import { initFileIcons, getFileIconSvg, getFolderIconSvg } from './fileicons.js';
8
+ import { copyMarkdownFormatting } from './rich-clipboard.js';
9
+ import { animateMarkdownChange, beginMarkdownPresentation, cancelMarkdownFollow, isFollowingMarkdown } from './markdown-live-edit.js';
8
10
 
9
11
  var ctx;
10
12
  var showDropHint = function () {};
@@ -147,10 +149,18 @@ export function initFileBrowser(_ctx) {
147
149
  if (currentContent) copyToClipboard(currentContent);
148
150
  });
149
151
 
152
+ document.getElementById("file-viewer-copy-formatted").addEventListener("click", function () {
153
+ if (!isRendered || !currentIsMarkdown) return;
154
+ copyMarkdownFormatting(currentContent).catch(function (err) {
155
+ console.error("Markdown formatting copy failed:", err);
156
+ });
157
+ });
158
+
150
159
  // Markdown render toggle
151
160
  document.getElementById("file-viewer-render").addEventListener("click", function () {
152
161
  if (!currentContent || !currentIsMarkdown) return;
153
162
  isRendered = !isRendered;
163
+ if (!isRendered && isFollowingMarkdown(currentFilePath)) cancelMarkdownFollow();
154
164
  renderBody();
155
165
  });
156
166
 
@@ -172,6 +182,7 @@ export function initFileBrowser(_ctx) {
172
182
  // History button
173
183
  document.getElementById("file-viewer-history").addEventListener("click", function () {
174
184
  if (currentHistoryEntries.length === 0) return;
185
+ if (isFollowingMarkdown(currentFilePath)) cancelMarkdownFollow();
175
186
  historyVisible = !historyVisible;
176
187
  inlineDiffActive = false;
177
188
  compareMode = false;
@@ -191,6 +202,9 @@ export function initFileBrowser(_ctx) {
191
202
  if (!currentFilePath) return;
192
203
  viewerRefreshBtn.classList.add("spinning");
193
204
  setTimeout(function () { viewerRefreshBtn.classList.remove("spinning"); }, 500);
205
+ // Refresh the content without kicking a rendered Markdown document back
206
+ // to source mode. Automatic watcher refreshes use the same state path.
207
+ pendingRefresh = true;
194
208
  requestFileContent(currentFilePath);
195
209
  });
196
210
  }
@@ -395,6 +409,7 @@ function sendUnwatch() {
395
409
  }
396
410
 
397
411
  export function closeFileViewer() {
412
+ if (currentFilePath && isFollowingMarkdown(currentFilePath)) cancelMarkdownFollow();
398
413
  sendUnwatch();
399
414
  inlineDiffActive = false;
400
415
  ctx.fileViewerEl.classList.remove("file-viewer-wide");
@@ -439,9 +454,14 @@ export function resetFileBrowser() {
439
454
  }
440
455
 
441
456
  var pendingOpenMode = null; // { type: "diff", oldStr, newStr } or null
457
+ var pendingRenderedOpen = false;
442
458
 
443
459
  export function openFile(filePath, opts) {
444
460
  if (!filePath) return;
461
+ if (followedFileChanged(filePath) || (opts && opts.diff && isFollowingMarkdown(filePath))) {
462
+ cancelMarkdownFollow();
463
+ }
464
+ pendingRenderedOpen = !!(opts && opts.rendered);
445
465
  if (opts && opts.diff) {
446
466
  pendingOpenMode = { type: "diff", oldStr: opts.diff.oldStr, newStr: opts.diff.newStr };
447
467
  } else {
@@ -450,10 +470,27 @@ export function openFile(filePath, opts) {
450
470
  requestFileContent(filePath);
451
471
  }
452
472
 
453
- function renderBody() {
473
+ export function presentMarkdownEdit(msg) {
474
+ if (!msg || !beginMarkdownPresentation(msg.path)) return;
475
+ pendingOpenMode = null;
476
+ pendingRenderedOpen = true;
477
+ pendingRefresh = false;
478
+ showFileContent({
479
+ path: msg.path,
480
+ content: typeof msg.content === "string" ? msg.content : "",
481
+ size: msg.size || 0,
482
+ });
483
+ }
484
+
485
+ function followedFileChanged(filePath) {
486
+ return currentFilePath && isFollowingMarkdown(currentFilePath) && !isFollowingMarkdown(filePath);
487
+ }
488
+
489
+ function renderBody(previousMarkdown) {
454
490
  var bodyEl = document.getElementById("file-viewer-body");
455
491
  var renderBtn = document.getElementById("file-viewer-render");
456
492
  var pdfBtn = document.getElementById("file-viewer-pdf");
493
+ var formattedCopyBtn = document.getElementById("file-viewer-copy-formatted");
457
494
 
458
495
  if (isRendered) {
459
496
  bodyEl.innerHTML = '<div class="file-viewer-markdown">' + renderMarkdown(currentContent) + '</div>';
@@ -467,11 +504,16 @@ function renderBody() {
467
504
  imgs[i].src = "api/file?path=" + encodeURIComponent(resolvedPath);
468
505
  }
469
506
  }
507
+ var markdownEl = bodyEl.querySelector(".file-viewer-markdown");
508
+ if (previousMarkdown != null && isFollowingMarkdown(currentFilePath)) {
509
+ animateMarkdownChange(markdownEl, previousMarkdown, currentContent, renderMarkdown);
510
+ }
470
511
  highlightCodeBlocks(bodyEl);
471
512
  renderMermaidBlocks(bodyEl);
472
513
  renderBtn.classList.add("active");
473
514
  renderBtn.title = "Show raw";
474
515
  pdfBtn.classList.remove("hidden");
516
+ formattedCopyBtn.classList.remove("hidden");
475
517
  } else {
476
518
  var pre = document.createElement("pre");
477
519
  var code = document.createElement("code");
@@ -486,6 +528,7 @@ function renderBody() {
486
528
  renderBtn.classList.remove("active");
487
529
  renderBtn.title = "Render markdown";
488
530
  pdfBtn.classList.add("hidden");
531
+ formattedCopyBtn.classList.add("hidden");
489
532
  }
490
533
  refreshIcons();
491
534
  }
@@ -661,6 +704,7 @@ function renderFilteredTree(container, tree, depth, query) {
661
704
  (function (filePath, rowEl) {
662
705
  rowEl.addEventListener("click", function (e) {
663
706
  e.stopPropagation();
707
+ if (followedFileChanged(filePath)) cancelMarkdownFollow();
664
708
  var prev = ctx.fileTreeEl.querySelector(".file-tree-item.active");
665
709
  if (prev) prev.classList.remove("active");
666
710
  rowEl.classList.add("active");
@@ -891,6 +935,7 @@ function renderEntries(container, entries, depth) {
891
935
  (function (filePath, rowEl) {
892
936
  rowEl.addEventListener("click", function (e) {
893
937
  e.stopPropagation();
938
+ if (followedFileChanged(filePath)) cancelMarkdownFollow();
894
939
  // Mark active
895
940
  var prev = ctx.fileTreeEl.querySelector(".file-tree-item.active");
896
941
  if (prev) prev.classList.remove("active");
@@ -915,6 +960,10 @@ function showFileContent(msg) {
915
960
  var pathEl = document.getElementById("file-viewer-path");
916
961
  var bodyEl = document.getElementById("file-viewer-body");
917
962
  var renderBtn = document.getElementById("file-viewer-render");
963
+ var copyBtn = document.getElementById("file-viewer-copy");
964
+ var previousContent = currentContent;
965
+ var previousPath = currentFilePath;
966
+ var previousWasMarkdown = currentIsMarkdown;
918
967
 
919
968
  pathEl.textContent = msg.path;
920
969
  var keepRenderState = pendingRefresh && msg.path === currentFilePath;
@@ -924,9 +973,15 @@ function showFileContent(msg) {
924
973
  currentFilePath = msg.path;
925
974
  currentIsMarkdown = false;
926
975
  if (!keepRenderState) isRendered = false;
976
+ var requestedExt = msg.path.split(".").pop().toLowerCase();
977
+ if (pendingRenderedOpen && (requestedExt === "md" || requestedExt === "mdx")) {
978
+ currentIsMarkdown = true;
979
+ isRendered = true;
980
+ }
927
981
  renderBtn.classList.add("hidden");
928
982
  renderBtn.classList.remove("active");
929
983
  document.getElementById("file-viewer-pdf").classList.add("hidden");
984
+ document.getElementById("file-viewer-copy-formatted").classList.add("hidden");
930
985
 
931
986
  if (msg.error) {
932
987
  bodyEl.innerHTML = '<div class="file-tree-error">' + escapeHtml(msg.error) + '</div>';
@@ -938,17 +993,25 @@ function showFileContent(msg) {
938
993
  }
939
994
  } else {
940
995
  currentContent = msg.content;
941
- var ext = msg.path.split(".").pop().toLowerCase();
996
+ var ext = requestedExt;
942
997
  currentIsMarkdown = (ext === "md" || ext === "mdx");
998
+ if (pendingRenderedOpen && currentIsMarkdown) isRendered = true;
943
999
 
944
1000
  if (currentIsMarkdown) {
945
1001
  renderBtn.classList.remove("hidden");
946
1002
  renderBtn.title = "Render markdown";
1003
+ copyBtn.title = "Copy Markdown source";
1004
+ } else {
1005
+ copyBtn.title = "Copy contents";
947
1006
  }
948
1007
 
949
1008
  // Show raw by default, use renderBody for markdown toggle
950
1009
  if (currentIsMarkdown) {
951
- renderBody();
1010
+ var transitionFrom = keepRenderState && prevRendered && previousWasMarkdown &&
1011
+ isFollowingMarkdown(msg.path) && pathsReferToSameFile(previousPath, msg.path)
1012
+ ? (previousContent == null ? "" : previousContent)
1013
+ : null;
1014
+ renderBody(transitionFrom);
952
1015
  } else {
953
1016
  renderCodeWithLineNumbers(bodyEl, msg.content, ext);
954
1017
  }
@@ -974,9 +1037,11 @@ function showFileContent(msg) {
974
1037
  historyBtn2.classList.remove("active");
975
1038
  requestFileHistory(msg.path);
976
1039
  showInlineDiff(diffOpts.oldStr, diffOpts.newStr);
1040
+ pendingRenderedOpen = false;
977
1041
  return;
978
1042
  }
979
1043
  pendingOpenMode = null;
1044
+ pendingRenderedOpen = false;
980
1045
 
981
1046
  // Request edit history for this file (skip on auto-refresh)
982
1047
  if (!keepRenderState) {
@@ -993,6 +1058,13 @@ function showFileContent(msg) {
993
1058
  }
994
1059
  }
995
1060
 
1061
+ function pathsReferToSameFile(left, right) {
1062
+ if (!left || !right) return false;
1063
+ var a = String(left).replace(/\\/g, "/");
1064
+ var b = String(right).replace(/\\/g, "/");
1065
+ return a === b || a.endsWith("/" + b) || b.endsWith("/" + a);
1066
+ }
1067
+
996
1068
  export function handleFileChanged(msg) {
997
1069
  if (!msg.path || msg.path !== currentFilePath) return;
998
1070
  if (ctx.fileViewerEl.classList.contains("hidden")) return;
@@ -0,0 +1,305 @@
1
+ var followedPath = null;
2
+ var suppressedForTurn = false;
3
+ var cleanupTimer = null;
4
+ var tourTimer = null;
5
+ var statusTimer = null;
6
+ var followExpiryTimer = null;
7
+ var sawChange = false;
8
+ var tourVersion = 0;
9
+ var touring = false;
10
+ var tourViewer = null;
11
+
12
+ function normalizedPath(filePath) {
13
+ return String(filePath || "").replace(/\\/g, "/");
14
+ }
15
+
16
+ export function isMarkdownPath(filePath) {
17
+ return /\.mdx?$/i.test(normalizedPath(filePath));
18
+ }
19
+
20
+ export function markdownPathFromToolInput(input) {
21
+ if (!input || typeof input !== "object") return null;
22
+ if (isMarkdownPath(input.file_path)) return input.file_path;
23
+ var paths = Array.isArray(input.file_paths) ? input.file_paths : [];
24
+ for (var i = 0; i < paths.length; i++) {
25
+ if (isMarkdownPath(paths[i])) return paths[i];
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export function pathsMatch(left, right) {
31
+ var a = normalizedPath(left);
32
+ var b = normalizedPath(right);
33
+ if (!a || !b) return false;
34
+ return a === b || a.endsWith("/" + b) || b.endsWith("/" + a);
35
+ }
36
+
37
+ function statusElement() {
38
+ return document.getElementById("file-viewer-live-status");
39
+ }
40
+
41
+ function setStatus(state, label) {
42
+ var el = statusElement();
43
+ if (!el) return;
44
+ clearTimeout(statusTimer);
45
+ el.classList.remove("hidden", "editing", "updated");
46
+ el.classList.add(state);
47
+ var labelEl = el.querySelector(".file-viewer-live-label");
48
+ if (labelEl) labelEl.textContent = label;
49
+ }
50
+
51
+ function hideStatusSoon() {
52
+ clearTimeout(statusTimer);
53
+ statusTimer = setTimeout(function () {
54
+ var el = statusElement();
55
+ if (el) el.classList.add("hidden");
56
+ }, 1800);
57
+ }
58
+
59
+ export function beginMarkdownTurn() {
60
+ stopChangeTour();
61
+ clearTimeout(cleanupTimer);
62
+ clearLiveClasses();
63
+ suppressedForTurn = false;
64
+ followedPath = null;
65
+ sawChange = false;
66
+ clearTimeout(followExpiryTimer);
67
+ clearTimeout(statusTimer);
68
+ var el = statusElement();
69
+ if (el) el.classList.add("hidden");
70
+ }
71
+
72
+ export function beginMarkdownPresentation(filePath) {
73
+ if (suppressedForTurn || !isMarkdownPath(filePath)) return false;
74
+ clearTimeout(followExpiryTimer);
75
+ if (!pathsMatch(followedPath, filePath)) sawChange = false;
76
+ followedPath = normalizedPath(filePath);
77
+ setStatus("editing", "Editing");
78
+ return true;
79
+ }
80
+
81
+ export function isFollowingMarkdown(filePath) {
82
+ return !suppressedForTurn && pathsMatch(followedPath, filePath);
83
+ }
84
+
85
+ export function cancelMarkdownFollow() {
86
+ followedPath = null;
87
+ suppressedForTurn = true;
88
+ clearTimeout(cleanupTimer);
89
+ stopChangeTour();
90
+ clearTimeout(statusTimer);
91
+ clearTimeout(followExpiryTimer);
92
+ var status = statusElement();
93
+ if (status) status.classList.add("hidden");
94
+ clearLiveClasses();
95
+ }
96
+
97
+ export function finishMarkdownTurn() {
98
+ if (!followedPath || suppressedForTurn) return;
99
+ if (sawChange && !touring) {
100
+ setStatus("updated", "Updated");
101
+ hideStatusSoon();
102
+ } else {
103
+ var el = statusElement();
104
+ if (el) el.classList.add("hidden");
105
+ }
106
+ clearTimeout(followExpiryTimer);
107
+ followExpiryTimer = setTimeout(function () { followedPath = null; }, 4000);
108
+ }
109
+
110
+ function blockSignature(node) {
111
+ return node.outerHTML.replace(/\s+/g, " ").trim();
112
+ }
113
+
114
+ export function diffBlockSignatures(oldSignatures, newSignatures) {
115
+ var oldLength = oldSignatures.length;
116
+ var newLength = newSignatures.length;
117
+ var rows = new Array(oldLength + 1);
118
+ var i;
119
+ var j;
120
+ for (i = 0; i <= oldLength; i++) rows[i] = new Uint32Array(newLength + 1);
121
+
122
+ for (i = oldLength - 1; i >= 0; i--) {
123
+ for (j = newLength - 1; j >= 0; j--) {
124
+ rows[i][j] = oldSignatures[i] === newSignatures[j]
125
+ ? rows[i + 1][j + 1] + 1
126
+ : Math.max(rows[i + 1][j], rows[i][j + 1]);
127
+ }
128
+ }
129
+
130
+ var matches = [];
131
+ i = 0;
132
+ j = 0;
133
+ while (i < oldLength && j < newLength) {
134
+ if (oldSignatures[i] === newSignatures[j]) {
135
+ matches.push({ oldIndex: i, newIndex: j });
136
+ i++;
137
+ j++;
138
+ } else if (rows[i + 1][j] >= rows[i][j + 1]) {
139
+ i++;
140
+ } else {
141
+ j++;
142
+ }
143
+ }
144
+ return matches;
145
+ }
146
+
147
+ function clearLiveClasses() {
148
+ var viewer = document.getElementById("file-viewer-body");
149
+ if (!viewer) return;
150
+ var removed = viewer.querySelectorAll(".markdown-live-removed");
151
+ for (var i = 0; i < removed.length; i++) removed[i].remove();
152
+ var active = viewer.querySelectorAll(".markdown-live-added, .markdown-live-changed, .markdown-live-focus, .markdown-live-seen");
153
+ for (var j = 0; j < active.length; j++) {
154
+ active[j].classList.remove("markdown-live-added", "markdown-live-changed", "markdown-live-focus", "markdown-live-seen");
155
+ }
156
+ }
157
+
158
+ function detachTourInterruption() {
159
+ if (tourViewer) {
160
+ tourViewer.removeEventListener("wheel", interruptChangeTour);
161
+ tourViewer.removeEventListener("pointerdown", interruptChangeTour);
162
+ tourViewer.removeEventListener("touchstart", interruptChangeTour);
163
+ }
164
+ document.removeEventListener("keydown", interruptChangeTour);
165
+ tourViewer = null;
166
+ }
167
+
168
+ function stopChangeTour() {
169
+ tourVersion++;
170
+ touring = false;
171
+ clearTimeout(tourTimer);
172
+ detachTourInterruption();
173
+ var focused = document.querySelectorAll(".markdown-live-focus");
174
+ for (var i = 0; i < focused.length; i++) focused[i].classList.remove("markdown-live-focus");
175
+ }
176
+
177
+ function interruptChangeTour() {
178
+ if (!touring) return;
179
+ stopChangeTour();
180
+ setStatus("updated", "Updated");
181
+ clearTimeout(cleanupTimer);
182
+ cleanupTimer = setTimeout(function () {
183
+ clearLiveClasses();
184
+ hideStatusSoon();
185
+ }, 1800);
186
+ }
187
+
188
+ function attachTourInterruption(viewer) {
189
+ tourViewer = viewer;
190
+ viewer.addEventListener("wheel", interruptChangeTour, { passive: true });
191
+ viewer.addEventListener("pointerdown", interruptChangeTour);
192
+ viewer.addEventListener("touchstart", interruptChangeTour, { passive: true });
193
+ document.addEventListener("keydown", interruptChangeTour);
194
+ }
195
+
196
+ export function changeTourDelay(text) {
197
+ var length = String(text || "").trim().length;
198
+ return Math.min(1800, Math.max(850, 700 + length * 3));
199
+ }
200
+
201
+ function startChangeTour(viewer, targets) {
202
+ stopChangeTour();
203
+ touring = true;
204
+ attachTourInterruption(viewer);
205
+ var version = tourVersion;
206
+ var index = 0;
207
+ var reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
208
+
209
+ function visitNext() {
210
+ if (!touring || version !== tourVersion) return;
211
+ if (index >= targets.length) {
212
+ var last = targets[targets.length - 1];
213
+ if (last && last.isConnected) {
214
+ last.classList.remove("markdown-live-focus");
215
+ last.classList.add("markdown-live-seen");
216
+ }
217
+ touring = false;
218
+ detachTourInterruption();
219
+ setStatus("updated", "Updated");
220
+ clearTimeout(cleanupTimer);
221
+ cleanupTimer = setTimeout(function () {
222
+ clearLiveClasses();
223
+ hideStatusSoon();
224
+ }, 1400);
225
+ return;
226
+ }
227
+
228
+ var previous = index > 0 ? targets[index - 1] : null;
229
+ if (previous && previous.isConnected) {
230
+ previous.classList.remove("markdown-live-focus");
231
+ previous.classList.add("markdown-live-seen");
232
+ }
233
+ var target = targets[index];
234
+ index++;
235
+ if (!target || !target.isConnected) {
236
+ visitNext();
237
+ return;
238
+ }
239
+ target.classList.add("markdown-live-focus");
240
+ setStatus("editing", "Change " + index + " of " + targets.length);
241
+ target.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "center" });
242
+ tourTimer = setTimeout(visitNext, reduceMotion ? 250 : changeTourDelay(target.textContent));
243
+ }
244
+
245
+ requestAnimationFrame(visitNext);
246
+ }
247
+
248
+ function changedRegionStart(matches, oldIndex) {
249
+ var newIndex = 0;
250
+ for (var i = 0; i < matches.length; i++) {
251
+ if (matches[i].oldIndex >= oldIndex) break;
252
+ newIndex = matches[i].newIndex + 1;
253
+ }
254
+ return newIndex;
255
+ }
256
+
257
+ export function animateMarkdownChange(markdownEl, oldMarkdown, newMarkdown, renderFn) {
258
+ if (!markdownEl || oldMarkdown === newMarkdown) return false;
259
+ clearLiveClasses();
260
+
261
+ var oldRoot = document.createElement("div");
262
+ var newRoot = document.createElement("div");
263
+ oldRoot.innerHTML = renderFn(oldMarkdown);
264
+ newRoot.innerHTML = renderFn(newMarkdown);
265
+
266
+ var oldBlocks = Array.from(oldRoot.children);
267
+ var newBlocks = Array.from(newRoot.children);
268
+ var liveBlocks = Array.from(markdownEl.children);
269
+ var oldSignatures = oldBlocks.map(blockSignature);
270
+ var newSignatures = newBlocks.map(blockSignature);
271
+ var matches = diffBlockSignatures(oldSignatures, newSignatures);
272
+ var matchedOld = new Set(matches.map(function (match) { return match.oldIndex; }));
273
+ var matchedNew = new Set(matches.map(function (match) { return match.newIndex; }));
274
+ for (var i = 0; i < liveBlocks.length; i++) {
275
+ if (matchedNew.has(i)) continue;
276
+ liveBlocks[i].classList.add("markdown-live-added");
277
+ }
278
+
279
+ for (var j = 0; j < oldBlocks.length; j++) {
280
+ if (matchedOld.has(j)) continue;
281
+ var clone = oldBlocks[j].cloneNode(true);
282
+ clone.classList.add("markdown-live-removed");
283
+ clone.setAttribute("aria-hidden", "true");
284
+ var insertionIndex = changedRegionStart(matches, j);
285
+ var anchor = insertionIndex < liveBlocks.length ? liveBlocks[insertionIndex] : null;
286
+ markdownEl.insertBefore(clone, anchor);
287
+ }
288
+
289
+ var changedTargets = Array.from(markdownEl.children).filter(function (element) {
290
+ return element.classList.contains("markdown-live-added") || element.classList.contains("markdown-live-removed");
291
+ });
292
+ if (changedTargets.length === 0) return false;
293
+ sawChange = true;
294
+ var hasRemoved = oldBlocks.length !== matchedOld.size;
295
+ var hasAdded = newBlocks.length !== matchedNew.size;
296
+ if (hasRemoved && hasAdded) {
297
+ for (var k = 0; k < liveBlocks.length; k++) {
298
+ if (!matchedNew.has(k)) liveBlocks[k].classList.add("markdown-live-changed");
299
+ }
300
+ }
301
+
302
+ clearTimeout(cleanupTimer);
303
+ startChangeTour(document.getElementById("file-viewer-body"), changedTargets);
304
+ return true;
305
+ }
@@ -0,0 +1,83 @@
1
+ import { renderMarkdown } from './markdown.js';
2
+ import { showToast } from './utils.js';
3
+
4
+ // Rich-text clipboards use HTML as their transport, but this payload contains
5
+ // only Markdown semantics. Clay's rendered DOM, theme, syntax highlighting,
6
+ // spacing, and typography never enter the clipboard.
7
+ export function buildMarkdownClipboardContent(markdown) {
8
+ var container = document.createElement("div");
9
+ container.innerHTML = renderMarkdown(markdown || "");
10
+
11
+ var elements = container.querySelectorAll("*");
12
+ for (var i = 0; i < elements.length; i++) {
13
+ var el = elements[i];
14
+ el.removeAttribute("class");
15
+ el.removeAttribute("style");
16
+ el.removeAttribute("id");
17
+ el.removeAttribute("contenteditable");
18
+ el.removeAttribute("target");
19
+ el.removeAttribute("rel");
20
+ var dataNames = [];
21
+ for (var ai = 0; ai < el.attributes.length; ai++) {
22
+ if (el.attributes[ai].name.indexOf("data-") === 0) dataNames.push(el.attributes[ai].name);
23
+ }
24
+ for (var di = 0; di < dataNames.length; di++) el.removeAttribute(dataNames[di]);
25
+ }
26
+
27
+ return {
28
+ html: '<meta charset="utf-8">' + container.innerHTML,
29
+ text: container.innerText || container.textContent || "",
30
+ };
31
+ }
32
+
33
+ function legacyCopyHtml(html) {
34
+ var container = document.createElement("div");
35
+ container.contentEditable = "true";
36
+ container.style.cssText = "position:fixed;left:-10000px;top:0;width:1px;height:1px;overflow:hidden";
37
+ container.innerHTML = html;
38
+ document.body.appendChild(container);
39
+
40
+ var selection = window.getSelection();
41
+ var savedRanges = [];
42
+ for (var i = 0; selection && i < selection.rangeCount; i++) {
43
+ savedRanges.push(selection.getRangeAt(i).cloneRange());
44
+ }
45
+ var range = document.createRange();
46
+ range.selectNodeContents(container);
47
+ if (selection) {
48
+ selection.removeAllRanges();
49
+ selection.addRange(range);
50
+ }
51
+ var copied = document.execCommand("copy");
52
+ if (selection) {
53
+ selection.removeAllRanges();
54
+ for (var ri = 0; ri < savedRanges.length; ri++) selection.addRange(savedRanges[ri]);
55
+ }
56
+ container.remove();
57
+ if (!copied) return Promise.reject(new Error("Formatted copy is not supported by this browser"));
58
+ return Promise.resolve();
59
+ }
60
+
61
+ export function copyMarkdownFormatting(markdown) {
62
+ var content = buildMarkdownClipboardContent(markdown);
63
+ var copyPromise;
64
+
65
+ if (navigator.clipboard && navigator.clipboard.write && typeof ClipboardItem !== "undefined") {
66
+ var item = new ClipboardItem({
67
+ "text/html": new Blob([content.html], { type: "text/html" }),
68
+ "text/plain": new Blob([content.text], { type: "text/plain" }),
69
+ });
70
+ copyPromise = navigator.clipboard.write([item]).catch(function () {
71
+ return legacyCopyHtml(content.html);
72
+ });
73
+ } else {
74
+ copyPromise = legacyCopyHtml(content.html);
75
+ }
76
+
77
+ return copyPromise.then(function () {
78
+ showToast("Copied Markdown formatting");
79
+ }).catch(function (err) {
80
+ showToast("Could not copy Markdown formatting", "warn");
81
+ throw err;
82
+ });
83
+ }
package/lib/sdk-bridge.js CHANGED
@@ -577,6 +577,14 @@ function createSDKBridge(opts) {
577
577
  return { behavior: "allow", updatedInput: input };
578
578
  }
579
579
 
580
+ // This tool only prepares a read-only UI projection for the document the
581
+ // agent is about to edit. The subsequent Edit/Write keeps its own normal
582
+ // permission behavior.
583
+ if (toolName.indexOf("mcp__clay-documents__present_markdown_edit") === 0 ||
584
+ toolName.indexOf("clay-documents__present_markdown_edit") !== -1) {
585
+ return { behavior: "allow", updatedInput: input };
586
+ }
587
+
580
588
  // Auto-approve read-only email MCP tools.
581
589
  // These only read data from accounts the user explicitly checked.
582
590
  // Write operations (send, reply, mark_read) still require permission.
@@ -0,0 +1,16 @@
1
+ // Live Markdown presentation tool for project sessions.
2
+
3
+ var buildShape = require("./session-spawn-mcp-server").buildShape;
4
+
5
+ function getToolDefs(handlers) {
6
+ return [{
7
+ name: "present_markdown_edit",
8
+ description: "Call once per targeted document, immediately before its first Edit or Write, when the user's primary request is to create or revise Markdown. Pass the resolved .md or .mdx path. Do not call for incidental documentation changes made as part of coding, maintenance, tests, or refactoring. This prepares Clay's rendered document view so the user can watch every change.",
9
+ inputSchema: buildShape({
10
+ path: { type: "string", description: "Project-relative or absolute path to the Markdown document that will be edited." },
11
+ }, ["path"]),
12
+ handler: function (args) { return handlers.present(args || {}); },
13
+ }];
14
+ }
15
+
16
+ module.exports = { getToolDefs: getToolDefs };
@@ -2,7 +2,7 @@
2
2
 
3
3
  var buildShape = require("./session-spawn-mcp-server").buildShape;
4
4
 
5
- var MEMORY_CONTRACT = "Shared project memory that persists across sessions and is visible to the user and other Clay agents. People may use the board freely. As a Clay agent, use notes specifically for actionable or durable work memory: checklists and to-do lists, work goals, handoffs, unfinished work, durable decisions and constraints, and knowledge that should still be available after the current session ends. Every note starts with a concise plain-text title on the first line. Add a detailed body whenever another worker would otherwise need to reconstruct the context. Keep one coherent topic per note, update an existing note instead of adding a near-duplicate, and delete notes that stop being true. Do not use notes as a transcript, a routine progress log, or an announcement of your own activity.";
5
+ var MEMORY_CONTRACT = "The sticky-note board persists across sessions and is a user-facing artifact shared with people and Clay agents, not private agent scratch space. Default to not writing. Create a note proactively only when the user explicitly asks to remember or track something, or when all of these are true: it will remain useful after the current task and session, it is not already adequately recorded in the repository or another note, and the user would likely be glad to find it on the board a week later. Good notes capture an unresolved commitment, durable product decision, user preference, constraint, or handoff that will materially change future work. Never create a note merely because work is important, lengthy, spans agents or restarts, or might help another agent. Do not record completed work, implementation details, test results, investigation logs, transient blockers, conversation summaries, or announcements of your own activity. When uncertain, do not write. Updates are visible too: update only when durable state materially changes, and remove a note created by your session when it stops being useful instead of turning it into a completion log. Put a concise plain-text title on the first line, stay focused on one topic, and include only the context needed for future action.";
6
6
 
7
7
  function getToolDefs(handlers) {
8
8
  return [
@@ -14,10 +14,10 @@ function getToolDefs(handlers) {
14
14
  },
15
15
  {
16
16
  name: "write_note",
17
- description: MEMORY_CONTRACT + " Create a new note, or update an existing note by id. Long task and handoff notes are allowed, with a generous 20000-character abuse guard.",
17
+ description: MEMORY_CONTRACT + " Create a new note, or update an existing note by id. Before creating, apply this test: would the user likely choose to keep this visible on their board next week? If the answer is unclear, do not call this tool. The 20000-character limit is an abuse guard, not a target.",
18
18
  inputSchema: buildShape({
19
19
  id: { type: "string", description: "Existing note id to update. Omit to create a note." },
20
- text: { type: "string", description: "Sticky-note text. Put the title on the first line, followed by the detailed handoff body. The board permits up to 20000 characters." },
20
+ text: { type: "string", description: "User-facing sticky-note text with a concise title on the first line and only durable, action-relevant context below it." },
21
21
  color: { type: "string", enum: ["yellow", "blue", "green", "pink", "orange", "purple"], description: "Optional sticky-note color." },
22
22
  }, ["text"]),
23
23
  handler: function (args) { return handlers.write(args || {}); },
package/lib/ws-schema.js CHANGED
@@ -106,6 +106,7 @@ var schema = {
106
106
  "subagent_done": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Subagent task completed" },
107
107
  "task_started": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Background task started" },
108
108
  "task_progress": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Background task progress update" },
109
+ "markdown_edit_present": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Open a user-requested Markdown edit with a before snapshot" },
109
110
 
110
111
  // -----------------------------------------------------------------------
111
112
  // Permissions
@@ -470,6 +470,7 @@ function flattenEvent(notification, state) {
470
470
  toolName: "Edit",
471
471
  input: {
472
472
  changes: changeDesc,
473
+ file_paths: changes.map(function(c) { return c.path; }).filter(Boolean),
473
474
  file_path: primaryPath || undefined,
474
475
  },
475
476
  });
@@ -842,6 +843,8 @@ function createCodexQueryHandle(appServer, queryOpts) {
842
843
  permissionToolName = "mcp__clay-notes__" + permissionToolName;
843
844
  } else if (permissionToolName === "send_to_partner" || permissionToolName === "read_partner") {
844
845
  permissionToolName = "mcp__clay-sessions__" + permissionToolName;
846
+ } else if (permissionToolName === "present_markdown_edit") {
847
+ permissionToolName = "mcp__clay-documents__" + permissionToolName;
845
848
  }
846
849
  var permission = canUseTool
847
850
  ? Promise.resolve(canUseTool(permissionToolName, dynamicParams.arguments || {}, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",