drafted 1.11.23 → 1.11.25

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
@@ -1107,6 +1138,18 @@ async function requireBoundOrgForProjectlessMutation(explicitOrg) {
1107
1138
  const sess = getSessionState();
1108
1139
  if (sess.boundOrgId) return; // bound via project open / get_org switch
1109
1140
  if (getState().projectId) return; // an active project implies its org
1141
+ // Remote/web sessions are isolated per connection: each one gets its OWN
1142
+ // server-side session row with its own org_id set on connect, so the session
1143
+ // org IS this session's binding — there's no shared, long-lived session to
1144
+ // confuse here the way stdio has. Adopt that org automatically instead of
1145
+ // forcing the agent to call get_org switch / project open before a project-less
1146
+ // wiki or skill write. The DRAFT-36 Phase 4 "refuse to guess" guard below
1147
+ // therefore applies to stdio only.
1148
+ if (isRemote) {
1149
+ const ctx = await getCurrentOrgContext();
1150
+ if (ctx?.id) sess.boundOrgId = ctx.id;
1151
+ return;
1152
+ }
1110
1153
  let count = sess.cachedOrgCount;
1111
1154
  if (count == null) {
1112
1155
  try {
@@ -2057,7 +2100,7 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
2057
2100
  color: z.string().optional().describe('[write] CSS color for frame border (e.g. #ff0000, red).'),
2058
2101
  operations: z.array(z.object({
2059
2102
  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.'),
2103
+ 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
2104
  newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
2062
2105
  })).optional().describe('[edit] hashline edit operations'),
2063
2106
  from: z.string().optional().describe('[mv] source path /{layer}/{lane}/{filename}'),
@@ -2576,9 +2619,9 @@ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "projec
2576
2619
  to: z.string().optional().describe('Destination path (for mv)'),
2577
2620
  operations: z.array(z.object({
2578
2621
  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.'),
2622
+ lineHash: z.string().describe('The full line anchor from read output — line number + 3-char hash, e.g. "182vix". NOT the bare hash.'),
2580
2623
  newContent: z.string().optional(),
2581
- })).optional().describe('Edit operations (for edit). lineHash is the full anchor (lineNum + hash), e.g. "182vi".'),
2624
+ })).optional().describe('Edit operations (for edit). lineHash is the full anchor (lineNum + hash), e.g. "182vix".'),
2582
2625
  asset_path: z.string().optional().describe('Relative asset path (for upload_asset, e.g., "css/styles.css")'),
2583
2626
  content_type: z.string().optional().describe('MIME type (for upload_asset, auto-detected if omitted)'),
2584
2627
  frame_id: z.string().optional().describe('Frame ID to associate asset with (for upload_asset)'),
@@ -3135,7 +3178,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3135
3178
  frontmatter: z.any().optional().describe('[write] frontmatter object'),
3136
3179
  operations: z.array(z.object({
3137
3180
  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.'),
3181
+ 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
3182
  newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
3140
3183
  })).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
3141
3184
  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.25",
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": [