drafted 1.7.17 → 1.7.19

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,15 +10,17 @@ 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, appendFileSync } from 'fs';
14
14
  import { join, dirname, basename, extname, resolve } from 'path';
15
- import { homedir } from 'os';
15
+ import { homedir, platform, release as osRelease, arch as osArch } from 'os';
16
16
  import { fileURLToPath } from 'url';
17
17
  import { AsyncLocalStorage } from 'node:async_hooks';
18
18
  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,18 @@ 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
+ const previousTool = state.currentTool;
206
+ state.currentTool = name;
207
+ trackUmamiEvent(UMAMI_EVENTS.MCP_TOOL_CALLED, { tool: name, projectId: state.projectId || undefined, source: 'mcp' });
208
+ reportInstallationEvent(UMAMI_EVENTS.DRAFTED_MCP_REQUEST, { tool: name });
209
+ try {
210
+ return await cb(...args);
211
+ } finally {
212
+ state.currentTool = previousTool;
213
+ }
214
+ });
197
215
  }
198
216
 
199
217
  // ── ChatGPT Apps SDK widgets ──────────────────────────────────────
@@ -255,20 +273,147 @@ registerAppResource(
255
273
  // ── Config ────────────────────────────────────────────────────────
256
274
 
257
275
  const AUTH_FILE = process.env.DRAFTED_AUTH_FILE || join(homedir(), '.drafted', 'auth.json');
276
+ const PENDING_AUTH_FILE = process.env.DRAFTED_PENDING_AUTH_FILE || `${AUTH_FILE}.pending`;
258
277
 
259
278
  function getServerUrl() {
260
279
  if (process.env.DRAFTED_SERVER) return process.env.DRAFTED_SERVER.replace(/\/$/, '');
280
+ if (getState().publicUrl) return getState().publicUrl.replace(/\/$/, '');
281
+ if (process.env.DRAFTED_PUBLIC_URL) return process.env.DRAFTED_PUBLIC_URL.replace(/\/$/, '');
282
+ if (process.env.BASE_URL) return process.env.BASE_URL.replace(/\/$/, '');
283
+ if (process.env.APP_URL) return process.env.APP_URL.replace(/\/$/, '');
261
284
  // Read from config.json next to auth.json (written by install-mcp.sh)
262
285
  try {
263
286
  const cfgPath = join(homedir(), '.drafted', 'config.json');
264
287
  if (existsSync(cfgPath)) {
265
288
  const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
266
289
  if (cfg.server) return cfg.server.replace(/\/$/, '');
290
+ if (cfg.publicUrl) return cfg.publicUrl.replace(/\/$/, '');
267
291
  }
268
292
  } catch { /* fall through */ }
269
293
  return `http://localhost:${process.env.DRAFTED_PORT || 3477}`;
270
294
  }
271
295
 
296
+ function getInstallInfo() {
297
+ if (process.env.DRAFTED_TELEMETRY === '0') return null;
298
+ try {
299
+ const installPath = join(homedir(), '.drafted', 'install.json');
300
+ if (!existsSync(installPath)) return null;
301
+ const info = JSON.parse(readFileSync(installPath, 'utf8'));
302
+ if (info.telemetry === false) return null;
303
+ 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;
304
+ return info;
305
+ } catch {
306
+ return null;
307
+ }
308
+ }
309
+
310
+
311
+ function osFamily() {
312
+ const p = platform();
313
+ if (p === 'darwin') return 'macos';
314
+ if (p === 'win32') return 'windows';
315
+ if (p === 'linux') return 'linux';
316
+ return 'unknown';
317
+ }
318
+
319
+ function normalizedArch() {
320
+ const a = osArch();
321
+ return ['x64', 'arm64', 'arm', 'ia32'].includes(a) ? a : 'unknown';
322
+ }
323
+
324
+ function mcpMode() {
325
+ return process.argv.includes('--http') ? 'http' : 'stdio';
326
+ }
327
+
328
+ function reportInstallationEvent(event, extra = {}) {
329
+ const info = getInstallInfo();
330
+ if (!info) return;
331
+ fetch(`${getServerUrl()}/api/installations/report`, {
332
+ method: 'POST',
333
+ headers: { 'Content-Type': 'application/json', 'User-Agent': `Drafted MCP/${PACKAGE_VERSION}` },
334
+ body: JSON.stringify({
335
+ installId: info.installId,
336
+ event,
337
+ schemaVersion: 1,
338
+ cliVersion: PACKAGE_VERSION,
339
+ osFamily: osFamily(),
340
+ osVersion: osRelease().slice(0, 60),
341
+ arch: normalizedArch(),
342
+ nodeVersion: process.version,
343
+ mcpMode: mcpMode(),
344
+ source: 'mcp',
345
+ ...extra,
346
+ }),
347
+ }).catch(() => {});
348
+ }
349
+
350
+ function classifyMcpError(error) {
351
+ const msg = String(error?.message || error || '').toLowerCase();
352
+ const causeCode = String(error?.cause?.code || error?.code || '').toLowerCase();
353
+ if (causeCode) return causeCode.slice(0, 80);
354
+ if (msg.includes('fetch failed')) return 'fetch_failed';
355
+ if (msg.includes('failed to fetch')) return 'fetch_failed';
356
+ if (msg.includes('network')) return 'network_error';
357
+ if (msg.includes('timeout') || msg.includes('timed out')) return 'timeout';
358
+ const httpMatch = msg.match(/http\s+(\d{3})/);
359
+ if (httpMatch) return `http_${httpMatch[1]}`;
360
+ if (msg.includes('unauthorized') || msg.includes('401')) return 'auth_401';
361
+ if (msg.includes('forbidden') || msg.includes('403')) return 'auth_403';
362
+ return 'tool_error';
363
+ }
364
+
365
+ function scrubLogValue(value) {
366
+ if (typeof value !== 'string') return value;
367
+ return value
368
+ .replace(/([?&](?:token|code|dci|session|auth|password|secret)=)[^&\s]+/gi, '$1[Filtered]')
369
+ .replace(/(Bearer\s+)[A-Za-z0-9._~+\/-]+=*/gi, '$1[Filtered]')
370
+ .replace(/(gc_session=)[^;\s]+/gi, '$1[Filtered]')
371
+ .slice(0, 500);
372
+ }
373
+
374
+ function serverOriginForLog() {
375
+ try { return new URL(getServerUrl()).origin; } catch { return 'invalid_server_url'; }
376
+ }
377
+
378
+ function writeMcpClientLog(tool, error) {
379
+ const entry = {
380
+ ts: new Date().toISOString(),
381
+ level: 'error',
382
+ component: 'drafted-mcp-client',
383
+ version: PACKAGE_VERSION,
384
+ tool: String(tool || 'unknown').slice(0, 80),
385
+ errorCode: classifyMcpError(error),
386
+ message: scrubLogValue(error?.message || String(error)),
387
+ causeCode: scrubLogValue(error?.cause?.code || error?.code || ''),
388
+ causeMessage: scrubLogValue(error?.cause?.message || ''),
389
+ server: serverOriginForLog(),
390
+ osFamily: osFamily(),
391
+ nodeVersion: process.version,
392
+ mcpMode: mcpMode(),
393
+ };
394
+ const line = `[Drafted MCP] ${JSON.stringify(entry)}\n`;
395
+ try { console.error(line.trimEnd()); } catch { /* ignore */ }
396
+ try {
397
+ const logPath = join(homedir(), '.drafted', 'mcp-client.log');
398
+ mkdirSync(dirname(logPath), { recursive: true });
399
+ appendFileSync(logPath, line, { mode: 0o600 });
400
+ } catch { /* best effort */ }
401
+ }
402
+
403
+ function reportMcpToolError(tool, error) {
404
+ writeMcpClientLog(tool, error);
405
+ reportInstallationEvent(UMAMI_EVENTS.DRAFTED_MCP_ERROR, {
406
+ tool: String(tool || 'unknown').slice(0, 80),
407
+ errorCode: classifyMcpError(error),
408
+ });
409
+ }
410
+
411
+ function wikiBrowserUrl(path = '') {
412
+ const normalized = normalizeWikiPath(path || '');
413
+ if (!normalized) return `${getServerUrl()}/wiki`;
414
+ return `${getServerUrl()}/wiki/${normalized.split('/').map(encodeURIComponent).join('/')}`;
415
+ }
416
+
272
417
  function getBootstrapSessionId() {
273
418
  try {
274
419
  if (existsSync(AUTH_FILE)) {
@@ -279,6 +424,87 @@ function getBootstrapSessionId() {
279
424
  return null;
280
425
  }
281
426
 
427
+ function persistAuthSession(data) {
428
+ mkdirSync(dirname(AUTH_FILE), { recursive: true });
429
+ writeFileSync(AUTH_FILE, JSON.stringify({
430
+ sessionId: data.sessionId,
431
+ userId: data.userId || null,
432
+ orgId: data.orgId || null,
433
+ server: getServerUrl(),
434
+ updatedAt: new Date().toISOString(),
435
+ }, null, 2), { mode: 0o600 });
436
+ }
437
+
438
+ function persistPendingDeviceCode(data) {
439
+ if (!data?.deviceCode) return;
440
+ const pending = {
441
+ ...data,
442
+ server: getServerUrl(),
443
+ createdAt: Date.now(),
444
+ expiresAt: Date.now() + (Number(data.expiresIn || 900) * 1000),
445
+ };
446
+ getSessionState().pendingDeviceCode = pending;
447
+ try {
448
+ mkdirSync(dirname(PENDING_AUTH_FILE), { recursive: true });
449
+ writeFileSync(PENDING_AUTH_FILE, JSON.stringify(pending, null, 2), { mode: 0o600 });
450
+ } catch { /* best effort; in-memory state still works for long-lived stdio */ }
451
+ schedulePendingAuthPoll(2000);
452
+ }
453
+
454
+ function clearPendingDeviceCode() {
455
+ stopPendingAuthPoll();
456
+ getSessionState().pendingDeviceCode = null;
457
+ try { if (existsSync(PENDING_AUTH_FILE)) unlinkSync(PENDING_AUTH_FILE); } catch { /* ignore */ }
458
+ }
459
+
460
+ function getPendingDeviceCode() {
461
+ const inMemory = getSessionState().pendingDeviceCode;
462
+ if (inMemory?.deviceCode) return inMemory;
463
+ try {
464
+ if (!existsSync(PENDING_AUTH_FILE)) return null;
465
+ const pending = JSON.parse(readFileSync(PENDING_AUTH_FILE, 'utf8'));
466
+ if (!pending?.deviceCode) return null;
467
+ if (pending.server && pending.server !== getServerUrl()) return null;
468
+ if (pending.expiresAt && Date.now() > Number(pending.expiresAt)) {
469
+ clearPendingDeviceCode();
470
+ return null;
471
+ }
472
+ getSessionState().pendingDeviceCode = pending;
473
+ return pending;
474
+ } catch {
475
+ return null;
476
+ }
477
+ }
478
+
479
+
480
+ let pendingAuthPollTimer = null;
481
+
482
+ function stopPendingAuthPoll() {
483
+ if (pendingAuthPollTimer) clearTimeout(pendingAuthPollTimer);
484
+ pendingAuthPollTimer = null;
485
+ }
486
+
487
+ function schedulePendingAuthPoll(delayMs = 2000) {
488
+ stopPendingAuthPoll();
489
+ pendingAuthPollTimer = setTimeout(async () => {
490
+ pendingAuthPollTimer = null;
491
+ const pending = getPendingDeviceCode();
492
+ if (!pending?.deviceCode) return;
493
+ const approved = await consumePendingDeviceCode();
494
+ if (approved) {
495
+ try { connectAgentWs().catch(() => {}); } catch { /* ignore */ }
496
+ return;
497
+ }
498
+ const expiresAt = Number(pending.expiresAt || 0);
499
+ if (expiresAt && Date.now() >= expiresAt) {
500
+ clearPendingDeviceCode();
501
+ return;
502
+ }
503
+ schedulePendingAuthPoll(2000);
504
+ }, delayMs);
505
+ if (typeof pendingAuthPollTimer.unref === 'function') pendingAuthPollTimer.unref();
506
+ }
507
+
282
508
  function getAuthHeaders() {
283
509
  const sid = getState().sessionId || getBootstrapSessionId();
284
510
  if (sid) return { Cookie: `gc_session=${sid}` };
@@ -304,6 +530,8 @@ async function cloneSession() {
304
530
  }
305
531
 
306
532
  async function ensureSession() {
533
+ if (getState().sessionId) return;
534
+ await consumePendingDeviceCode();
307
535
  if (getState().sessionId) return;
308
536
  await cloneSession();
309
537
  }
@@ -335,10 +563,13 @@ async function api(method, path, body, _retried) {
335
563
  const res = await fetch(url, opts);
336
564
  const text = await res.text();
337
565
 
338
- // Session expired after server restart re-clone and retry once
566
+ // Session expired after server restart, or a browser approval just completed
567
+ // for auth(action="get_link"). First try to consume any pending device code,
568
+ // then fall back to cloning the saved browser session, and retry once.
339
569
  if (res.status === 401 && !_retried) {
340
570
  getState().sessionId = null;
341
- await cloneSession();
571
+ const approved = await consumePendingDeviceCode();
572
+ if (!approved) await cloneSession();
342
573
  return api(method, path, body, true);
343
574
  }
344
575
 
@@ -398,6 +629,22 @@ function ok(text, opts) {
398
629
  return result;
399
630
  }
400
631
 
632
+ function summarizeSkillForSearch(skill) {
633
+ if (!skill || typeof skill !== 'object') return skill;
634
+ return {
635
+ id: skill.id,
636
+ orgId: skill.orgId ?? null,
637
+ name: skill.name,
638
+ slug: skill.slug,
639
+ description: skill.description,
640
+ tags: skill.tags || [],
641
+ triggerPatterns: skill.triggerPatterns || [],
642
+ version: skill.version,
643
+ updatedAt: skill.updatedAt,
644
+ fileCount: Array.isArray(skill.files) ? skill.files.length : undefined,
645
+ };
646
+ }
647
+
401
648
  // Build the structuredContent shape that the frame-preview widget reads.
402
649
  // Tools that produce or return a frame (read/write/edit) call this so the
403
650
  // model and widget see the same metadata view.
@@ -545,6 +792,7 @@ if (!globalThis.__draftedAgentWsBootstrapped) {
545
792
  }
546
793
 
547
794
  function err(error) {
795
+ reportMcpToolError(getState().currentTool, error);
548
796
  return { content: [{ type: 'text', text: error.message || String(error) }], isError: true };
549
797
  }
550
798
 
@@ -694,7 +942,7 @@ function applyHashlineOps(content, operations) {
694
942
  let lines = content.split('\n');
695
943
  const lineToHash = {};
696
944
  for (let i = 0; i < lines.length; i++) {
697
- lineToHash[i] = createHash('sha256').update(lines[i] || '').digest('hex').slice(0, 4);
945
+ lineToHash[i] = createHash('sha256').update(lines[i] || '').digest('hex').slice(0, 12);
698
946
  }
699
947
  const sorted = [...operations].reverse();
700
948
  for (const op of sorted) {
@@ -744,10 +992,49 @@ function runCLI(command, args = [], options = {}) {
744
992
 
745
993
  // ── Login tools ─────────────────────────────────────────────────────
746
994
 
747
- // Shared pending device code — auth(action=get_link) stores it, auth(action=login) reuses it
748
- let pendingDeviceCode = null;
995
+ // Per-session pending device code — auth(action=get_link) stores it, and
996
+ // auth(action=login) or a later tool call can consume it.
997
+ //
998
+ // This prevents the "I already authed" loop after get_link: once the user
999
+ // finishes the browser step, the next tool call can exchange the approved
1000
+ // device code for a Drafted session automatically.
1001
+ //
1002
+ // SCOPE: This `auth` tool is for the stdio path only — when drafted-mcp runs
1003
+ // as a local Node process (npm-installed `drafted-mcp` for self-hosters,
1004
+ // Cursor/VS Code one-click installs that use stdio). The Claude Code plugin
1005
+ // has used HTTP MCP since v1.7.17 and authenticates via Clerk OAuth at the
1006
+ // MCP protocol layer (handled by Claude Code itself, see src/middleware/oauth.ts).
1007
+ // HTTP-mode callers never reach this tool because Clerk gates all /mcp tool
1008
+ // calls before authentication. Don't add new auth flows here — add them to
1009
+ // src/auth/clerk.ts instead.
1010
+
1011
+ async function consumePendingDeviceCode() {
1012
+ const pending = getPendingDeviceCode();
1013
+ if (!pending?.deviceCode) return false;
749
1014
 
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.', {
1015
+ try {
1016
+ const res = await fetch(`${getServerUrl()}/auth/device/token`, {
1017
+ method: 'POST',
1018
+ headers: { 'Content-Type': 'application/json' },
1019
+ body: JSON.stringify({ deviceCode: pending.deviceCode }),
1020
+ });
1021
+ if (!res.ok) return false;
1022
+ const data = await res.json();
1023
+ if (data.status === 'approved' && data.sessionId) {
1024
+ persistAuthSession(data);
1025
+ getState().sessionId = data.sessionId;
1026
+ clearPendingDeviceCode();
1027
+ return true;
1028
+ }
1029
+ if (data.status === 'expired') {
1030
+ clearPendingDeviceCode();
1031
+ }
1032
+ } catch { /* ignore and fall through */ }
1033
+
1034
+ return false;
1035
+ }
1036
+
1037
+ 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
1038
  action: z.enum(['get_link', 'login']).describe('Operation to perform.'),
752
1039
  }, async ({ action }) => {
753
1040
  try {
@@ -755,7 +1042,7 @@ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL i
755
1042
  const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
756
1043
  if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
757
1044
  const data = await codeRes.json();
758
- pendingDeviceCode = data;
1045
+ persistPendingDeviceCode(data);
759
1046
  return ok(data.verificationUrl);
760
1047
  }
761
1048
  if (action === 'login') {
@@ -775,9 +1062,9 @@ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL i
775
1062
  let deviceCode, verificationUrl, expiresIn;
776
1063
  let reusingPending = false;
777
1064
 
778
- if (pendingDeviceCode) {
779
- ({ deviceCode, verificationUrl, expiresIn } = pendingDeviceCode);
780
- pendingDeviceCode = null;
1065
+ const pending = getPendingDeviceCode();
1066
+ if (pending) {
1067
+ ({ deviceCode, verificationUrl, expiresIn } = pending);
781
1068
  reusingPending = true;
782
1069
  } else {
783
1070
  const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
@@ -813,16 +1100,8 @@ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL i
813
1100
  const data = await res.json();
814
1101
 
815
1102
  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));
1103
+ persistAuthSession(data);
1104
+ clearPendingDeviceCode();
826
1105
 
827
1106
  getState().sessionId = null;
828
1107
  await cloneSession();
@@ -1171,7 +1450,7 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
1171
1450
  const listing = await api('GET', `/api/fs?path=/${key}&projectId=${projectId}&recursive=true`);
1172
1451
  const entries = listing.entries || [];
1173
1452
  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'));
1453
+ 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
1454
  const frameCount = realFrames.length;
1176
1455
  if (frameCount > 0) {
1177
1456
  throw new Error(`Layer "${key}" contains ${frameCount} frame(s). Use force: true to confirm deletion.`);
@@ -1209,6 +1488,32 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
1209
1488
  } catch (error) { return err(error); }
1210
1489
  });
1211
1490
 
1491
+
1492
+ async function getGoogleDriveAvailability() {
1493
+ try {
1494
+ const status = await api('GET', '/api/google/status');
1495
+ return {
1496
+ connected: !!status?.connected,
1497
+ syncEnabled: !!status?.syncEnabled,
1498
+ driveRootFolderId: status?.driveRootFolderId || null,
1499
+ driveRootFolderName: status?.driveRootFolderName || null,
1500
+ workspaceFramesAvailable: !!status?.connected,
1501
+ preference: status?.connected
1502
+ ? 'Strongly prefer Google Workspace frames for docs, sheets, and slides in this org.'
1503
+ : 'Google Drive is not connected; use normal Drafted frames.',
1504
+ };
1505
+ } catch {
1506
+ return {
1507
+ connected: false,
1508
+ syncEnabled: false,
1509
+ driveRootFolderId: null,
1510
+ driveRootFolderName: null,
1511
+ workspaceFramesAvailable: false,
1512
+ preference: 'Google Drive status unavailable; use normal Drafted frames unless a Google Workspace frame succeeds.',
1513
+ };
1514
+ }
1515
+ }
1516
+
1212
1517
  tool('get_org', {
1213
1518
  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
1519
  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 +1534,8 @@ tool('get_org', {
1229
1534
  const me = await api('GET', '/auth/me');
1230
1535
  const orgs = (await api('GET', '/api/orgs')).orgs || [];
1231
1536
  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).' });
1537
+ const googleDrive = await getGoogleDriveAvailability();
1538
+ 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
1539
  }
1234
1540
 
1235
1541
  // Source of truth = THIS MCP session's bound org (what mutations will actually
@@ -1242,6 +1548,8 @@ tool('get_org', {
1242
1548
  const orgs = (data.orgs || data || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
1243
1549
  const activeOrg = sessionOrgId ? (orgs.find(o => o.id === sessionOrgId) || null) : null;
1244
1550
 
1551
+ const googleDrive = await getGoogleDriveAvailability();
1552
+
1245
1553
  let members = [];
1246
1554
  if (sessionOrgId) {
1247
1555
  try {
@@ -1253,27 +1561,34 @@ tool('get_org', {
1253
1561
  activeOrg,
1254
1562
  orgs,
1255
1563
  members: members.map(m => ({ id: m.userId, name: m.username, email: m.email, role: m.role })),
1564
+ googleDrive,
1256
1565
  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.",
1566
+ 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
1567
  });
1259
1568
  } catch (error) { return err(error); }
1260
1569
  });
1261
1570
 
1262
1571
  // ── Filesystem tools (direct HTTP to /api/fs) ─────────────────────
1263
1572
 
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.'),
1573
+ 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`.', {
1574
+ action: z.enum(['read', 'write', 'write_excalidraw', 'edit', 'mv', 'anchor', 'search', 'versions', 'read_version', 'restore_version']).describe('Operation to perform.'),
1266
1575
  path: z.string().optional().describe('[read] /{layer}/{lane}/{filename}, frame URL, or UUID. [write|edit|anchor] /{layer}/{lane}/{filename}.'),
1267
1576
  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.'),
1577
+ 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.'),
1578
+ excalidraw_data: z.any().optional().describe('[write_excalidraw] Excalidraw scene JSON object or JSON string. Defaults to an empty scene.'),
1579
+ mermaid: z.string().optional().describe('[write_excalidraw] Mermaid source to convert into an editable Excalidraw scene.'),
1269
1580
  file_path: z.string().optional().describe('[write] absolute path to a local file to upload. Mutually exclusive with content.'),
1581
+ 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.'),
1582
+ title: z.string().optional().describe('[write + googleType] Title for a new Google Doc/Sheet/Slide. Defaults to filename from path.'),
1583
+ url: z.string().optional().describe('[write + googleType] Existing Google Doc/Sheet/Slide URL to attach.'),
1584
+ googleId: z.string().optional().describe('[write + googleType] Existing Google file ID to attach.'),
1270
1585
  autoSize: z.boolean().optional().describe('[write] measure HTML content and size frame to fit. Content only, not file_path.'),
1271
1586
  width: z.number().optional().describe('[write] explicit width in pixels. Overrides layer default. Ignored if autoSize=true.'),
1272
1587
  height: z.number().optional().describe('[write] explicit height in pixels. Overrides layer default. Ignored if autoSize=true.'),
1273
1588
  color: z.string().optional().describe('[write] CSS color for frame border (e.g. #ff0000, red).'),
1274
1589
  operations: z.array(z.object({
1275
1590
  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.'),
1591
+ lineHash: z.string().describe('Hash of the target line (from read output). Use the full hash to avoid ambiguity in large frames.'),
1277
1592
  newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
1278
1593
  })).optional().describe('[edit] hashline edit operations'),
1279
1594
  from: z.string().optional().describe('[mv] source path /{layer}/{lane}/{filename}'),
@@ -1283,6 +1598,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1283
1598
  query: z.string().optional().describe('[search] term to match against frame names'),
1284
1599
  projectId: z.string().optional().describe('[search] limit to a specific project (optional)'),
1285
1600
  limit: z.number().optional().describe('[search] max results (default 50, max 200)'),
1601
+ versionId: z.string().optional().describe('[read_version|restore_version] version id'),
1602
+ reason: z.string().optional().describe('[restore_version] reason recorded on the snapshot of current content'),
1286
1603
  }, async (args) => {
1287
1604
  try {
1288
1605
  const { action } = args;
@@ -1330,28 +1647,55 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1330
1647
  });
1331
1648
  }
1332
1649
  case 'write': {
1333
- const { path, content, file_path, autoSize, width, height, color } = args;
1650
+ const { path, content, file_path, autoSize, width, height, color, googleType, title, url, googleId } = args;
1334
1651
  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');
1652
+ const writeSources = [content != null, !!file_path, !!googleType].filter(Boolean).length;
1653
+ if (writeSources > 1) throw new Error('Provide only one of content, file_path, or googleType');
1654
+ if (writeSources === 0) throw new Error('Provide content, file_path, or googleType');
1337
1655
  const skillErr = await checkProjectSkills(getState().projectId);
1338
1656
  if (skillErr) return err(new Error(skillErr));
1339
1657
  const anchorErr = await checkAnchors(parseLayer(path));
1340
1658
  if (anchorErr) return err(new Error(anchorErr));
1341
1659
  const parts = path.replace(/^\/+/, '').split('/');
1342
1660
  if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
1661
+ if (googleType) {
1662
+ const projectId = getState().projectId;
1663
+ if (!projectId) throw new Error('Open a project first with project(action="open") before creating Google Workspace frames');
1664
+ const label = basename(parts[2], extname(parts[2])) || parts[2];
1665
+ const body = {
1666
+ projectId,
1667
+ layer: parts[0],
1668
+ lane: parts[1],
1669
+ label,
1670
+ type: googleType,
1671
+ width,
1672
+ height,
1673
+ };
1674
+ const result = url || googleId
1675
+ ? await api('POST', '/api/google/workspace/frame', { ...body, url, googleId })
1676
+ : await api('POST', '/api/google/workspace/create-frame', { ...body, title: title || label });
1677
+ return ok(withProject(withFrameBreadcrumb({
1678
+ ...result,
1679
+ path,
1680
+ label,
1681
+ contentType: 'text/html',
1682
+ sourceType: googleType,
1683
+ }, { hint: true })), {
1684
+ structuredContent: frameStructuredContent({ ...result, path, label, contentType: 'text/html' }, projectCtx),
1685
+ });
1686
+ }
1343
1687
  let body;
1344
1688
  if (file_path) {
1345
1689
  const resolved = resolve(file_path);
1346
1690
  if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
1347
1691
  const ext = extname(resolved).toLowerCase();
1348
- const TEXT_EXTS = ['.html', '.htm', '.svg', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml'];
1692
+ const TEXT_EXTS = ['.html', '.htm', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml', '.excalidraw'];
1349
1693
  if (TEXT_EXTS.includes(ext)) {
1350
1694
  body = { content: readFileSync(resolved, 'utf8') };
1351
1695
  if (autoSize) body.autoSize = true;
1352
1696
  } else {
1353
1697
  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' };
1698
+ 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
1699
  body = { base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
1356
1700
  }
1357
1701
  } else {
@@ -1367,6 +1711,28 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1367
1711
  _meta: body.content ? { frameHtml: body.content } : undefined,
1368
1712
  });
1369
1713
  }
1714
+ case 'write_excalidraw': {
1715
+ const { path, excalidraw_data, mermaid, width, height, color } = args;
1716
+ if (!path) throw new Error('path required for action=write_excalidraw');
1717
+ if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
1718
+ const skillErr = await checkProjectSkills(getState().projectId);
1719
+ if (skillErr) return err(new Error(skillErr));
1720
+ const anchorErr = await checkAnchors(parseLayer(path));
1721
+ if (anchorErr) return err(new Error(anchorErr));
1722
+ const parts = path.replace(/^\/+/, '').split('/');
1723
+ if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
1724
+ const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
1725
+ const scene = mermaid ? await excalidrawSceneFromMermaid(mermaid) : (excalidraw_data ?? emptyExcalidrawScene());
1726
+ const body = { content: stringifyExcalidrawScene(scene) };
1727
+ if (width) body.width = width;
1728
+ if (height) body.height = height;
1729
+ if (color) body.color = color;
1730
+ const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${filename}`, body);
1731
+ return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
1732
+ structuredContent: frameStructuredContent(result, projectCtx),
1733
+ _meta: { frameHtml: body.content },
1734
+ });
1735
+ }
1370
1736
  case 'edit': {
1371
1737
  const { path, operations } = args;
1372
1738
  if (!path) throw new Error('path required for action=edit');
@@ -1412,6 +1778,27 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1412
1778
  if (skillErr) return err(new Error(skillErr));
1413
1779
  return ok(withProject(await api('POST', '/api/fs/anchor', { path, anchored })));
1414
1780
  }
1781
+
1782
+ case 'versions': {
1783
+ const { path } = args;
1784
+ if (!path) throw new Error('path required for action=versions');
1785
+ const result = await api('GET', `/api/fs/versions?path=${encodeURIComponent(path)}`);
1786
+ return ok(withProject(result));
1787
+ }
1788
+ case 'read_version': {
1789
+ const { versionId } = args;
1790
+ if (!versionId) throw new Error('versionId required for action=read_version');
1791
+ const result = await api('GET', `/api/fs/versions/${versionId}`);
1792
+ return ok(withProject(result));
1793
+ }
1794
+ case 'restore_version': {
1795
+ const { versionId, reason } = args;
1796
+ if (!versionId) throw new Error('versionId required for action=restore_version');
1797
+ const skillErr = await checkProjectSkills(getState().projectId);
1798
+ if (skillErr) return err(new Error(skillErr));
1799
+ const result = await api('POST', '/api/fs/restore-version', { versionId, reason });
1800
+ return ok(withProject(result));
1801
+ }
1415
1802
  case 'search': {
1416
1803
  const { query, projectId, limit = 50 } = args;
1417
1804
  if (!query) throw new Error('query required for action=search');
@@ -1587,7 +1974,7 @@ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "projec
1587
1974
 
1588
1975
  // Handle frame operations via the batch API
1589
1976
  if (frameOps.length > 0) {
1590
- const TEXT_EXTS = ['.html', '.htm', '.svg', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml'];
1977
+ const TEXT_EXTS = ['.html', '.htm', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml', '.excalidraw'];
1591
1978
  const resolvedOps = frameOps.map(op => {
1592
1979
  if (op.tool === 'write' && op.file_path) {
1593
1980
  const resolved = resolve(op.file_path);
@@ -1805,7 +2192,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
1805
2192
  ]).describe('Operation to perform.'),
1806
2193
  query: z.string().optional().describe('[search] term to match against name/description/content'),
1807
2194
  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)'),
2195
+ 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
2196
  limit: z.number().optional().describe('[search] max results (default 25, max 100)'),
1810
2197
  skill: z.string().optional().describe('[load] skill ID (UUID) or slug'),
1811
2198
  skillId: z.string().optional().describe('[update|remove|attach|detach|favorite|unfavorite|read_file|update_file] skill ID'),
@@ -1829,10 +2216,14 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
1829
2216
  const endpoint = query ? '/api/skills/search' : '/api/skills';
1830
2217
  const result = await api('GET', `${endpoint}${qs ? '?' + qs : ''}`);
1831
2218
  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);
2219
+ if (Array.isArray(result?.skills)) {
2220
+ if (result.skills.length > cap) {
2221
+ result.totalAvailable = result.skills.length;
2222
+ result.truncated = true;
2223
+ result.skills = result.skills.slice(0, cap);
2224
+ }
2225
+ result.skills = result.skills.map(summarizeSkillForSearch);
2226
+ 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
2227
  }
1837
2228
  return ok(result);
1838
2229
  }
@@ -1846,6 +2237,18 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
1846
2237
  return ok(result);
1847
2238
  }
1848
2239
  case 'list': {
2240
+ if (args.scope || args.tags?.length) {
2241
+ const params = new URLSearchParams();
2242
+ params.set('scope', args.scope || 'all');
2243
+ if (args.tags?.length) params.set('tags', args.tags.join(','));
2244
+ const result = await api('GET', `/api/skills?${params.toString()}`);
2245
+ if (Array.isArray(result?.skills)) {
2246
+ result.skills = result.skills.map(summarizeSkillForSearch);
2247
+ 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.';
2248
+ }
2249
+ return ok(result);
2250
+ }
2251
+
1849
2252
  // Prefer the explicit projectId param; otherwise the active project;
1850
2253
  // otherwise fall back to org-attached skills so list works in
1851
2254
  // empty-org / wiki-only sessions where there's no project to bind to.
@@ -1951,7 +2354,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
1951
2354
  frontmatter: z.any().optional().describe('[write] frontmatter object'),
1952
2355
  operations: z.array(z.object({
1953
2356
  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)'),
2357
+ lineHash: z.string().describe('Hash of the target line (from read output)'),
1955
2358
  newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
1956
2359
  })).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
1957
2360
  from: z.string().optional().describe('[mv] source path'),
@@ -2006,6 +2409,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2006
2409
  title: p.title,
2007
2410
  type: p.type,
2008
2411
  id: p.id,
2412
+ url: wikiBrowserUrl(p.path),
2009
2413
  }));
2010
2414
  return ok({ tree });
2011
2415
  }
@@ -2021,8 +2425,8 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2021
2425
  }
2022
2426
  return ok({
2023
2427
  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 })),
2428
+ ...Array.from(dirs).sort().map(d => ({ type: 'directory', name: d, url: wikiBrowserUrl(d) })),
2429
+ ...rootPages.map(p => ({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) })),
2026
2430
  ],
2027
2431
  });
