drafted 1.11.22 → 1.11.24

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.
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Active-project persistence for the stdio MCP.
3
+ *
4
+ * The active project lives only in the in-memory sessionStates Map, so a
5
+ * `system restart mode=mcp` / skill-edit pass that re-spawns the stdio MCP
6
+ * wipes it — the recurring "active project keeps resetting, re-open it" churn.
7
+ * Persisting it lets the long-lived stdio process rehydrate transparently after
8
+ * a restart.
9
+ *
10
+ * Keyed by cwd so two concurrent same-machine agent sessions (each its own
11
+ * stdio MCP, same launch config) don't clobber each other's active project.
12
+ * Only the stdio process persists here; HTTP/remote user sessions are keyed by
13
+ * a real Drafted session id and never call into this store.
14
+ *
15
+ * Side-effect-free on import (no network, no eager writes) so it can be unit
16
+ * tested against the real code without booting the MCP server.
17
+ */
18
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
19
+ import { join, dirname } from 'path';
20
+ import { homedir } from 'os';
21
+
22
+ const DEFAULT_FILE = process.env.DRAFTED_MCP_STATE_FILE || join(homedir(), '.drafted', 'mcp-state.json');
23
+
24
+ function defaultCwd() {
25
+ try { return process.cwd(); } catch { return '__default__'; }
26
+ }
27
+
28
+ export function loadPersistedProject({ file = DEFAULT_FILE, cwd = defaultCwd() } = {}) {
29
+ try {
30
+ const all = JSON.parse(readFileSync(file, 'utf8'));
31
+ const e = all && all[cwd];
32
+ if (e && e.activeProjectId) {
33
+ return {
34
+ activeProjectId: e.activeProjectId,
35
+ activeProjectMeta: e.activeProjectMeta || null,
36
+ boundOrgId: e.boundOrgId || null,
37
+ };
38
+ }
39
+ } catch { /* no state file yet / unreadable — start fresh */ }
40
+ return null;
41
+ }
42
+
43
+ export function savePersistedProject(entry, { file = DEFAULT_FILE, cwd = defaultCwd() } = {}) {
44
+ try {
45
+ let all = {};
46
+ try { all = JSON.parse(readFileSync(file, 'utf8')) || {}; } catch { /* recreate */ }
47
+ if (entry && entry.activeProjectId) {
48
+ all[cwd] = {
49
+ activeProjectId: entry.activeProjectId,
50
+ activeProjectMeta: entry.activeProjectMeta || null,
51
+ boundOrgId: entry.boundOrgId || null,
52
+ };
53
+ } else {
54
+ delete all[cwd];
55
+ }
56
+ mkdirSync(dirname(file), { recursive: true });
57
+ writeFileSync(file, JSON.stringify(all), { mode: 0o600 });
58
+ return true;
59
+ } catch {
60
+ // Best-effort; persistence is an optimization, never block a tool on it.
61
+ return false;
62
+ }
63
+ }
package/mcp/server.mjs CHANGED
@@ -21,6 +21,7 @@ import WebSocket from 'ws';
21
21
  import { LAYERS } from '../src/shared/constants.mjs';
22
22
  import { emptyExcalidrawScene, excalidrawSceneFromMermaid, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
23
23
  import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
24
+ import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
24
25
 
25
26
  // Frame actions that mutate content — gated by G1 (wiki search before editing).
26
27
  // Read-style actions (read, search, versions, get_*/read_*) are exempt.
@@ -92,6 +93,9 @@ function getOrCreateSessionState(sid) {
92
93
  cachedOrgIdTime: 0,
93
94
  wsSessionId: null,
94
95
  pendingDeviceCode: null,
96
+ // Only the long-lived stdio bucket persists its active project to disk;
97
+ // HTTP/remote sessions (keyed by a real Drafted session id) must not.
98
+ _stdio: key === '__stdio__',
95
99
  };
96
100
  sessionStates.set(key, s);
97
101
  }
