pmx-canvas 0.1.0 → 0.1.2

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.
@@ -14,5 +14,5 @@ export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMo
14
14
  export declare function sendExtAppBootstrapState(bridge: ExtAppBridgeNotifications, toolInput: Record<string, unknown>, toolResult: CallToolResult | undefined): Promise<void>;
15
15
  export declare function ExtAppFrame({ node }: {
16
16
  node: CanvasNodeState;
17
- }): import("preact/src").JSX.Element;
17
+ }): import("preact/jsx-runtime").JSX.Element;
18
18
  export {};
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Bundled-skill discovery for the PMX Canvas MCP server.
3
+ *
4
+ * Skill files ship inside the npm package under `skills/<name>/SKILL.md`
5
+ * but until 0.1.2 they were not discoverable to the agent — an agent
6
+ * calling `canvas_build_web_artifact` had no way to find the companion
7
+ * `skills/web-artifacts-builder/SKILL.md` prompt that documents the
8
+ * workflow, stack choices, and gotchas.
9
+ *
10
+ * This module locates the bundled `skills/` directory relative to the
11
+ * package root (works for both repo-local development and global npm
12
+ * installs), parses the YAML frontmatter of each `SKILL.md` to produce
13
+ * a compact index, and reads individual skill content on demand.
14
+ *
15
+ * Exposed via MCP as:
16
+ * - `canvas://skills` → JSON index
17
+ * - `canvas://skills/<name>` → full markdown content
18
+ */
19
+ export interface BundledSkill {
20
+ name: string;
21
+ description: string;
22
+ uri: string;
23
+ filePath: string;
24
+ }
25
+ /**
26
+ * Resolve the packaged `skills/` directory. Walks parents from this module
27
+ * looking for a sibling `skills/` that contains at least one `<name>/SKILL.md`,
28
+ * so it works whether the code runs from source (`src/server/…`), from a
29
+ * compiled bundle (`dist/…`), or from a global npm install
30
+ * (`/opt/homebrew/lib/node_modules/pmx-canvas/src/server/…`).
31
+ */
32
+ export declare function findBundledSkillsRoot(): string | null;
33
+ /**
34
+ * Enumerate every `<name>/SKILL.md` under the bundled skills root and return
35
+ * a compact index. Hidden directories (dotfolders) and files that don't parse
36
+ * are skipped silently rather than throwing — missing metadata should never
37
+ * break the MCP server's resource listing.
38
+ */
39
+ export declare function listBundledSkills(): BundledSkill[];
40
+ export declare function readBundledSkill(name: string): string | null;
@@ -7,3 +7,15 @@ export interface NormalizeExtAppToolResultInput {
7
7
  detailedContent?: string;
8
8
  }
9
9
  export declare function normalizeExtAppToolResult(input: NormalizeExtAppToolResultInput): CallToolResult;
10
+ /**
11
+ * Structural equality between two `CallToolResult` values, used by the host
12
+ * ExtAppFrame to suppress echo-back re-renders when an SSE layout update
13
+ * mints a new object reference for an otherwise-unchanged tool result.
14
+ *
15
+ * JSON-stringify is adequate here: tool results are strictly JSON (no
16
+ * functions, symbols, or cycles), typically small, and on the hot path we
17
+ * only hit this when references already differ. For very large payloads
18
+ * (> ~2MB) an early length check skips the stringify to avoid a user-visible
19
+ * stall — such results are treated as "changed" and forwarded to the widget.
20
+ */
21
+ export declare function extAppToolResultsMatch(a: CallToolResult, b: CallToolResult): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
package/src/cli/index.ts CHANGED
@@ -1,13 +1,31 @@
1
1
  #!/usr/bin/env bun
2
2
  import { spawn } from 'node:child_process';
3
3
  import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
- import { dirname, resolve } from 'node:path';
4
+ import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { runAgentCli } from './agent.js';
7
7
  import { createCanvas } from '../server/index.js';
8
8
 