2028
2432
  }
@@ -2033,14 +2437,14 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2033
2437
  const prefix = parent + '/';
2034
2438
  for (const p of pages) {
2035
2439
  if (p.path === parent) {
2036
- children.push({ type: 'page', path: p.path, title: p.title, id: p.id });
2440
+ children.push({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) });
2037
2441
  } else if (p.path.startsWith(prefix)) {
2038
2442
  const rest = p.path.slice(prefix.length);
2039
2443
  if (rest.includes('/')) {
2040
2444
  const sub = rest.split('/')[0];
2041
- if (!seenDirs.has(sub)) { seenDirs.add(sub); children.push({ type: 'directory', name: parent + '/' + sub }); }
2445
+ if (!seenDirs.has(sub)) { seenDirs.add(sub); children.push({ type: 'directory', name: parent + '/' + sub, url: wikiBrowserUrl(parent + '/' + sub) }); }
2042
2446
  } else {
2043
- children.push({ type: 'page', path: p.path, title: p.title, id: p.id });
2447
+ children.push({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) });
2044
2448
  }
2045
2449
  }
2046
2450
  }
@@ -2055,12 +2459,12 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2055
2459
  .filter(p => p.updatedAt)
2056
2460
  .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
2057
2461
  .slice(0, Math.max(1, recentLimit))
