drafted 1.7.17 → 1.7.18

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/mcp/server.mjs CHANGED
@@ -10,7 +10,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { execFile } from 'child_process';
12
12
  import { createHash } from 'node:crypto';
13
- import { readFileSync, existsSync, realpathSync } from 'fs';
13
+ import { readFileSync, existsSync, realpathSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
14
14
  import { join, dirname, basename, extname, resolve } from 'path';
15
15
  import { homedir } from 'os';
16
16
  import { fileURLToPath } from 'url';
@@ -19,6 +19,8 @@ import { z } from 'zod';
19
19
  import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
20
20
  import WebSocket from 'ws';
21
21
  import { LAYERS } from '../src/shared/constants.mjs';
22
+ import { emptyExcalidrawScene, excalidrawSceneFromMermaid, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
23
+ import { UMAMI_EVENTS, trackUmamiEvent } from '../server/lib/umami.mjs';
22
24
 
23
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
26
 
@@ -41,8 +43,8 @@ const PACKAGE_VERSION = (() => {
41
43
  const requestState = new AsyncLocalStorage();
42
44
  const standaloneState = { sessionId: null, projectId: null, projectMeta: null };
43
45
 
44
- // Per-MCP-session sticky state. Keyed by Clerk sessionId (HTTP /mcp route)
45
- // or '__stdio__' for the long-lived stdio process. Holds active-project,
46
+ // Per-MCP-session sticky state. Keyed by the Drafted session id bound to
47
+ // this MCP instance, or '__stdio__' for the long-lived stdio process. Holds active-project,
46
48
  // loaded-skill, and cached-org state that must persist across HTTP request
47
49
  // boundaries so a `project open` followed by `frame.read` in a separate
48
50
  // request still has an active project.
@@ -63,6 +65,7 @@ function getOrCreateSessionState(sid) {
63
65
  cachedOrgId: null,
64
66
  cachedOrgIdTime: 0,
65
67
  wsSessionId: null,
68
+ pendingDeviceCode: null,
66
69
  };
67
70
  sessionStates.set(key, s);
68
71
  }
@@ -99,6 +102,7 @@ export function runWithRequestState(initial, fn) {
99
102
  // Stdio mode uses the `mcpServer` singleton (built once at module load).
100
103
 
101
104
  export function createMcpServer() {
105
+ trackUmamiEvent(UMAMI_EVENTS.MCP_CONNECTED, { source: 'mcp' });
102
106
  const server = new McpServer({
103
107
  name: 'drafted',
104
108
  version: PACKAGE_VERSION,
@@ -112,6 +116,9 @@ SKILLS: Drafted has a skill library -- reusable agent instructions stored as SKI
112
116
 
113
117
  BREADCRUMBS: When a frame you write or read corresponds to a file in the user's codebase (a component spec, wireframe for a route, design doc for a module), leave a comment in that code file using the canonical token "drafted:<frameId>" wrapped in the file's comment syntax (e.g. "// drafted:abc-123..." for JS/TS, "# drafted:abc-123..." for Python/YAML, "<!-- drafted:abc-123... -->" for HTML/Markdown). Project-level references use "drafted-project:<projectId>" in the project README or CLAUDE.md. Future agents grepping for "drafted:" will discover the link and can pull the frame via read(<frameId>). One line per related frame. Skip for throwaway or exploratory frames.
114
118
 
119
+ CONTEXT RULES (follow these before every action):
120
+ - WIKI CHECK: Before acting on any request, search the org wiki for relevant conventions, existing designs, and prior decisions. Use wiki(action="search") with relevant keywords.
121
+ - LAYER CONTEXT: Before reading or mutating a frame, read all anchored frames in the same layer. Anchored frames are per-layer required reading (style guides, design systems, conventions). The server enforces this mechanically for writes/edits/deletes/moves — but proactively reading anchored frames before any frame operation prevents wasted work.
115
122
  IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use read(path=URL) to get frame content, focus(target=URL) to pan the canvas to it. Never curl or WebFetch Drafted URLs.`,
116
123
  });
117
124
 
@@ -129,11 +136,11 @@ const layerKeys = Object.keys(LAYERS);
129
136
 
130
137
  const TOOL_ANNOTATIONS = {
131
138
  // Auth — initiates external browser / email flows
132
- auth: { title: 'Sign in', readOnlyHint: false, destructiveHint: false, openWorldHint: true, description: 'Sign in to Drafted. `action=get_link` returns a URL for manual sign-in (SSH/headless); `action=login` opens a browser and polls for approval.' },
139
+ auth: { title: 'Sign in', readOnlyHint: false, destructiveHint: false, openWorldHint: true, description: 'Sign in to Drafted. `action=get_link` returns a URL immediately and starts background approval polling; after the user opens the link, later Drafted tool calls also auto-consume the approved login. `action=login` opens a browser when needed and explicitly waits/polls for approval.' },
133
140
 
134
141
  // Projects
135
142
  project: { title: 'Projects', readOnlyHint: false, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html', description: 'Manage projects: list (start here), open (switch active project), create, update, move to another org.' },
136
- get_org: { title: 'Organization', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Get the active organization (action="get", default) or switch to a different org (action="switch", orgId=...). Use switch when you need wiki/skill work in an org that has no projects — opening a project also switches, but is unavailable in empty orgs.' },
143
+ get_org: { title: 'Organization', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Get the active organization (action="get", default), Google Drive availability, or switch to a different org (action="switch", orgId=...). Use switch when you need wiki/skill work in an org that has no projects — opening a project also switches, but is unavailable in empty orgs. When googleDrive.connected is true, strongly prefer Google Workspace frames for documents, sheets, and slides.' },
137
144
 
138
145
  // Templates
139
146
  template: { title: 'Templates', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage project templates: list, create, update, delete, fork.' },
@@ -143,7 +150,7 @@ const TOOL_ANNOTATIONS = {
143
150
 
144
151
  // Frames — filesystem
145
152
  ls: { title: 'List frames', readOnlyHint: true, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html' },
146
- frame: { title: 'Frames', readOnlyHint: false, destructiveHint: true, openWorldHint: false, widgetUri: 'ui://widget/drafted-frame-preview.html', description: 'Read, write, edit, move, anchor, or search frames in the ACTIVE PROJECT. Dispatch by `action`. Use `ls` to browse, `rm` to delete.' },
153
+ frame: { title: 'Frames', readOnlyHint: false, destructiveHint: true, openWorldHint: false, widgetUri: 'ui://widget/drafted-frame-preview.html', description: 'Read, write, edit, move, anchor, search, or restore frame versions in the ACTIVE PROJECT. Dispatch by `action`. Use `ls` to browse, `rm` to delete.' },
147
154
  rm: { title: 'Delete frame', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
148
155
  // batch: { title: 'Batch operations', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
149
156
 
@@ -193,7 +200,12 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
193
200
  if (ann.widgetUri) {
194
201
  config._meta = { 'ui': { resourceUri: ann.widgetUri } };
195
202
  }
196
- return server.registerTool(name, config, cb);
203
+ return server.registerTool(name, config, async (...args) => {
204
+ const state = getState();
205
+ trackUmamiEvent(UMAMI_EVENTS.MCP_TOOL_CALLED, { tool: name, projectId: state.projectId || undefined, source: 'mcp' });
206
+ reportInstallationEvent(UMAMI_EVENTS.DRAFTED_MCP_REQUEST);
207
+ return cb(...args);
208
+ });
197
209
  }
198
210
 
199
211
  // ── ChatGPT Apps SDK widgets ──────────────────────────────────────
@@ -255,20 +267,63 @@ registerAppResource(
255
267
  // ── Config ────────────────────────────────────────────────────────
256
268
 
257
269
  const AUTH_FILE = process.env.DRAFTED_AUTH_FILE || join(homedir(), '.drafted', 'auth.json');
270
+ const PENDING_AUTH_FILE = process.env.DRAFTED_PENDING_AUTH_FILE || `${AUTH_FILE}.pending`;
258
271
 
259
272
  function getServerUrl() {
260
273
  if (process.env.DRAFTED_SERVER) return process.env.DRAFTED_SERVER.replace(/\/$/, '');
274
+ if (getState().publicUrl) return getState().publicUrl.replace(/\/$/, '');
275
+ if (process.env.DRAFTED_PUBLIC_URL) return process.env.DRAFTED_PUBLIC_URL.replace(/\/$/, '');
276
+ if (process.env.BASE_URL) return process.env.BASE_URL.replace(/\/$/, '');
277
+ if (process.env.APP_URL) return process.env.APP_URL.replace(/\/$/, '');
261
278
  // Read from config.json next to auth.json (written by install-mcp.sh)
262
279
  try {
263
280
  const cfgPath = join(homedir(), '.drafted', 'config.json');
264
281
  if (existsSync(cfgPath)) {
265
282
  const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
266
283
  if (cfg.server) return cfg.server.replace(/\/$/, '');
284
+ if (cfg.publicUrl) return cfg.publicUrl.replace(/\/$/, '');
267
285
  }
268
286
  } catch { /* fall through */ }
269
287
  return `http://localhost:${process.env.DRAFTED_PORT || 3477}`;
270
288
  }
271
289
 
290
+ function getInstallInfo() {
291
+ if (process.env.DRAFTED_TELEMETRY === '0') return null;
292
+ try {
293
+ const installPath = join(homedir(), '.drafted', 'install.json');
294
+ if (!existsSync(installPath)) return null;
295
+ const info = JSON.parse(readFileSync(installPath, 'utf8'));
296
+ if (info.telemetry === false) return null;
297
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(info.installId || ''))) return null;
298
+ return info;
299
+ } catch {
300
+ return null;
301
+ }
302
+ }
303
+
304
+ function reportInstallationEvent(event) {
305
+ const info = getInstallInfo();
306
+ if (!info) return;
307
+ fetch(`${getServerUrl()}/api/installations/report`, {
308
+ method: 'POST',
309
+ headers: { 'Content-Type': 'application/json', 'User-Agent': `Drafted MCP/${PACKAGE_VERSION}` },
310
+ body: JSON.stringify({
311
+ installId: info.installId,
312
+ event,
313
+ schemaVersion: 1,
314
+ cliVersion: PACKAGE_VERSION,
315
+ mcpMode: process.argv.includes('--http') ? 'http' : 'stdio',
316
+ source: 'mcp',
317
+ }),
318
+ }).catch(() => {});
319
+ }
320
+
321
+ function wikiBrowserUrl(path = '') {
322
+ const normalized = normalizeWikiPath(path || '');
323
+ if (!normalized) return `${getServerUrl()}/wiki`;
324
+ return `${getServerUrl()}/wiki/${normalized.split('/').map(encodeURIComponent).join('/')}`;
325
+ }
326
+
272
327
  function getBootstrapSessionId() {
273
328
  try {
274
329
  if (existsSync(AUTH_FILE)) {
@@ -279,6 +334,87 @@ function getBootstrapSessionId() {
279
334
  return null;
280
335
  }
281
336
 
337
+ function persistAuthSession(data) {
338
+ mkdirSync(dirname(AUTH_FILE), { recursive: true });
339
+ writeFileSync(AUTH_FILE, JSON.stringify({
340
+ sessionId: data.sessionId,
341
+ userId: data.userId || null,
342
+ orgId: data.orgId || null,
343
+ server: getServerUrl(),
344
+ updatedAt: new Date().toISOString(),
345
+ }, null, 2), { mode: 0o600 });
346
+ }
347
+
348
+ function persistPendingDeviceCode(data) {
349
+ if (!data?.deviceCode) return;
350
+ const pending = {
351
+ ...data,
352
+ server: getServerUrl(),
353
+ createdAt: Date.now(),
354
+ expiresAt: Date.now() + (Number(data.expiresIn || 900) * 1000),
355
+ };
356
+ getSessionState().pendingDeviceCode = pending;
357
+ try {
358
+ mkdirSync(dirname(PENDING_AUTH_FILE), { recursive: true });
359
+ writeFileSync(PENDING_AUTH_FILE, JSON.stringify(pending, null, 2), { mode: 0o600 });
360
+ } catch { /* best effort; in-memory state still works for long-lived stdio */ }
361
+ schedulePendingAuthPoll(2000);
362
+ }
363
+
364
+ function clearPendingDeviceCode() {
365
+ stopPendingAuthPoll();
366
+ getSessionState().pendingDeviceCode = null;
367
+ try { if (existsSync(PENDING_AUTH_FILE)) unlinkSync(PENDING_AUTH_FILE); } catch { /* ignore */ }
368
+ }
369
+
370
+ function getPendingDeviceCode() {
371
+ const inMemory = getSessionState().pendingDeviceCode;
372
+ if (inMemory?.deviceCode) return inMemory;
373
+ try {
374
+ if (!existsSync(PENDING_AUTH_FILE)) return null;
375
+ const pending = JSON.parse(readFileSync(PENDING_AUTH_FILE, 'utf8'));
376
+ if (!pending?.deviceCode) return null;
377
+ if (pending.server && pending.server !== getServerUrl()) return null;
378
+ if (pending.expiresAt && Date.now() > Number(pending.expiresAt)) {
379
+ clearPendingDeviceCode();
380
+ return null;
381
+ }
382
+ getSessionState().pendingDeviceCode = pending;
383
+ return pending;
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+
389
+
390
+ let pendingAuthPollTimer = null;
391
+
392
+ function stopPendingAuthPoll() {
393
+ if (pendingAuthPollTimer) clearTimeout(pendingAuthPollTimer);
394
+ pendingAuthPollTimer = null;
395
+ }
396
+
397
+ function schedulePendingAuthPoll(delayMs = 2000) {
398
+ stopPendingAuthPoll();
399
+ pendingAuthPollTimer = setTimeout(async () => {
400
+ pendingAuthPollTimer = null;
401
+ const pending = getPendingDeviceCode();
402
+ if (!pending?.deviceCode) return;
403
+ const approved = await consumePendingDeviceCode();
404
+ if (approved) {
405
+ try { connectAgentWs().catch(() => {}); } catch { /* ignore */ }
406
+ return;
407
+ }
408
+ const expiresAt = Number(pending.expiresAt || 0);
409
+ if (expiresAt && Date.now() >= expiresAt) {
410
+ clearPendingDeviceCode();
411
+ return;
412
+ }
413
+ schedulePendingAuthPoll(2000);
414
+ }, delayMs);
415
+ if (typeof pendingAuthPollTimer.unref === 'function') pendingAuthPollTimer.unref();
416
+ }
417
+
282
418
  function getAuthHeaders() {
283
419
  const sid = getState().sessionId || getBootstrapSessionId();
284
420
  if (sid) return { Cookie: `gc_session=${sid}` };
@@ -304,6 +440,8 @@ async function cloneSession() {
304
440
  }
305
441
 
306
442
  async function ensureSession() {
443
+ if (getState().sessionId) return;
444
+ await consumePendingDeviceCode();
307
445
  if (getState().sessionId) return;
308
446
  await cloneSession();
309
447
  }
@@ -335,10 +473,13 @@ async function api(method, path, body, _retried) {
335
473
  const res = await fetch(url, opts);
336
474
  const text = await res.text();
337
475
 
338
- // Session expired after server restart re-clone and retry once
476
+ // Session expired after server restart, or a browser approval just completed
477
+ // for auth(action="get_link"). First try to consume any pending device code,
478
+ // then fall back to cloning the saved browser session, and retry once.
339
479
  if (res.status === 401 && !_retried) {
340
480
  getState().sessionId = null;
341
- await cloneSession();
481
+ const approved = await consumePendingDeviceCode();
482
+ if (!approved) await cloneSession();
342
483
  return api(method, path, body, true);
343
484
  }
344
485
 
@@ -398,6 +539,22 @@ function ok(text, opts) {
398
539
  return result;
399
540
  }
400
541
 
542
+ function summarizeSkillForSearch(skill) {
543
+ if (!skill || typeof skill !== 'object') return skill;
544
+ return {
545
+ id: skill.id,
546
+ orgId: skill.orgId ?? null,
547
+ name: skill.name,
548
+ slug: skill.slug,
549
+ description: skill.description,
550
+ tags: skill.tags || [],
551
+ triggerPatterns: skill.triggerPatterns || [],
552
+ version: skill.version,
553
+ updatedAt: skill.updatedAt,
554
+ fileCount: Array.isArray(skill.files) ? skill.files.length : undefined,
555
+ };
556
+ }
557
+
401
558
  // Build the structuredContent shape that the frame-preview widget reads.
402
559
  // Tools that produce or return a frame (read/write/edit) call this so the
403
560
  // model and widget see the same metadata view.
@@ -694,7 +851,7 @@ function applyHashlineOps(content, operations) {
694
851
  let lines = content.split('\n');
695
852
  const lineToHash = {};
696
853
  for (let i = 0; i < lines.length; i++) {
697
- lineToHash[i] = createHash('sha256').update(lines[i] || '').digest('hex').slice(0, 4);
854
+ lineToHash[i] = createHash('sha256').update(lines[i] || '').digest('hex').slice(0, 12);
698
855
  }
699
856
  const sorted = [...operations].reverse();
700
857
  for (const op of sorted) {
@@ -744,10 +901,49 @@ function runCLI(command, args = [], options = {}) {
744
901
 
745
902
  // ── Login tools ─────────────────────────────────────────────────────
746
903
 
747
- // Shared pending device code — auth(action=get_link) stores it, auth(action=login) reuses it
748
- let pendingDeviceCode = null;
904
+ // Per-session pending device code — auth(action=get_link) stores it, and
905
+ // auth(action=login) or a later tool call can consume it.
906
+ //
907
+ // This prevents the "I already authed" loop after get_link: once the user
908
+ // finishes the browser step, the next tool call can exchange the approved
909
+ // device code for a Drafted session automatically.
910
+ //
911
+ // SCOPE: This `auth` tool is for the stdio path only — when drafted-mcp runs
912
+ // as a local Node process (npm-installed `drafted-mcp` for self-hosters,
913
+ // Cursor/VS Code one-click installs that use stdio). The Claude Code plugin
914
+ // has used HTTP MCP since v1.7.17 and authenticates via Clerk OAuth at the
915
+ // MCP protocol layer (handled by Claude Code itself, see src/middleware/oauth.ts).
916
+ // HTTP-mode callers never reach this tool because Clerk gates all /mcp tool
917
+ // calls before authentication. Don't add new auth flows here — add them to
918
+ // src/auth/clerk.ts instead.
919
+
920
+ async function consumePendingDeviceCode() {
921
+ const pending = getPendingDeviceCode();
922
+ if (!pending?.deviceCode) return false;
923
+
924
+ try {
925
+ const res = await fetch(`${getServerUrl()}/auth/device/token`, {
926
+ method: 'POST',
927
+ headers: { 'Content-Type': 'application/json' },
928
+ body: JSON.stringify({ deviceCode: pending.deviceCode }),
929
+ });
930
+ if (!res.ok) return false;
931
+ const data = await res.json();
932
+ if (data.status === 'approved' && data.sessionId) {
933
+ persistAuthSession(data);
934
+ getState().sessionId = data.sessionId;
935
+ clearPendingDeviceCode();
936
+ return true;
937
+ }
938
+ if (data.status === 'expired') {
939
+ clearPendingDeviceCode();
940
+ }
941
+ } catch { /* ignore and fall through */ }
942
+
943
+ return false;
944
+ }
749
945
 
750
- tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL immediately (use for SSH/headless/tmux where a browser may not open). `action=login` opens a browser and polls for approval — run this if other tools return auth errors or "fetch failed". If get_link was called first, login reuses that pending code instead of opening a new browser.', {
946
+ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL immediately (use for SSH/headless/tmux where a browser may not open) and starts background polling; after the user opens the link, later Drafted tool calls also auto-consume the approved login. `action=login` opens a browser when needed and explicitly waits/polls for approval. If get_link was called first, login reuses that pending code instead of opening a new browser.', {
751
947
  action: z.enum(['get_link', 'login']).describe('Operation to perform.'),
752
948
  }, async ({ action }) => {
753
949
  try {
@@ -755,7 +951,7 @@ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL i
755
951
  const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
756
952
  if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
757
953
  const data = await codeRes.json();
758
- pendingDeviceCode = data;
954
+ persistPendingDeviceCode(data);
759
955
  return ok(data.verificationUrl);
760
956
  }
761
957
  if (action === 'login') {
@@ -775,9 +971,9 @@ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL i
775
971
  let deviceCode, verificationUrl, expiresIn;
776
972
  let reusingPending = false;
777
973
 
778
- if (pendingDeviceCode) {
779
- ({ deviceCode, verificationUrl, expiresIn } = pendingDeviceCode);
780
- pendingDeviceCode = null;
974
+ const pending = getPendingDeviceCode();
975
+ if (pending) {
976
+ ({ deviceCode, verificationUrl, expiresIn } = pending);
781
977
  reusingPending = true;
782
978
  } else {
783
979
  const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
@@ -813,16 +1009,8 @@ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL i
813
1009
  const data = await res.json();
814
1010
 
815
1011
  if (data.status === 'approved') {
816
- const { writeFileSync, mkdirSync } = await import('fs');
817
- const { dirname } = await import('path');
818
- mkdirSync(dirname(AUTH_FILE), { recursive: true });
819
- writeFileSync(AUTH_FILE, JSON.stringify({
820
- sessionId: data.sessionId,
821
- userId: data.userId || null,
822
- orgId: data.orgId || null,
823
- server: getServerUrl(),
824
- updatedAt: new Date().toISOString(),
825
- }, null, 2));
1012
+ persistAuthSession(data);
1013
+ clearPendingDeviceCode();
826
1014
 
827
1015
  getState().sessionId = null;
828
1016
  await cloneSession();
@@ -1171,7 +1359,7 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
1171
1359
  const listing = await api('GET', `/api/fs?path=/${key}&projectId=${projectId}&recursive=true`);
1172
1360
  const entries = listing.entries || [];
1173
1361
  const frames = entries.filter(e => e.type === 'frame');
1174
- const realFrames = frames.filter(f => !f.path.endsWith('/_meta/_context.md') && !f.path.endsWith('/instructions/context.md'));
1362
+ const realFrames = frames.filter(f => !f.path.endsWith('/_meta/_context.md') && !f.path.endsWith('/instructions/context.md') && !f.path.endsWith('/instructions/AGENTS.md') && !f.path.endsWith('/AGENTS.md'));
1175
1363
  const frameCount = realFrames.length;
1176
1364
  if (frameCount > 0) {
1177
1365
  throw new Error(`Layer "${key}" contains ${frameCount} frame(s). Use force: true to confirm deletion.`);
@@ -1209,6 +1397,32 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
1209
1397
  } catch (error) { return err(error); }
1210
1398
  });
1211
1399
 
1400
+
1401
+ async function getGoogleDriveAvailability() {
1402
+ try {
1403
+ const status = await api('GET', '/api/google/status');
1404
+ return {
1405
+ connected: !!status?.connected,
1406
+ syncEnabled: !!status?.syncEnabled,
1407
+ driveRootFolderId: status?.driveRootFolderId || null,
1408
+ driveRootFolderName: status?.driveRootFolderName || null,
1409
+ workspaceFramesAvailable: !!status?.connected,
1410
+ preference: status?.connected
1411
+ ? 'Strongly prefer Google Workspace frames for docs, sheets, and slides in this org.'
1412
+ : 'Google Drive is not connected; use normal Drafted frames.',
1413
+ };
1414
+ } catch {
1415
+ return {
1416
+ connected: false,
1417
+ syncEnabled: false,
1418
+ driveRootFolderId: null,
1419
+ driveRootFolderName: null,
1420
+ workspaceFramesAvailable: false,
1421
+ preference: 'Google Drive status unavailable; use normal Drafted frames unless a Google Workspace frame succeeds.',
1422
+ };
1423
+ }
1424
+ }
1425
+
1212
1426
  tool('get_org', {
1213
1427
  action: z.enum(['get', 'switch']).optional().describe('Default: "get" returns active org and member info. Use "switch" with orgId to change the active org without opening a project.'),
1214
1428
  orgId: z.string().optional().describe('[switch] target org ID to switch to. Must be one of the orgs the user is a member of.'),
@@ -1229,7 +1443,8 @@ tool('get_org', {
1229
1443
  const me = await api('GET', '/auth/me');
1230
1444
  const orgs = (await api('GET', '/api/orgs')).orgs || [];
1231
1445
  const activeOrg = (orgs || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name })).find(o => o.id === me?.orgId) || null;
1232
- return ok({ switched: true, activeOrg, note: 'Active org switched. Wiki and skill calls now target this org. Active project cleared — open a project (or stay org-scoped for wiki/skill).' });
1446
+ const googleDrive = await getGoogleDriveAvailability();
1447
+ return ok({ switched: true, activeOrg, googleDrive, note: 'Active org switched. Wiki and skill calls now target this org. Active project cleared — open a project (or stay org-scoped for wiki/skill). If googleDrive.connected is true, prefer Google Workspace frames for docs, sheets, and slides.' });
1233
1448
  }
1234
1449
 
1235
1450
  // Source of truth = THIS MCP session's bound org (what mutations will actually
@@ -1242,6 +1457,8 @@ tool('get_org', {
1242
1457
  const orgs = (data.orgs || data || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
1243
1458
  const activeOrg = sessionOrgId ? (orgs.find(o => o.id === sessionOrgId) || null) : null;
1244
1459
 
1460
+ const googleDrive = await getGoogleDriveAvailability();
1461
+
1245
1462
  let members = [];
1246
1463
  if (sessionOrgId) {
1247
1464
  try {
@@ -1253,27 +1470,34 @@ tool('get_org', {
1253
1470
  activeOrg,
1254
1471
  orgs,
1255
1472
  members: members.map(m => ({ id: m.userId, name: m.username, email: m.email, role: m.role })),
1473
+ googleDrive,
1256
1474
  mcpVersion: PACKAGE_VERSION,
1257
- note: "activeOrg is the org bound to THIS MCP session — what mutations will actually target. To switch orgs without creating a project, call get_org(action=\"switch\", orgId=\"...\"). Browser tabs and other MCP sessions for the same user can be on different orgs.",
1475
+ note: "activeOrg is the org bound to THIS MCP session — what mutations will actually target. To switch orgs without creating a project, call get_org(action=\"switch\", orgId=\"...\"). Browser tabs and other MCP sessions for the same user can be on different orgs. If googleDrive.connected is true, strongly prefer Google Workspace frames for docs, sheets, and slides.",
1258
1476
  });
1259
1477
  } catch (error) { return err(error); }
1260
1478
  });
1261
1479
 
1262
1480
  // ── Filesystem tools (direct HTTP to /api/fs) ─────────────────────
1263
1481
 
1264
- tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by path, frame URL, or UUID), write (new frame or overwrite), edit (hashline ops), mv (rename/move), anchor (mark as required-read for the layer), search (match frame names). Use project(action="open") first. For listing use `ls`, for deletion use `rm`.\n\n**Write — content or file:** Provide `content` (HTML/markdown/text) OR `file_path` (absolute path to a local file like a PNG screenshot). For binary files (images, PDFs), use `file_path` — uploaded to storage and displayed as an asset frame.\n\n**Write — dimensions:** By default, frames use the layer\'s default size (e.g. 1440×900 for designs, 1440×3000 for wireframes). Often too large for small content. Use `autoSize: true` to measure HTML content and size to fit, or pass explicit `width`/`height`.', {
1265
- action: z.enum(['read', 'write', 'edit', 'mv', 'anchor', 'search']).describe('Operation to perform.'),
1482
+ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by path, frame URL, or UUID), write (new frame or overwrite), write_excalidraw (native editable Excalidraw diagram), edit (hashline ops), mv (rename/move), anchor (mark as required-read for the layer), search (match frame names). Use project(action="open") first. For listing use `ls`, for deletion use `rm`.\n\n**Write — content, file, or Google Workspace frame:** Provide `content` (HTML/markdown/text), `file_path` (absolute path to a local file), `googleType` (`google-doc`, `google-sheet`, `google-slide`), OR `write_excalidraw` with `excalidraw_data` or `mermaid`. Call get_org first; when `googleDrive.connected` is true, strongly prefer Google Workspace frames for business artifacts: use `google-doc` for memos/reports/briefs/SOPs/proposals, `google-sheet` for tables/trackers/budgets/research matrices/models, and `google-slide` for decks/presentation outlines. For inline content, filename extension matters: use `.html` for complete HTML documents and `.md` for Markdown. Never place a full HTML document in a `.md` or extensionless frame. For a new Google file, pass `googleType` and optional `title`; for an existing Google file, pass `googleType` plus `url` or `googleId`. For binary files (images, PDFs), use `file_path` — uploaded to storage and displayed as an asset frame.\n\n**Write — dimensions:** By default, frames use the layer\'s default size (e.g. 1440×900 for designs, 1440×3000 for wireframes). Often too large for small content. Use `autoSize: true` to measure HTML content and size to fit, or pass explicit `width`/`height`.', {
1483
+ action: z.enum(['read', 'write', 'write_excalidraw', 'edit', 'mv', 'anchor', 'search', 'versions', 'read_version', 'restore_version']).describe('Operation to perform.'),
1266
1484
  path: z.string().optional().describe('[read] /{layer}/{lane}/{filename}, frame URL, or UUID. [write|edit|anchor] /{layer}/{lane}/{filename}.'),
1267
1485
  lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
1268
- content: z.string().optional().describe('[write] HTML/markdown/text. Mutually exclusive with file_path.'),
1486
+ content: z.string().optional().describe('[write] HTML/markdown/text. Mutually exclusive with file_path. Use a .html path for complete HTML documents and a .md path for Markdown.'),
1487
+ excalidraw_data: z.any().optional().describe('[write_excalidraw] Excalidraw scene JSON object or JSON string. Defaults to an empty scene.'),
1488
+ mermaid: z.string().optional().describe('[write_excalidraw] Mermaid source to convert into an editable Excalidraw scene.'),
1269
1489
  file_path: z.string().optional().describe('[write] absolute path to a local file to upload. Mutually exclusive with content.'),
1490
+ googleType: z.enum(['google-doc', 'google-sheet', 'google-slide']).optional().describe('[write] Create or attach a native Google Workspace frame. Use with title to create new, or url/googleId to attach existing.'),
1491
+ title: z.string().optional().describe('[write + googleType] Title for a new Google Doc/Sheet/Slide. Defaults to filename from path.'),
1492
+ url: z.string().optional().describe('[write + googleType] Existing Google Doc/Sheet/Slide URL to attach.'),
1493
+ googleId: z.string().optional().describe('[write + googleType] Existing Google file ID to attach.'),
1270
1494
  autoSize: z.boolean().optional().describe('[write] measure HTML content and size frame to fit. Content only, not file_path.'),
1271
1495
  width: z.number().optional().describe('[write] explicit width in pixels. Overrides layer default. Ignored if autoSize=true.'),
1272
1496
  height: z.number().optional().describe('[write] explicit height in pixels. Overrides layer default. Ignored if autoSize=true.'),
1273
1497
  color: z.string().optional().describe('[write] CSS color for frame border (e.g. #ff0000, red).'),
1274
1498
  operations: z.array(z.object({
1275
1499
  type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
1276
- lineHash: z.string().describe('4-char hash of the target line (from read output). Each line has a unique hash.'),
1500
+ lineHash: z.string().describe('Hash of the target line (from read output). Use the full hash to avoid ambiguity in large frames.'),
1277
1501
  newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
1278
1502
  })).optional().describe('[edit] hashline edit operations'),
1279
1503
  from: z.string().optional().describe('[mv] source path /{layer}/{lane}/{filename}'),
@@ -1283,6 +1507,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1283
1507
  query: z.string().optional().describe('[search] term to match against frame names'),
1284
1508
  projectId: z.string().optional().describe('[search] limit to a specific project (optional)'),
1285
1509
  limit: z.number().optional().describe('[search] max results (default 50, max 200)'),
1510
+ versionId: z.string().optional().describe('[read_version|restore_version] version id'),
1511
+ reason: z.string().optional().describe('[restore_version] reason recorded on the snapshot of current content'),
1286
1512
  }, async (args) => {
1287
1513
  try {
1288
1514
  const { action } = args;
@@ -1330,28 +1556,55 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1330
1556
  });
1331
1557
  }
1332
1558
  case 'write': {
1333
- const { path, content, file_path, autoSize, width, height, color } = args;
1559
+ const { path, content, file_path, autoSize, width, height, color, googleType, title, url, googleId } = args;
1334
1560
  if (!path) throw new Error('path required for action=write');
1335
- if (content != null && file_path) throw new Error('Provide content OR file_path, not both');
1336
- if (content == null && !file_path) throw new Error('Provide content or file_path');
1561
+ const writeSources = [content != null, !!file_path, !!googleType].filter(Boolean).length;
1562
+ if (writeSources > 1) throw new Error('Provide only one of content, file_path, or googleType');
1563
+ if (writeSources === 0) throw new Error('Provide content, file_path, or googleType');
1337
1564
  const skillErr = await checkProjectSkills(getState().projectId);
1338
1565
  if (skillErr) return err(new Error(skillErr));
1339
1566
  const anchorErr = await checkAnchors(parseLayer(path));
1340
1567
  if (anchorErr) return err(new Error(anchorErr));
1341
1568
  const parts = path.replace(/^\/+/, '').split('/');
1342
1569
  if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
1570
+ if (googleType) {
1571
+ const projectId = getState().projectId;
1572
+ if (!projectId) throw new Error('Open a project first with project(action="open") before creating Google Workspace frames');
1573
+ const label = basename(parts[2], extname(parts[2])) || parts[2];
1574
+ const body = {
1575
+ projectId,
1576
+ layer: parts[0],
1577
+ lane: parts[1],
1578
+ label,
1579
+ type: googleType,
1580
+ width,
1581
+ height,
1582
+ };
1583
+ const result = url || googleId
1584
+ ? await api('POST', '/api/google/workspace/frame', { ...body, url, googleId })
1585
+ : await api('POST', '/api/google/workspace/create-frame', { ...body, title: title || label });
1586
+ return ok(withProject(withFrameBreadcrumb({
1587
+ ...result,
1588
+ path,
1589
+ label,
1590
+ contentType: 'text/html',
1591
+ sourceType: googleType,
1592
+ }, { hint: true })), {
1593
+ structuredContent: frameStructuredContent({ ...result, path, label, contentType: 'text/html' }, projectCtx),
1594
+ });
1595
+ }
1343
1596
  let body;
1344
1597
  if (file_path) {
1345
1598
  const resolved = resolve(file_path);
1346
1599
  if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
1347
1600
  const ext = extname(resolved).toLowerCase();
1348
- const TEXT_EXTS = ['.html', '.htm', '.svg', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml'];
1601
+ const TEXT_EXTS = ['.html', '.htm', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml', '.excalidraw'];
1349
1602
  if (TEXT_EXTS.includes(ext)) {
1350
1603
  body = { content: readFileSync(resolved, 'utf8') };
1351
1604
  if (autoSize) body.autoSize = true;
1352
1605
  } else {
1353
1606
  const buffer = readFileSync(resolved);
1354
- const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf' };
1607
+ const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.m4v': 'video/x-m4v' };
1355
1608
  body = { base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
1356
1609
  }
1357
1610
  } else {
@@ -1367,6 +1620,28 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1367
1620
  _meta: body.content ? { frameHtml: body.content } : undefined,
1368
1621
  });
1369
1622
  }
1623
+ case 'write_excalidraw': {
1624
+ const { path, excalidraw_data, mermaid, width, height, color } = args;
1625
+ if (!path) throw new Error('path required for action=write_excalidraw');
1626
+ if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
1627
+ const skillErr = await checkProjectSkills(getState().projectId);
1628
+ if (skillErr) return err(new Error(skillErr));
1629
+ const anchorErr = await checkAnchors(parseLayer(path));
1630
+ if (anchorErr) return err(new Error(anchorErr));
1631
+ const parts = path.replace(/^\/+/, '').split('/');
1632
+ if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
1633
+ const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
1634
+ const scene = mermaid ? await excalidrawSceneFromMermaid(mermaid) : (excalidraw_data ?? emptyExcalidrawScene());
1635
+ const body = { content: stringifyExcalidrawScene(scene) };
1636
+ if (width) body.width = width;
1637
+ if (height) body.height = height;
1638
+ if (color) body.color = color;
1639
+ const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${filename}`, body);
1640
+ return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
1641
+ structuredContent: frameStructuredContent(result, projectCtx),
1642
+ _meta: { frameHtml: body.content },
1643
+ });
1644
+ }
1370
1645
  case 'edit': {
1371
1646
  const { path, operations } = args;
1372
1647
  if (!path) throw new Error('path required for action=edit');
@@ -1412,6 +1687,27 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1412
1687
  if (skillErr) return err(new Error(skillErr));
1413
1688
  return ok(withProject(await api('POST', '/api/fs/anchor', { path, anchored })));
1414
1689
  }
1690
+
1691
+ case 'versions': {
1692
+ const { path } = args;
1693
+ if (!path) throw new Error('path required for action=versions');
1694
+ const result = await api('GET', `/api/fs/versions?path=${encodeURIComponent(path)}`);
1695
+ return ok(withProject(result));
1696
+ }
1697
+ case 'read_version': {
1698
+ const { versionId } = args;
1699
+ if (!versionId) throw new Error('versionId required for action=read_version');
1700
+ const result = await api('GET', `/api/fs/versions/${versionId}`);
1701
+ return ok(withProject(result));
1702
+ }
1703
+ case 'restore_version': {
1704
+ const { versionId, reason } = args;
1705
+ if (!versionId) throw new Error('versionId required for action=restore_version');
1706
+ const skillErr = await checkProjectSkills(getState().projectId);
1707
+ if (skillErr) return err(new Error(skillErr));
1708
+ const result = await api('POST', '/api/fs/restore-version', { versionId, reason });
1709
+ return ok(withProject(result));
1710
+ }
1415
1711
  case 'search': {
1416
1712
  const { query, projectId, limit = 50 } = args;
1417
1713
  if (!query) throw new Error('query required for action=search');
@@ -1587,7 +1883,7 @@ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "projec
1587
1883
 
1588
1884
  // Handle frame operations via the batch API
1589
1885
  if (frameOps.length > 0) {
1590
- const TEXT_EXTS = ['.html', '.htm', '.svg', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml'];
1886
+ const TEXT_EXTS = ['.html', '.htm', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml', '.excalidraw'];
1591
1887
  const resolvedOps = frameOps.map(op => {
1592
1888
  if (op.tool === 'write' && op.file_path) {
1593
1889
  const resolved = resolve(op.file_path);
@@ -1805,7 +2101,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
1805
2101
  ]).describe('Operation to perform.'),
1806
2102
  query: z.string().optional().describe('[search] term to match against name/description/content'),
1807
2103
  tags: z.array(z.string()).optional().describe('[search] filter by tags; [add|update] tag list'),
1808
- scope: z.enum(['all', 'org', 'global']).optional().describe('[search] scope (default: all)'),
2104
+ scope: z.enum(['all', 'org', 'global']).optional().describe('[search|list] library scope (default: all for search; when provided to list, lists the library instead of project/org attachments)'),
1809
2105
  limit: z.number().optional().describe('[search] max results (default 25, max 100)'),
1810
2106
  skill: z.string().optional().describe('[load] skill ID (UUID) or slug'),
1811
2107
  skillId: z.string().optional().describe('[update|remove|attach|detach|favorite|unfavorite|read_file|update_file] skill ID'),
@@ -1829,10 +2125,14 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
1829
2125
  const endpoint = query ? '/api/skills/search' : '/api/skills';
1830
2126
  const result = await api('GET', `${endpoint}${qs ? '?' + qs : ''}`);
1831
2127
  const cap = Math.min(Math.max(1, limit || 25), 100);
1832
- if (Array.isArray(result?.skills) && result.skills.length > cap) {
1833
- result.totalAvailable = result.skills.length;
1834
- result.truncated = true;
1835
- result.skills = result.skills.slice(0, cap);
2128
+ if (Array.isArray(result?.skills)) {
2129
+ if (result.skills.length > cap) {
2130
+ result.totalAvailable = result.skills.length;
2131
+ result.truncated = true;
2132
+ result.skills = result.skills.slice(0, cap);
2133
+ }
2134
+ result.skills = result.skills.map(summarizeSkillForSearch);
2135
+ result.note = 'Search/list returns skill summaries only. Use skill(action="load", skill="<slug-or-id>") to read full SKILL.md content and supporting file list.';
1836
2136
  }
1837
2137
  return ok(result);
1838
2138
  }
@@ -1846,6 +2146,18 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
1846
2146
  return ok(result);
1847
2147
  }
1848
2148
  case 'list': {
2149
+ if (args.scope || args.tags?.length) {
2150
+ const params = new URLSearchParams();
2151
+ params.set('scope', args.scope || 'all');
2152
+ if (args.tags?.length) params.set('tags', args.tags.join(','));
2153
+ const result = await api('GET', `/api/skills?${params.toString()}`);
2154
+ if (Array.isArray(result?.skills)) {
2155
+ result.skills = result.skills.map(summarizeSkillForSearch);
2156
+ result.note = 'Library list returns skill summaries only. Use skill(action="load", skill="<slug-or-id>") to read full SKILL.md content and supporting file list.';
2157
+ }
2158
+ return ok(result);
2159
+ }
2160
+
1849
2161
  // Prefer the explicit projectId param; otherwise the active project;
1850
2162
  // otherwise fall back to org-attached skills so list works in
1851
2163
  // empty-org / wiki-only sessions where there's no project to bind to.
@@ -1951,7 +2263,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
1951
2263
  frontmatter: z.any().optional().describe('[write] frontmatter object'),
1952
2264
  operations: z.array(z.object({
1953
2265
  type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
1954
- lineHash: z.string().describe('4-char hash of the target line (from read output)'),
2266
+ lineHash: z.string().describe('Hash of the target line (from read output)'),
1955
2267
  newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
1956
2268
  })).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
1957
2269
  from: z.string().optional().describe('[mv] source path'),
@@ -2006,6 +2318,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2006
2318
  title: p.title,
2007
2319
  type: p.type,
2008
2320
  id: p.id,
2321
+ url: wikiBrowserUrl(p.path),
2009
2322
  }));
2010
2323
  return ok({ tree });
2011
2324
  }
@@ -2021,8 +2334,8 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2021
2334
  }
2022
2335
  return ok({
2023
2336
  tree: [
2024
- ...Array.from(dirs).sort().map(d => ({ type: 'directory', name: d })),
2025
- ...rootPages.map(p => ({ type: 'page', path: p.path, title: p.title, id: p.id })),
2337
+ ...Array.from(dirs).sort().map(d => ({ type: 'directory', name: d, url: wikiBrowserUrl(d) })),
2338
+ ...rootPages.map(p => ({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) })),
2026
2339
  ],
2027
2340
  });
2028
2341
  }
@@ -2033,14 +2346,14 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2033
2346
  const prefix = parent + '/';
2034
2347
  for (const p of pages) {
2035
2348
  if (p.path === parent) {
2036
- children.push({ type: 'page', path: p.path, title: p.title, id: p.id });
2349
+ children.push({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) });
2037
2350
  } else if (p.path.startsWith(prefix)) {
2038
2351
  const rest = p.path.slice(prefix.length);
2039
2352
  if (rest.includes('/')) {
2040
2353
  const sub = rest.split('/')[0];
2041
- if (!seenDirs.has(sub)) { seenDirs.add(sub); children.push({ type: 'directory', name: parent + '/' + sub }); }
2354
+ if (!seenDirs.has(sub)) { seenDirs.add(sub); children.push({ type: 'directory', name: parent + '/' + sub, url: wikiBrowserUrl(parent + '/' + sub) }); }
2042
2355
  } else {
2043
- children.push({ type: 'page', path: p.path, title: p.title, id: p.id });
2356
+ children.push({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) });
2044
2357
  }
2045
2358
  }
2046
2359
  }
@@ -2055,12 +2368,12 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2055
2368
  .filter(p => p.updatedAt)
2056
2369
  .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
2057
2370
  .slice(0, Math.max(1, recentLimit))
2058
- .map(p => ({ path: p.path, title: p.title, updatedAt: p.updatedAt }));
2371
+ .map(p => ({ path: p.path, title: p.title, updatedAt: p.updatedAt, url: wikiBrowserUrl(p.path) }));
2059
2372
  return ok({ pages: recent });
2060
2373
  }
2061
2374
 
2062
2375
  // ── read ────────────────────────────────────────────────────
2063
- // Returns content in hashline format (`lineNum:hash|content`) so the
2376
+ // Returns content in hashline format (`LINE+ID|content`) so the
2064
2377
  // agent can produce hashline edit operations. Mirrors frame.read.
2065
2378
  case 'read': {
2066
2379
  const { path: readPath, lines: readLines } = args;
@@ -2088,6 +2401,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2088
2401
  lastEditedBy: page.updatedBy,
2089
2402
  lastEditedAt: page.updatedAt,
2090
2403
  backlinkCount,
2404
+ url: wikiBrowserUrl(page.path),
2091
2405
  });
2092
2406
  }
2093
2407
 
@@ -2096,7 +2410,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2096
2410
  const { query: searchQuery, limit: searchLimit = 25 } = args;
2097
2411
  if (!searchQuery) throw new Error('query required for action=search');
2098
2412
  const result = await api('GET', `/api/wiki/search?q=${encodeURIComponent(searchQuery)}&limit=${searchLimit}`);
2099
- const hits = (result.hits || []).map(h => ({ path: h.path, title: h.title }));
2413
+ const hits = (result.hits || []).map(h => ({ path: h.path, title: h.title, url: wikiBrowserUrl(h.path) }));
2100
2414
  return ok({ hits });
2101
2415
  }
2102
2416
 
@@ -2131,13 +2445,13 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2131
2445
  title: 'Log',
2132
2446
  content: entry + '\n',
2133
2447
  });
2134
- return ok(withOrg({ appended: true, created: true, pageId: created.id }));
2448
+ return ok(withOrg({ appended: true, created: true, pageId: created.id, path: 'log', url: wikiBrowserUrl('log') }));
2135
2449
  }
2136
2450
 
2137
2451
  // Append to existing log
2138
2452
  const updatedContent = (existingContent.endsWith('\n') ? existingContent : existingContent + '\n') + entry + '\n';
2139
2453
  await api('PATCH', `/api/wiki/pages/${existingId}`, { content: updatedContent });
2140
- return ok(withOrg({ appended: true }));
2454
+ return ok(withOrg({ appended: true, path: 'log', url: wikiBrowserUrl('log') }));
2141
2455
  }
2142
2456
 
2143
2457
  // ── health ──────────────────────────────────────────────────
@@ -2160,17 +2474,39 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2160
2474
  try {
2161
2475
  const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
2162
2476
  const result = await api('PATCH', `/api/wiki/pages/${existing.id}`, body);
2163
- return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true }));
2477
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path) }));
2164
2478
  } catch {
2165
2479
  const result = await api('POST', '/api/wiki/pages', body);
2166
- return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true }));
2480
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path) }));
2167
2481
  }
2168
2482
  }
2169
2483
 
2170
2484
  // ── edit ────────────────────────────────────────────────────
2171
2485
  // Hashlines come from `read` (which now formats content as
2172
- // `lineNum:hash|content`). The server applies the ops via the same
2486
+ // `LINE+ID|content`). The server applies the ops via the same
2173
2487
  // hashline algorithm — no client-side re-hashing, no algorithm drift.
2488
+ case 'write_excalidraw': {
2489
+ const { path, excalidraw_data, mermaid, width, height, color } = args;
2490
+ if (!path) throw new Error('path required for action=write_excalidraw');
2491
+ if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
2492
+ const skillErr = await checkProjectSkills(getState().projectId);
2493
+ if (skillErr) return err(new Error(skillErr));
2494
+ const anchorErr = await checkAnchors(parseLayer(path));
2495
+ if (anchorErr) return err(new Error(anchorErr));
2496
+ const parts = path.replace(/^\/+/, '').split('/');
2497
+ if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
2498
+ const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
2499
+ const scene = mermaid ? await excalidrawSceneFromMermaid(mermaid) : (excalidraw_data ?? emptyExcalidrawScene());
2500
+ const body = { content: stringifyExcalidrawScene(scene) };
2501
+ if (width) body.width = width;
2502
+ if (height) body.height = height;
2503
+ if (color) body.color = color;
2504
+ const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${filename}`, body);
2505
+ return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
2506
+ structuredContent: frameStructuredContent(result, projectCtx),
2507
+ _meta: { frameHtml: body.content },
2508
+ });
2509
+ }
2174
2510
  case 'edit': {
2175
2511
  const { path: editPath, operations: editOps } = args;
2176
2512
  if (!editPath) throw new Error('path required for action=edit');
@@ -2178,7 +2514,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2178
2514
  const normalized = normalizeWikiPath(editPath);
2179
2515
  const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
2180
2516
  const result = await api('POST', `/api/wiki/pages/${page.id}/edit`, { operations: editOps });
2181
- return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied }));
2517
+ return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied, url: wikiBrowserUrl(result.path) }));
2182
2518
  }
2183
2519
 
2184
2520
  // ── mv ──────────────────────────────────────────────────────
@@ -2199,7 +2535,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2199
2535
 
2200
2536
  // Server-side cascade: /move rewrites referrers in one transaction
2201
2537
  const moved = await api('PATCH', `/api/wiki/pages/${page.id}/move`, { path: toPath });
2202
- return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0 }));
2538
+ return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0, url: wikiBrowserUrl(moved.path) }));
2203
2539
  }
2204
2540
 
2205
2541
  // ── rm ──────────────────────────────────────────────────────
@@ -2251,8 +2587,8 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2251
2587
  return ok(withOrg({
2252
2588
  created: result.created?.length ?? 0,
2253
2589
  updated: result.updated?.length ?? 0,
2254
- createdPaths: (result.created ?? []).map((r) => r.path),
2255
- updatedPaths: (result.updated ?? []).map((r) => r.path),
2590
+ createdPages: (result.created ?? []).map((r) => ({ path: r.path, url: wikiBrowserUrl(r.path) })),
2591
+ updatedPages: (result.updated ?? []).map((r) => ({ path: r.path, url: wikiBrowserUrl(r.path) })),
2256
2592
  errors: result.errors ?? [],
2257
2593
  }));
2258
2594
  }