drafted 1.11.23 → 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}'),
@@ -2576,9 +2607,9 @@ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "projec
2576
2607
  to: z.string().optional().describe('Destination path (for mv)'),
2577
2608
  operations: z.array(z.object({
2578
2609
  type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']),
2579
- 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.'),
2580
2611
  newContent: z.string().optional(),
2581
- })).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".'),
2582
2613
  asset_path: z.string().optional().describe('Relative asset path (for upload_asset, e.g., "css/styles.css")'),
2583
2614
  content_type: z.string().optional().describe('MIME type (for upload_asset, auto-detected if omitted)'),
2584
2615
  frame_id: z.string().optional().describe('Frame ID to associate asset with (for upload_asset)'),
@@ -3135,7 +3166,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3135
3166
  frontmatter: z.any().optional().describe('[write] frontmatter object'),
3136
3167
  operations: z.array(z.object({
3137
3168
  type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
3138
- 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.'),
3139
3170
  newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
3140
3171
  })).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
3141
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.23",
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": [