2058
- .map(p => ({ path: p.path, title: p.title, updatedAt: p.updatedAt }));
2462
+ .map(p => ({ path: p.path, title: p.title, updatedAt: p.updatedAt, url: wikiBrowserUrl(p.path) }));
2059
2463
  return ok({ pages: recent });
2060
2464
  }
2061
2465
 
2062
2466
  // ── read ────────────────────────────────────────────────────
2063
- // Returns content in hashline format (`lineNum:hash|content`) so the
2467
+ // Returns content in hashline format (`LINE+ID|content`) so the
2064
2468
  // agent can produce hashline edit operations. Mirrors frame.read.
2065
2469
  case 'read': {
2066
2470
  const { path: readPath, lines: readLines } = args;
@@ -2088,6 +2492,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2088
2492
  lastEditedBy: page.updatedBy,
2089
2493
  lastEditedAt: page.updatedAt,
2090
2494
  backlinkCount,
2495
+ url: wikiBrowserUrl(page.path),
2091
2496
  });
2092
2497
  }
2093
2498
 
@@ -2096,7 +2501,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2096
2501
  const { query: searchQuery, limit: searchLimit = 25 } = args;
2097
2502
  if (!searchQuery) throw new Error('query required for action=search');
2098
2503
  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 }));
2504
+ const hits = (result.hits || []).map(h => ({ path: h.path, title: h.title, url: wikiBrowserUrl(h.path) }));
2100
2505
  return ok({ hits });