@@ -439,6 +443,28 @@ registerAppResource(
439
443
  const AUTH_FILE = process.env.DRAFTED_AUTH_FILE || join(homedir(), '.drafted', 'auth.json');
440
444
  const PENDING_AUTH_FILE = process.env.DRAFTED_PENDING_AUTH_FILE || `${AUTH_FILE}.pending`;
441
445
 
446
+ // Active-project persistence (stdio only): see mcp/active-project-store.mjs.
447
+ // Persisted so a `system restart mode=mcp` / skill-edit pass that re-spawns the
448
+ // stdio MCP rehydrates the active project instead of dropping it (the recurring
449
+ // "active project keeps resetting" churn). Keyed by cwd; HTTP/remote sessions
450
+ // (real Drafted session id, never the __stdio__ bucket) never touch this file.
451
+
452
+ // Rehydrate the stdio MCP's active project at process start so the first tool
453
+ // call after a restart is already scoped. Seeds both standaloneState (what
454
+ // api() reads for projectId scoping in stdio mode) and the __stdio__ session
455
+ // bucket (boundOrgId → the X-Drafted-Org per-request scope). No-op when the
456
+ // file is absent (fresh install, or the hosted server process).
457
+ (function rehydrateStdioActiveProject() {
458
+ const p = loadPersistedProject();
459
+ if (!p) { getOrCreateSessionState(null); return; }
460
+ standaloneState.projectId = p.activeProjectId;
461
+ standaloneState.projectMeta = p.activeProjectMeta || null;
462
+ const sess = getOrCreateSessionState(null);
463
+ sess.activeProjectId = p.activeProjectId;
464
+ sess.activeProjectMeta = p.activeProjectMeta || null;
465
+ if (p.boundOrgId) sess.boundOrgId = p.boundOrgId;
466
+ })();
467
+
442
468
  function getServerUrl() {
443
469
  if (process.env.DRAFTED_SERVER) return process.env.DRAFTED_SERVER.replace(/\/$/, '');
444
470
  if (getState().publicUrl) return getState().publicUrl.replace(/\/$/, '');
@@ -835,6 +861,7 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
835
861
  const sess = getSessionState();
836
862
  sess.activeProjectId = null;
837
863
  sess.activeProjectMeta = null;
864
+ if (sess._stdio) savePersistedProject(null);
838
865
  throw new Error(
839
866
  `${msg} — the active project (${meta?.slug || pid}) is no longer in the current org. ` +
840
867
  `Active project cleared. Call project(action="open") to set a new one, or proceed without one for org-scoped tools (wiki, skill).`
@@ -979,6 +1006,10 @@ function setMcpActiveProject(projectId, meta = null) {
979
1006
  // inheriting the root login's current org. Only set when known — a (null,null)
980
1007
  // clear must NOT wipe the bound org (the switch handler sets it explicitly).
981
1008
  if (meta?.orgId) sess.boundOrgId = meta.orgId;
1009
+ // Persist for the stdio process so an MCP restart rehydrates this project.
1010
+ if (sess._stdio) {
1011
+ savePersistedProject(projectId ? { activeProjectId: projectId, activeProjectMeta: meta, boundOrgId: sess.boundOrgId } : null);
1012
+ }
982
1013
  }
983
1014
 
984
1015
  // Returns { id, slug, name, orgId } for the project this MCP session most
@@ -2057,7 +2088,7 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
2057
2088
  color: z.string().optional().describe('[write] CSS color for frame border (e.g. #ff0000, red).'),
2058
2089
  operations: z.array(z.object({
2059
2090
  type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
2060
- lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number + 2-char hash, e.g. "182vi" (the token left of the "|"). NOT the bare 2-char hash "vi": that is rejected, since the same hash can recur on multiple lines.'),
2091
+ lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number + 3-char hash, e.g. "182vix" (the token left of the "|"). NOT the bare hash "vix": that is rejected, since the same hash can recur on multiple lines.'),
2061
2092
  newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
2062
2093
  })).optional().describe('[edit] hashline edit operations'),
2063
2094
  from: z.string().optional().describe('[mv] source path /{layer}/{lane}/{filename}'),
@@ -2494,9 +2525,12 @@ tool('ls', 'List contents of the ACTIVE PROJECT. Use ls / after project(action="
2494
2525
  try {
2495
2526
  const params = new URLSearchParams();
2496
2527
  params.set('path', path);
2497
- // Recursive walks can return huge content payloads force summary mode
2498
- // so each entry stays small. Agents that need full content can read individual frames.
2499
- if (recursive) {
2528
+ // At layer depth (/designs, /wireframes, etc.) auto-recurse so frames in sublanes
2529
+ // are always visible without this, agents only see root frames and miss any frame
2530
+ // whose lane != "". Recursive walks force summary mode to keep payloads small.
2531
+ const cleanPath = (path || '/').replace(/^\/+|\/+$/g, '');
2532
+ const pathDepth = cleanPath ? cleanPath.split('/').length : 0;
2533
+ if (recursive || pathDepth === 1) {
2500
2534
  params.set('recursive', 'true');
2501
2535
  params.set('summary', 'true');
2502
2536
  } else if (summary) {
@@ -2573,9 +2607,9 @@ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "projec
2573
2607
  to: z.string().optional().describe('Destination path (for mv)'),
2574
2608
  operations: z.array(z.object({
2575
2609
  type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']),
2576
- lineHash: z.string().describe('The full line anchor from read output — line number + 2-char hash, e.g. "182vi". NOT the bare 2-char hash.'),
2610
+ lineHash: z.string().describe('The full line anchor from read output — line number + 3-char hash, e.g. "182vix". NOT the bare hash.'),
2577
2611
  newContent: z.string().optional(),
2578
- })).optional().describe('Edit operations (for edit). lineHash is the full anchor (lineNum + hash), e.g. "182vi".'),
2612
+ })).optional().describe('Edit operations (for edit). lineHash is the full anchor (lineNum + hash), e.g. "182vix".'),
2579
2613
  asset_path: z.string().optional().describe('Relative asset path (for upload_asset, e.g., "css/styles.css")'),
2580
2614
  content_type: z.string().optional().describe('MIME type (for upload_asset, auto-detected if omitted)'),
2581
2615
  frame_id: z.string().optional().describe('Frame ID to associate asset with (for upload_asset)'),
@@ -3132,7 +3166,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3132
3166
  frontmatter: z.any().optional().describe('[write] frontmatter object'),
3133
3167
  operations: z.array(z.object({
3134
3168
  type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
3135
- lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number + 2-char hash, e.g. "182vi" (the token left of the "|"). NOT the bare 2-char hash.'),
3169
+ lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number + 3-char hash, e.g. "182vix" (the token left of the "|"). NOT the bare hash.'),
3136
3170
  newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
3137
3171
  })).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
3138
3172
  from: z.string().optional().describe('[mv] source path'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.11.22",
3
+ "version": "1.11.24",
4
4
  "description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
5
5
  "type": "module",
6
6
  "files": [