9
9
  const args = process.argv.slice(2);
10
10
 
11
+ // ── --version / -v ─────────────────────────────────────────────
12
+ // Print the installed package version and exit. Resolved from the
13
+ // sibling package.json so it stays accurate through bunx, global npm
14
+ // installs, and repo-local runs (no hard-coded string, no build step
15
+ // required).
16
+ if (args.includes('--version') || args.includes('-v')) {
17
+ try {
18
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
19
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string };
20
+ console.log(pkg.version ?? 'unknown');
21
+ process.exit(0);
22
+ } catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ console.error(`pmx-canvas: failed to read package.json (${message})`);
25
+ process.exit(1);
26
+ }
27
+ }
28
+
11
29
  // ── Agent CLI subcommands ────────────────────────────────────
12
30
  // If first arg is a known subcommand (not a --flag), route to the agent CLI.
13
31
  const AGENT_COMMANDS = new Set([
@@ -2,6 +2,7 @@ import type { CallToolResult, ListToolsResult, RequestId, Tool } from '@modelcon
2
2
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge';
4
4
  import { useEffect, useRef, useState } from 'preact/hooks';
5
+ import { extAppToolResultsMatch } from '../../shared/ext-app-tool-result.js';
5
6
  import {
6
7
  canvasTheme,
7
8
  collapseExpandedNode,
@@ -103,6 +104,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
103
104
  const latestToolInputRef = useRef<Record<string, unknown>>({});
104
105
  const latestToolResultRef = useRef<CallToolResult | undefined>(undefined);
105
106
  const toolResultSentRef = useRef(false);
107
+ const lastSentToolResultRef = useRef<CallToolResult | undefined>(undefined);
106
108
  const toolResultSendingRef = useRef<Promise<void> | null>(null);
107
109
  const bridgeReadyRef = useRef(false);
108
110
  const themeUnsubRef = useRef<(() => void) | null>(null);
@@ -140,13 +142,33 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
140
142
 
141
143
  const flushToolResult = (bridge: AppBridge | null): Promise<void> | null => {
142
144
  const pendingToolResult = latestToolResultRef.current;
143
- if (!bridge || !bridgeReadyRef.current || !pendingToolResult || toolResultSentRef.current) {
145
+ if (!bridge || !bridgeReadyRef.current || !pendingToolResult) {
146
+ return null;
147
+ }
148
+ // Skip when the content is unchanged. Updates from callServerTool
149
+ // (e.g. Excalidraw saving edits) produce a new reference via SSE and
150
+ // must be forwarded to keep other clients in sync — but SSE layout
151
+ // updates *also* mint new references when nothing in the tool result
152
+ // has actually changed (e.g. after the widget's own updateModelContext
153
+ // call), which would echo the result back and cause the widget to
154
+ // re-render mid-interaction (see: Counter fixture click instability).
155
+ // Deep-equality via structural compare handles both cases: new content
156
+ // is forwarded, unchanged content is suppressed.
157
+ if (lastSentToolResultRef.current === pendingToolResult) {
158
+ return null;
159
+ }
160
+ if (
161
+ lastSentToolResultRef.current &&
162
+ extAppToolResultsMatch(lastSentToolResultRef.current, pendingToolResult)
163
+ ) {
164
+ lastSentToolResultRef.current = pendingToolResult;
144
165
  return null;
145
166
  }
146
167
  if (toolResultSendingRef.current) return toolResultSendingRef.current;
147
168
  const sendPromise = bridge
148
169
  .sendToolResult(pendingToolResult)
149
170
  .then(() => {
171
+ lastSentToolResultRef.current = pendingToolResult;
150
172
  toolResultSentRef.current = true;
151
173
  setStatus('done');
152
174
  })
@@ -170,6 +192,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
170
192
  let disposed = false;
171
193
  let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
172
194
  toolResultSentRef.current = false;
195
+ lastSentToolResultRef.current = undefined;
173
196
  toolResultSendingRef.current = null;
174
197
  bridgeReadyRef.current = false;
175
198
 
@@ -323,10 +346,14 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
323
346
  // handshake timing differs across SDK versions.
324
347
  fallbackTimer = setTimeout(() => {
325
348
  if (disposed || bridgeReadyRef.current) return;
326
- void sendExtAppBootstrapState(bridge, latestToolInputRef.current, latestToolResultRef.current)
349
+ const bootstrapToolResult = latestToolResultRef.current;
350
+ void sendExtAppBootstrapState(bridge, latestToolInputRef.current, bootstrapToolResult)
327
351
  .then(() => {
328
- toolResultSentRef.current = Boolean(latestToolResultRef.current);
329
- setStatus(latestToolResultRef.current ? 'done' : 'ready');
352
+ toolResultSentRef.current = Boolean(bootstrapToolResult);
353
+ if (bootstrapToolResult) {
354
+ lastSentToolResultRef.current = bootstrapToolResult;
355
+ }
356
+ setStatus(bootstrapToolResult ? 'done' : 'ready');
330
357
  setError(null);
331
358
  })
332
359
  .catch((err) => {
@@ -344,7 +371,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
344
371
  bridgeRef.current = bridge;
345
372
  transportRef.current = transport;
346
373
 
347
- // Propagate theme changes to ext-app iframe
374
+ // Propagate theme changes to ext-app iframe. Read current expanded state
375
+ // at fire time so the widget keeps its fullscreen/inline context accurate.
348
376
  let firstFire = true;
349
377
  themeUnsubRef.current = canvasTheme.subscribe((newTheme) => {
350
378
  if (firstFire) { firstFire = false; return; }
@@ -353,7 +381,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
353
381
  theme: toMcpTheme(newTheme),
354
382
  platform: 'web',
355
383
  containerDimensions: { maxHeight },
356
- displayMode: 'inline',
384
+ displayMode: expandedNodeId.value === nodeId ? 'fullscreen' : 'inline',
357
385
  locale: navigator.language,
358
386
  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
359
387
  });
@@ -392,6 +420,24 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
392
420
  }
393
421
  }, [toolResult, status]);
394
422
 
423
+ // Keep the widget's displayMode in sync when the host expands or collapses
424
+ // the node. Without this, a widget that opened in inline mode would never
425
+ // learn that it is now fullscreen (and vice versa), so features gated on
426
+ // fullscreen (like Excalidraw's edit mode) would not activate on the same
427
+ // click that triggered the expansion.
428
+ useEffect(() => {
429
+ const bridge = bridgeRef.current;
430
+ if (!bridge || !bridgeReadyRef.current) return;
431
+ bridge.setHostContext?.({
432
+ theme: toMcpTheme(canvasTheme.value),
433
+ platform: 'web',
434
+ containerDimensions: { maxHeight },
435
+ displayMode: isExpanded ? 'fullscreen' : 'inline',
436
+ locale: navigator.language,
437
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
438
+ });
439
+ }, [isExpanded, maxHeight]);
440
+
395
441
  // Loading state — HTML not yet fetched
396
442
  if (!html) {
397
443
  return (
@@ -493,17 +539,45 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
493
539
  Connecting to ext-app viewer...
494
540
  </div>
495
541
  )}
496
- {/* allow-scripts only (no allow-same-origin) srcdoc gets opaque origin,
497
- cannot access host cookies/storage/DOM. Communication via postMessage only. */}
498
- <iframe
499
- key={frameKey}
500
- ref={iframeRef}
501
- srcdoc={html}
502
- sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
503
- allow={buildAllowAttribute(resourceMeta?.permissions)}
504
- style={{ flex: 1, border: 'none', background: 'var(--c-panel)' }}
505
- title={`Ext App: ${toolName}`}
506
- />
542
+ {/* Iframe stack: the widget renders a preview; when not expanded, a
543
+ transparent click-catcher sits on top so the first click always
544
+ expands the node. Without this, widgets like Excalidraw show their
545
+ own "Edit" button inline, which triggers a fullscreen request and
546
+ remounts the iframe in the overlay — forcing the user to click Edit
547
+ a second time to actually enter edit mode. Routing all inline clicks
548
+ to "expand" makes the flow "open → edit" instead of "edit → expand → edit". */}
549
+ <div style={{ flex: 1, position: 'relative', display: 'flex', minHeight: 0 }}>
550
+ <iframe
551
+ key={frameKey}
552
+ ref={iframeRef}
553
+ srcdoc={html}
554
+ sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
555
+ allow={buildAllowAttribute(resourceMeta?.permissions)}
556
+ style={{ flex: 1, border: 'none', background: 'var(--c-panel)' }}
557
+ title={`Ext App: ${toolName}`}
558
+ />
559
+ {!isExpanded && (
560
+ <button
561
+ type="button"
562
+ onClick={(e) => {
563
+ e.stopPropagation();
564
+ expandNode(nodeId);
565
+ }}
566
+ class="ext-app-preview-catcher"
567
+ title="Click to open"
568
+ style={{
569
+ position: 'absolute',
570
+ inset: 0,
571
+ background: 'transparent',
572
+ border: 'none',
573
+ padding: 0,
574
+ margin: 0,
575
+ cursor: 'zoom-in',
576
+ }}
577
+ aria-label="Open full view to edit"
578
+ />
579
+ )}
580
+ </div>
507
581
  </div>
508
582
  );
509
583
  }
package/src/mcp/server.ts CHANGED
@@ -37,6 +37,7 @@ import { searchNodes, buildSpatialContext, findNeighborhoods } from '../server/s
37
37
  import { mutationHistory, diffLayouts, formatDiff } from '../server/mutation-history.js';
38
38
  import { buildCodeGraphSummary, formatCodeGraph } from '../server/code-graph.js';
39
39
  import { buildCanvasSummary, serializeCanvasLayout, serializeCanvasNode } from '../server/canvas-serialization.js';
40
+ import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
40
41
 
41
42
  let canvas: PmxCanvas | null = null;
42
43
 
@@ -339,7 +340,7 @@ export async function startMcpServer(): Promise<void> {
339
340
  // ── canvas_build_web_artifact ───────────────────────────────
340
341
  server.tool(
341
342
  'canvas_build_web_artifact',
342
- 'Build a bundled single-file HTML web artifact from React/Tailwind source files using the bundled web-artifacts-builder skill scripts. Optionally opens the generated artifact as an embedded node on the canvas.',
343
+ 'Build a bundled single-file HTML web artifact from React/Tailwind source files using the bundled web-artifacts-builder skill scripts. Optionally opens the generated artifact as an embedded node on the canvas. Read canvas://skills/web-artifacts-builder for the full workflow, stack, and anti-slop design guidelines before calling.',
343
344
  {
344
345
  title: z.string().describe('Artifact title used for default project and output paths'),
345
346
  appTsx: z.string().describe('Contents for src/App.tsx'),
@@ -1158,6 +1159,68 @@ export async function startMcpServer(): Promise<void> {
1158
1159
  },
1159
1160
  );
1160
1161
 
1162
+ // ── canvas://skills ────────────────────────────────────────
1163
+ // Discoverability for the skill prompts bundled with the npm package
1164
+ // (skills/<name>/SKILL.md). Before 0.1.2 these files shipped but were
1165
+ // invisible to agents — calling canvas_build_web_artifact without the
1166
+ // companion `web-artifacts-builder` skill led to predictable misuse.
1167
+ // The index lists every bundled skill with its frontmatter description;
1168
+ // individual skills are served verbatim at canvas://skills/<name>.
1169
+ server.resource(
1170
+ 'bundled-skills',
1171
+ 'canvas://skills',
1172
+ {
1173
+ description:
1174
+ 'Index of agent skills bundled with this PMX Canvas install. Lists name, ' +
1175
+ 'description, and per-skill URI (canvas://skills/<name>). Read a specific ' +
1176
+ 'skill for workflow guidance — notably web-artifacts-builder for ' +
1177
+ 'canvas_build_web_artifact, and pmx-canvas for the broader workbench.',
1178
+ mimeType: 'application/json',
1179
+ },
1180
+ async () => {
1181
+ const skills = listBundledSkills();
1182
+ const index = {
1183
+ count: skills.length,
1184
+ skills: skills.map((s) => ({ name: s.name, description: s.description, uri: s.uri })),
1185
+ };
1186
+ return {
1187
+ contents: [
1188
+ {
1189
+ uri: 'canvas://skills',
1190
+ mimeType: 'application/json',
1191
+ text: JSON.stringify(index, null, 2),
1192
+ },
1193
+ ],
1194
+ };
1195
+ },
1196
+ );
1197
+
1198
+ // Register each bundled skill as its own resource so agents can address
1199
+ // them individually (canvas://skills/web-artifacts-builder, etc.) and
1200
+ // MCP clients can display them with per-skill descriptions.
1201
+ for (const skill of listBundledSkills()) {
1202
+ server.resource(
1203
+ `skill-${skill.name}`,
1204
+ skill.uri,
1205
+ {
1206
+ description: skill.description || `Bundled PMX Canvas skill: ${skill.name}`,
1207
+ mimeType: 'text/markdown',
1208
+ },
1209
+ async () => {
1210
+ const markdown = readBundledSkill(skill.name);
1211
+ return {
1212
+ contents: [
1213
+ {
1214
+ uri: skill.uri,
1215
+ mimeType: 'text/markdown',
1216
+ text: markdown ?? `# ${skill.name}\n\n_Skill file not found on disk._\n`,
1217
+ },
1218
+ ],
1219
+ };
1220
+ },
1221
+ );
1222
+ }
1223
+
1161
1224
  // ── canvas_create_group ──────────────────────────────────────
1162
1225
  server.tool(
1163
1226
  'canvas_create_group',
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Bundled-skill discovery for the PMX Canvas MCP server.
3
+ *
4
+ * Skill files ship inside the npm package under `skills/<name>/SKILL.md`
5
+ * but until 0.1.2 they were not discoverable to the agent — an agent
6
+ * calling `canvas_build_web_artifact` had no way to find the companion
7
+ * `skills/web-artifacts-builder/SKILL.md` prompt that documents the
8
+ * workflow, stack choices, and gotchas.
9
+ *
10
+ * This module locates the bundled `skills/` directory relative to the
11
+ * package root (works for both repo-local development and global npm
12
+ * installs), parses the YAML frontmatter of each `SKILL.md` to produce
13
+ * a compact index, and reads individual skill content on demand.
14
+ *
15
+ * Exposed via MCP as:
16
+ * - `canvas://skills` → JSON index
17
+ * - `canvas://skills/<name>` → full markdown content
18
+ */
19
+
20
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
21
+ import { dirname, join, resolve } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ export interface BundledSkill {
25
+ name: string;
26
+ description: string;
27
+ uri: string;
28
+ filePath: string;
29
+ }
30
+
31
+ const MAX_DESCRIPTION_LENGTH = 400;
32
+
33
+ /**
34
+ * Resolve the packaged `skills/` directory. Walks parents from this module
35
+ * looking for a sibling `skills/` that contains at least one `<name>/SKILL.md`,
36
+ * so it works whether the code runs from source (`src/server/…`), from a
37
+ * compiled bundle (`dist/…`), or from a global npm install
38
+ * (`/opt/homebrew/lib/node_modules/pmx-canvas/src/server/…`).
39
+ */
40
+ export function findBundledSkillsRoot(): string | null {
41
+ let current = dirname(fileURLToPath(import.meta.url));
42
+ const seen = new Set<string>();
43
+ while (!seen.has(current)) {
44
+ seen.add(current);
45
+ const candidate = join(current, 'skills');
46
+ if (existsSync(candidate)) {
47
+ try {
48
+ if (statSync(candidate).isDirectory()) {
49
+ const entries = readdirSync(candidate);
50
+ for (const entry of entries) {
51
+ if (existsSync(join(candidate, entry, 'SKILL.md'))) {
52
+ return resolve(candidate);
53
+ }
54
+ }
55
+ }
56
+ } catch {
57
+ // swallow and keep walking up — a permissions/transient error on this
58
+ // candidate shouldn't prevent finding a valid skills root higher up.
59
+ }
60
+ }
61
+ const parent = dirname(current);
62
+ if (parent === current) break;
63
+ current = parent;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function parseFrontmatterDescription(markdown: string): string {
69
+ // YAML frontmatter lives between two `---` fences at the very top.
70
+ if (!markdown.startsWith('---')) return '';
71
+ const end = markdown.indexOf('\n---', 3);
72
+ if (end === -1) return '';
73
+ const frontmatter = markdown.slice(3, end);
74
+ const lines = frontmatter.split('\n');
75
+
76
+ // Support both single-line `description: ...` and block-scalar `description: >` /
77
+ // `description: |` forms (indented continuation lines).
78
+ for (let i = 0; i < lines.length; i++) {
79
+ const line = lines[i] ?? '';
80
+ const match = /^description:\s*(.*)$/.exec(line);
81
+ if (!match) continue;
82
+ const first = (match[1] ?? '').trim();
83
+ if (first && first !== '>' && first !== '|' && first !== '>-' && first !== '|-') {
84
+ return first.slice(0, MAX_DESCRIPTION_LENGTH);
85
+ }
86
+ // Block scalar — concatenate indented follow-on lines.
87
+ const parts: string[] = [];
88
+ for (let j = i + 1; j < lines.length; j++) {
89
+ const follow = lines[j] ?? '';
90
+ if (follow.length === 0) {
91
+ parts.push('');
92
+ continue;
93
+ }
94
+ if (!/^\s/.test(follow)) break;
95
+ parts.push(follow.trim());
96
+ }
97
+ return parts.join(' ').replace(/\s+/g, ' ').trim().slice(0, MAX_DESCRIPTION_LENGTH);
98
+ }
99
+ return '';
100
+ }
101
+
102
+ /**
103
+ * Enumerate every `<name>/SKILL.md` under the bundled skills root and return
104
+ * a compact index. Hidden directories (dotfolders) and files that don't parse
105
+ * are skipped silently rather than throwing — missing metadata should never
106
+ * break the MCP server's resource listing.
107
+ */
108
+ export function listBundledSkills(): BundledSkill[] {
109
+ const root = findBundledSkillsRoot();
110
+ if (!root) return [];
111
+ const entries = readdirSync(root);
112
+ const skills: BundledSkill[] = [];
113
+ for (const entry of entries) {
114
+ if (entry.startsWith('.')) continue;
115
+ const skillFile = join(root, entry, 'SKILL.md');
116
+ if (!existsSync(skillFile)) continue;
117
+ try {
118
+ const markdown = readFileSync(skillFile, 'utf-8');
119
+ const description = parseFrontmatterDescription(markdown);
120
+ skills.push({
121
+ name: entry,
122
+ description,
123
+ uri: `canvas://skills/${entry}`,
124
+ filePath: skillFile,
125
+ });
126
+ } catch {
127
+ // skip unreadable files
128
+ }
129
+ }
130
+ skills.sort((a, b) => a.name.localeCompare(b.name));
131
+ return skills;
132
+ }
133
+
134
+ export function readBundledSkill(name: string): string | null {
135
+ const skills = listBundledSkills();
136
+ const match = skills.find((s) => s.name === name);
137
+ if (!match) return null;
138
+ try {
139
+ return readFileSync(match.filePath, 'utf-8');
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
@@ -392,17 +392,36 @@ function buildImageNodeData(input: CanvasAddNodeInput): Record<string, unknown>
392
392
  const src = input.content ?? '';
393
393
  const isDataUri = src.startsWith('data:');
394
394
  const isUrl = src.startsWith('http://') || src.startsWith('https://');
395
+
396
+ if (isDataUri) {
397
+ // Basic data-URI sanity: must be an image/* mediatype.
398
+ const header = src.slice(5, src.indexOf(',') >= 0 ? src.indexOf(',') : src.length);
399
+ if (!/^image\//i.test(header)) {
400
+ throw new Error(
401
+ `Invalid image node: data URI must be an image/* media type (got "${header.slice(0, 40)}"). ` +
402
+ `Accepted: png, jpeg, gif, svg+xml, webp, bmp, avif, x-icon.`,
403
+ );
404
+ }
405
+ }
406
+
395
407
  if (!isDataUri && !isUrl && src) {
396
408
  const resolved = resolve(src);
409
+ const ext = resolved.split('.').pop()?.toLowerCase() ?? '';
397
410
  const fileName = resolved.split('/').pop() ?? src;
411
+ const mime = IMAGE_MIME_MAP[ext];
412
+ if (!mime) {
413
+ throw new Error(
414
+ `Invalid image node: "${fileName}" has unsupported extension ".${ext}". ` +
415
+ `Accepted: ${Object.keys(IMAGE_MIME_MAP).join(', ')}. ` +
416
+ `For non-image files use type="file" (live viewer) or type="webpage" (URL) instead.`,
417
+ );
418
+ }
398
419
  return {
399
420
  ...(input.data ?? {}),
400
421
  src: resolved,
401
422
  title: input.title ?? fileName,
402
423
  path: resolved,
403
- ...(IMAGE_MIME_MAP[resolved.split('.').pop()?.toLowerCase() ?? '']
404
- ? { mimeType: IMAGE_MIME_MAP[resolved.split('.').pop()?.toLowerCase() ?? ''] }
405
- : {}),
424
+ mimeType: mime,
406
425
  };
407
426
  }
408
427
 
@@ -1105,19 +1105,26 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1105
1105
  const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1106
1106
  ? body.data as Record<string, unknown>
1107
1107
  : undefined;
1108
- const { id, node, needsCodeGraphRecompute } = addCanvasNode({
1109
- type: type as CanvasNodeState['type'],
1110
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
1111
- ...(typeof body.content === 'string' ? { content: body.content } : {}),
1112
- ...(extraData ? { data: extraData } : {}),
1113
- ...(typeof body.x === 'number' ? { x: body.x } : {}),
1114
- ...(typeof body.y === 'number' ? { y: body.y } : {}),
1115
- ...(typeof body.width === 'number' ? { width: body.width } : {}),
1116
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
1117
- defaultWidth: 360,
1118
- defaultHeight: 200,
1119
- fileMode: 'auto',
1120
- });
1108
+ let added: ReturnType<typeof addCanvasNode>;
1109
+ try {
1110
+ added = addCanvasNode({
1111
+ type: type as CanvasNodeState['type'],
1112
+ ...(typeof body.title === 'string' ? { title: body.title } : {}),
1113
+ ...(typeof body.content === 'string' ? { content: body.content } : {}),
1114
+ ...(extraData ? { data: extraData } : {}),
1115
+ ...(typeof body.x === 'number' ? { x: body.x } : {}),
1116
+ ...(typeof body.y === 'number' ? { y: body.y } : {}),
1117
+ ...(typeof body.width === 'number' ? { width: body.width } : {}),
1118
+ ...(typeof body.height === 'number' ? { height: body.height } : {}),
1119
+ defaultWidth: 360,
1120
+ defaultHeight: 200,
1121
+ fileMode: 'auto',
1122
+ });
1123
+ } catch (error) {
1124
+ const message = error instanceof Error ? error.message : String(error);
1125
+ return responseJson({ ok: false, error: message }, 400);
1126
+ }
1127
+ const { node, needsCodeGraphRecompute } = added;
1121
1128
 
1122
1129
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1123
1130
  if (needsCodeGraphRecompute) {