2101
2506
  }
2102
2507
 
@@ -2131,13 +2536,13 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2131
2536
  title: 'Log',
2132
2537
  content: entry + '\n',
2133
2538
  });
2134
- return ok(withOrg({ appended: true, created: true, pageId: created.id }));
2539
+ return ok(withOrg({ appended: true, created: true, pageId: created.id, path: 'log', url: wikiBrowserUrl('log') }));
2135
2540
  }
2136
2541
 
2137
2542
  // Append to existing log
2138
2543
  const updatedContent = (existingContent.endsWith('\n') ? existingContent : existingContent + '\n') + entry + '\n';
2139
2544
  await api('PATCH', `/api/wiki/pages/${existingId}`, { content: updatedContent });
2140
- return ok(withOrg({ appended: true }));
2545
+ return ok(withOrg({ appended: true, path: 'log', url: wikiBrowserUrl('log') }));
2141
2546
  }
2142
2547
 
2143
2548
  // ── health ──────────────────────────────────────────────────
@@ -2160,17 +2565,39 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2160
2565
  try {
2161
2566
  const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
2162
2567
  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 }));
2568
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path) }));
2164
2569
  } catch {
2165
2570
  const result = await api('POST', '/api/wiki/pages', body);
2166
- return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true }));
2571
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path) }));
2167
2572
  }
2168
2573
  }
2169
2574
 
2170
2575
  // ── edit ────────────────────────────────────────────────────
2171
2576
  // Hashlines come from `read` (which now formats content as
2172
- // `lineNum:hash|content`). The server applies the ops via the same
2577
+ // `LINE+ID|content`). The server applies the ops via the same
2173
2578
  // hashline algorithm — no client-side re-hashing, no algorithm drift.
2579
+ case 'write_excalidraw': {
2580
+ const { path, excalidraw_data, mermaid, width, height, color } = args;
2581
+ if (!path) throw new Error('path required for action=write_excalidraw');
2582
+ if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
2583
+ const skillErr = await checkProjectSkills(getState().projectId);
2584
+ if (skillErr) return err(new Error(skillErr));
2585
+ const anchorErr = await checkAnchors(parseLayer(path));
2586
+ if (anchorErr) return err(new Error(anchorErr));
2587
+ const parts = path.replace(/^\/+/, '').split('/');
2588
+ if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
2589
+ const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
2590
+ const scene = mermaid ? await excalidrawSceneFromMermaid(mermaid) : (excalidraw_data ?? emptyExcalidrawScene());
2591
+ const body = { content: stringifyExcalidrawScene(scene) };
2592
+ if (width) body.width = width;
2593
+ if (height) body.height = height;
2594
+ if (color) body.color = color;
2595
+ const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${filename}`, body);
2596
+ return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
2597
+ structuredContent: frameStructuredContent(result, projectCtx),
2598
+ _meta: { frameHtml: body.content },
2599
+ });
2600
+ }
2174
2601
  case 'edit': {
2175
2602
  const { path: editPath, operations: editOps } = args;
2176
2603
  if (!editPath) throw new Error('path required for action=edit');
@@ -2178,7 +2605,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2178
2605
  const normalized = normalizeWikiPath(editPath);
2179
2606
  const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
2180
2607
  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 }));
2608
+ return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied, url: wikiBrowserUrl(result.path) }));
2182
2609
  }
2183
2610
 
2184
2611
  // ── mv ──────────────────────────────────────────────────────
@@ -2199,7 +2626,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2199
2626
 
2200
2627
  // Server-side cascade: /move rewrites referrers in one transaction
2201
2628
  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 }));
2629
+ return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0, url: wikiBrowserUrl(moved.path) }));
2203
2630
  }
2204
2631
 
2205
2632
  // ── rm ──────────────────────────────────────────────────────
@@ -2251,8 +2678,8 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2251
2678
  return ok(withOrg({
2252
2679
  created: result.created?.length ?? 0,
2253
2680
  updated: result.updated?.length ?? 0,
2254
- createdPaths: (result.created ?? []).map((r) => r.path),
2255
- updatedPaths: (result.updated ?? []).map((r) => r.path),
2681
+ createdPages: (result.created ?? []).map((r) => ({ path: r.path, url: wikiBrowserUrl(r.path) })),
2682
+ updatedPages: (result.updated ?? []).map((r) => ({ path: r.path, url: wikiBrowserUrl(r.path) })),
2256
2683
  errors: result.errors ?? [],
2257
2684
  }));
2258
2685
  }