pmx-canvas 0.6.1 → 0.6.3

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/Readme.md +14 -2
  3. package/dist/canvas/global.css +22 -3
  4. package/dist/canvas/index.js +64 -64
  5. package/dist/json-render/index.js +57 -57
  6. package/dist/types/client/canvas/auto-fit.d.ts +3 -0
  7. package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
  8. package/dist/types/client/nodes/use-iframe-content-height.d.ts +3 -3
  9. package/dist/types/client/state/human-store.d.ts +1 -0
  10. package/dist/types/json-render/catalog.d.ts +20 -20
  11. package/dist/types/json-render/schema.d.ts +40 -0
  12. package/dist/types/mcp/skills.d.ts +13 -0
  13. package/dist/types/server/bundled-skills.d.ts +32 -4
  14. package/docs/http-api.md +5 -1
  15. package/docs/mcp.md +35 -1
  16. package/docs/node-types.md +1 -1
  17. package/docs/product-context-vision-2026-09.md +16 -5
  18. package/docs/product-vision-2026-09.md +4 -2
  19. package/package.json +11 -11
  20. package/skills/json-render-mcp/SKILL.md +118 -2
  21. package/skills/pmx-canvas/SKILL.md +42 -16
  22. package/skills/pmx-canvas/evals/evals.json +1 -1
  23. package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +5 -0
  24. package/skills/pmx-canvas/references/full-reference.md +5 -4
  25. package/skills/pmx-canvas/references/installing-pmx-canvas.md +5 -5
  26. package/skills/pmx-canvas-testing/SKILL.md +13 -4
  27. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +11 -12
  28. package/src/client/canvas/CanvasNode.tsx +9 -12
  29. package/src/client/canvas/CanvasViewport.tsx +10 -34
  30. package/src/client/canvas/ContextMenu.tsx +13 -0
  31. package/src/client/canvas/EdgeLayer.tsx +2 -2
  32. package/src/client/canvas/SessionPanel.tsx +23 -4
  33. package/src/client/canvas/ShortcutOverlay.tsx +2 -2
  34. package/src/client/canvas/auto-fit.ts +20 -1
  35. package/src/client/canvas/use-pan-zoom.ts +16 -3
  36. package/src/client/nodes/ExtAppFrame.tsx +7 -1
  37. package/src/client/nodes/use-iframe-content-height.ts +44 -24
  38. package/src/client/state/human-store.ts +6 -6
  39. package/src/client/state/sse-bridge.ts +3 -1
  40. package/src/client/theme/global.css +22 -3
  41. package/src/json-render/catalog.ts +6 -0
  42. package/src/json-render/renderer/index.tsx +17 -0
  43. package/src/json-render/schema.ts +4 -0
  44. package/src/mcp/server.ts +9 -4
  45. package/src/mcp/skills.ts +47 -0
  46. package/src/server/bundled-skills.ts +92 -53
  47. package/src/server/canvas-operations.ts +2 -2
  48. package/src/server/operations/ops/nodes.ts +43 -11
  49. package/src/server/operations/ops/viewport.ts +6 -1
  50. package/src/server/server.ts +9 -9
  51. package/src/server/web-artifacts/scripts/bundle-artifact.sh +29 -8
  52. package/src/shared/content-height-reporter.ts +4 -2
@@ -1467,7 +1467,13 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1467
1467
  minHeight: 0,
1468
1468
  border: 'none',
1469
1469
  background: 'var(--c-panel)',
1470
- pointerEvents: isExpanded && status !== 'loading' ? 'auto' : 'none',
1470
+ // Keep the expanded frame in Chromium's pointer hit-test tree from
1471
+ // its first paint. Toggling none → auto when boot completes can
1472
+ // leave the compositor routing the first click to the parent even
1473
+ // though DOM hit testing and Playwright both resolve the button.
1474
+ // The loading sibling is non-interactive; inline frames remain
1475
+ // guarded by the preview catcher below.
1476
+ pointerEvents: isExpanded ? 'auto' : 'none',
1471
1477
  }}
1472
1478
  title={`Ext App: ${toolName}`}
1473
1479
  />
@@ -1,6 +1,9 @@
1
- import { useEffect, useRef } from 'preact/hooks';
2
- import { persistLayout, resizeNode } from '../state/canvas-store';
3
- import { computeContentGrowHeight } from '../canvas/auto-fit';
1
+ import { effect } from '@preact/signals';
2
+ import { useEffect } from 'preact/hooks';
3
+ import { nodes, updateNode } from '../state/canvas-store';
4
+ import { grabbingNodeId, otherHumans } from '../state/human-store';
5
+ import { pushCanvasUpdate } from '../state/intent-bridge';
6
+ import { computeContentGrowHeight, contentFitPosition } from '../canvas/auto-fit';
4
7
  import type { CanvasNodeState } from '../types';
5
8
 
6
9
  /**
@@ -11,43 +14,60 @@ import type { CanvasNodeState } from '../types';
11
14
  * because growth is monotonic with a dead-band — cannot oscillate. This is the
12
15
  * fix for iframe nodes whose body scrollHeight the parent can't measure.
13
16
  *
14
- * The latest node is read through a ref so the effect stays mounted across the
15
- * grow (its deps are only id + token). Putting node.size in the deps would re-run
16
- * the effect on each grow and its cleanup would cancel the pending persist.
17
+ * Debounce before changing geometry, retaining the latest measurement during a
18
+ * human grab. Read current geometry at application time; persist only this node.
19
+ * Relocations are undoable, height-only adjustments do not fill the undo stack.
17
20
  */
18
21
  export function useIframeContentHeight(
19
22
  node: CanvasNodeState,
20
23
  iframeRef: { current: HTMLIFrameElement | null },
21
24
  frameToken: string,
22
25
  ): void {
23
- const nodeRef = useRef(node);
24
- nodeRef.current = node;
25
- const persistTimer = useRef<number | null>(null);
26
-
27
26
  useEffect(() => {
28
27
  if (!frameToken) return undefined;
28
+ let timer: ReturnType<typeof setTimeout> | null = null;
29
+ let pendingHeight: number | null = null;
30
+ const held = () =>
31
+ grabbingNodeId.value === node.id || otherHumans.value.some((human) => human.grabbingNodeId === node.id);
32
+ function apply() {
33
+ timer = null;
34
+ if (held() || pendingHeight === null) return;
35
+ const reported = pendingHeight;
36
+ pendingHeight = null;
37
+ const current = nodes.value.get(node.id);
38
+ if (!current) return;
39
+ const target = computeContentGrowHeight(current, reported);
40
+ if (target === null) return;
41
+ const position = contentFitPosition(current, target, Array.from(nodes.value.values()));
42
+ const moved = position.x !== current.position.x || position.y !== current.position.y;
43
+ const patch = {
44
+ size: { width: current.size.width, height: target },
45
+ ...(moved ? { position } : {}),
46
+ };
47
+ updateNode(current.id, patch);
48
+ void pushCanvasUpdate([{ id: current.id, ...patch }], { recordHistory: moved });
49
+ }
50
+ function schedule() {
51
+ if (timer !== null) clearTimeout(timer);
52
+ timer = pendingHeight !== null && !held() ? setTimeout(apply, 300) : null;
53
+ }
54
+ const dispose = effect(() => {
55
+ held();
56
+ schedule();
57
+ });
29
58
  function onMessage(event: MessageEvent) {
30
59
  if (event.source !== iframeRef.current?.contentWindow) return;
31
60
  const d = event.data as { source?: string; type?: string; token?: string; height?: unknown } | null;
32
61
  if (!d || d.source !== 'pmx-canvas-frame' || d.type !== 'content-height' || d.token !== frameToken) return;
33
- const current = nodeRef.current;
34
- const reported = typeof d.height === 'number' ? d.height : 0;
35
- const target = computeContentGrowHeight(current, reported);
36
- if (target === null) return;
37
- resizeNode(current.id, { width: current.size.width, height: target });
38
- if (persistTimer.current !== null) window.clearTimeout(persistTimer.current);
39
- persistTimer.current = window.setTimeout(() => {
40
- persistLayout({ recordHistory: false });
41
- persistTimer.current = null;
42
- }, 300);
62
+ if (typeof d.height !== 'number' || !Number.isFinite(d.height) || d.height <= 0) return;
63
+ pendingHeight = d.height;
64
+ schedule();
43
65
  }
44
66
  window.addEventListener('message', onMessage);
45
67
  return () => {
46
68
  window.removeEventListener('message', onMessage);
47
- if (persistTimer.current !== null) {
48
- window.clearTimeout(persistTimer.current);
49
- persistTimer.current = null;
50
- }
69
+ dispose();
70
+ if (timer !== null) clearTimeout(timer);
51
71
  };
52
72
  }, [node.id, frameToken]);
53
73
  }
@@ -58,7 +58,7 @@ export function applyHumanSnapshot(snapshot: Partial<HumanPresenceSnapshot> | nu
58
58
  humans.value = snapshot.humans;
59
59
  }
60
60
 
61
- let grabbing: string | null = null;
61
+ export const grabbingNodeId = signal<string | null>(null);
62
62
  let lastCursor: { x: number; y: number } | null = null;
63
63
  let lastSentAt = 0;
64
64
  let pending: ReturnType<typeof setTimeout> | null = null;
@@ -74,7 +74,7 @@ function post(body: Record<string, unknown>): void {
74
74
  function flushCursor(): void {
75
75
  pending = null;
76
76
  lastSentAt = Date.now();
77
- post({ cursor: lastCursor, grabbingNodeId: grabbing });
77
+ post({ cursor: lastCursor, grabbingNodeId: grabbingNodeId.value });
78
78
  }
79
79
 
80
80
  /** Report the pointer in world coordinates (throttled); null when it leaves the canvas. */
@@ -91,7 +91,7 @@ export function reportHumanCursor(cursor: { x: number; y: number } | null): void
91
91
 
92
92
  /** Hold / release a node (drag, rename): the edit lock agents must respect. */
93
93
  export function reportHumanGrab(nodeId: string | null): void {
94
- grabbing = nodeId;
94
+ grabbingNodeId.value = nodeId;
95
95
  post({ cursor: lastCursor, grabbingNodeId: nodeId });
96
96
  }
97
97
 
@@ -147,8 +147,8 @@ let heartbeat: ReturnType<typeof setInterval> | null = null;
147
147
 
148
148
  /** Announce this tab and keep it alive; renews a held grab. Call once per connection. */
149
149
  export function startHumanPresence(): () => void {
150
- post({ cursor: lastCursor, grabbingNodeId: grabbing });
151
- heartbeat = setInterval(() => post({ grabbingNodeId: grabbing }), 3000);
150
+ post({ cursor: lastCursor, grabbingNodeId: grabbingNodeId.value });
151
+ heartbeat = setInterval(() => post({ grabbingNodeId: grabbingNodeId.value }), 3000);
152
152
  const leave = () => {
153
153
  try {
154
154
  navigator.sendBeacon?.(
@@ -170,6 +170,6 @@ export function startHumanPresence(): () => void {
170
170
  export function resetHumanPresence(): void {
171
171
  humans.value = [];
172
172
  yieldedNodes.value = new Map();
173
- grabbing = null;
173
+ grabbingNodeId.value = null;
174
174
  lastCursor = null;
175
175
  }
@@ -922,7 +922,9 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
922
922
  }
923
923
  : undefined;
924
924
 
925
- cancelViewportAnimation();
925
+ // Routine node/layout broadcasts do not replace the camera, so must not
926
+ // interrupt a focus animation either (view.focus also emits a layout).
927
+ if (shouldApplyViewport) cancelViewportAnimation();
926
928
  applyServerCanvasLayout(
927
929
  {
928
930
  ...(nextViewport ? { viewport: nextViewport } : {}),
@@ -1377,7 +1377,7 @@ body,
1377
1377
  animation: session-panel-in 180ms ease;
1378
1378
  }
1379
1379
  .session-panel.is-collapsed {
1380
- width: 28px;
1380
+ width: 40px;
1381
1381
  align-items: center;
1382
1382
  padding-top: 10px;
1383
1383
  gap: 10px;
@@ -1390,6 +1390,17 @@ body,
1390
1390
  text-transform: uppercase;
1391
1391
  color: var(--c-dim);
1392
1392
  }
1393
+ .session-collapsed-live {
1394
+ display: inline-flex;
1395
+ flex-direction: column;
1396
+ align-items: center;
1397
+ gap: 4px;
1398
+ font: 9px var(--mono);
1399
+ color: var(--c-dim);
1400
+ }
1401
+ .session-collapsed-live .session-live-dot {
1402
+ background: var(--c-ok);
1403
+ }
1393
1404
 
1394
1405
  .session-panel-header {
1395
1406
  height: var(--hud-bar-height);
@@ -1717,7 +1728,7 @@ body,
1717
1728
  }
1718
1729
  .session-timeline-filters .activity-filter {
1719
1730
  flex-shrink: 0;
1720
- padding: 2px 4px;
1731
+ padding: 2px 3px;
1721
1732
  }
1722
1733
 
1723
1734
  .session-timeline-row {
@@ -3745,6 +3756,12 @@ body,
3745
3756
  }
3746
3757
 
3747
3758
  /* Node resize handle */
3759
+ .canvas-node:has(.node-resize-handle) {
3760
+ /* Keep the four-pixel corner grab inside the shell's inner clip, not on
3761
+ its rounded border. The other corners retain the standard card radius. */
3762
+ border-bottom-right-radius: 8px;
3763
+ }
3764
+
3748
3765
  .canvas-node .node-resize-handle {
3749
3766
  position: absolute;
3750
3767
  bottom: 0;
@@ -3789,7 +3806,9 @@ html.is-node-dragging .attention-field-layer {
3789
3806
  }
3790
3807
 
3791
3808
  html.is-node-dragging,
3792
- html.is-node-dragging * {
3809
+ html.is-node-dragging *,
3810
+ html.is-canvas-panning,
3811
+ html.is-canvas-panning * {
3793
3812
  cursor: grabbing !important;
3794
3813
  user-select: none !important;
3795
3814
  -webkit-user-select: none !important;
@@ -20,6 +20,12 @@ const buttonDefinition = shadcnComponentDefinitions.Button;
20
20
 
21
21
  export const allComponentDefinitions = {
22
22
  ...shadcnComponentDefinitions,
23
+ Card: {
24
+ ...shadcnComponentDefinitions.Card,
25
+ slots: ['default', 'header', 'footer'],
26
+ description:
27
+ 'Card container. Use children for its body and optional header/footer slots for custom headings or actions. A header slot replaces the title and description props.',
28
+ },
23
29
  Badge: {
24
30
  ...badgeDefinition,
25
31
  props: badgeDefinition.props.extend({
@@ -69,9 +69,26 @@ function Button({ props, emit }: { props: ButtonProps; emit: (event: string) =>
69
69
  );
70
70
  }
71
71
 
72
+ function Card({ props, children, slots, ...rest }: Parameters<typeof shadcnComponents.Card>[0]) {
73
+ const header = slots?.header;
74
+ const footer = slots?.footer;
75
+ return (
76
+ <shadcnComponents.Card {...rest} props={header ? { ...props, title: null, description: null } : props}>
77
+ {header && <header data-pmx-card-slot="header">{header}</header>}
78
+ {children}
79
+ {footer && (
80
+ <footer data-pmx-card-slot="footer" className="flex flex-wrap items-center gap-3">
81
+ {footer}
82
+ </footer>
83
+ )}
84
+ </shadcnComponents.Card>
85
+ );
86
+ }
87
+
72
88
  const { registry } = defineRegistry(catalog as never, {
73
89
  components: {
74
90
  ...shadcnComponents,
91
+ Card,
75
92
  Badge,
76
93
  Button,
77
94
  ...chartComponents,
@@ -4,12 +4,16 @@ export const schema = defineSchema(
4
4
  (s) => ({
5
5
  spec: s.object({
6
6
  root: s.string(),
7
+ state: { ...s.record(s.any()), ...s.optional() },
7
8
  elements: s.record(
8
9
  s.object({
9
10
  type: s.ref('catalog.components'),
10
11
  props: s.propsOf('catalog.components'),
11
12
  children: s.array(s.string()),
12
13
  visible: s.any(),
14
+ repeat: { ...s.any(), ...s.optional() },
15
+ watch: { ...s.record(s.any()), ...s.optional() },
16
+ slots: { ...s.record(s.array(s.string())), ...s.optional() },
13
17
  // Event→action bindings (on.press, on.change, …). Preserved through
14
18
  // validation so spec authors can wire actions — including the ax.*
15
19
  // handlers the viewer forwards to the canvas AX bridge.
package/src/mcp/server.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  summarizeCanvasAnnotationForContext,
42
42
  } from '../server/canvas-serialization.js';
43
43
  import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
44
+ import { registerSkillExtension } from './skills.js';
44
45
 
45
46
  let canvas: CanvasAccess | null = null;
46
47
  let resourceNotificationServer: McpServer | null = null;
@@ -212,11 +213,15 @@ function buildSummaryFromLayout(
212
213
  }
213
214
 
214
215
  export async function startMcpServer(): Promise<void> {
215
- const server = new McpServer({
216
- name: 'pmx-canvas',
217
- version: readPackageVersion(),
218
- });
216
+ const server = new McpServer(
217
+ { name: 'pmx-canvas', version: readPackageVersion() },
218
+ {
219
+ instructions:
220
+ 'Bundled workbench guidance: skill://pmx-canvas/SKILL.md. Discover skills via skills/list when supported, or canvas://skills; read files through resources/read. Skill delivery does not grant permission to execute bundled scripts.',
221
+ },
222
+ );
219
223
  resourceNotificationServer = server;
224
+ registerSkillExtension(server);
220
225
 
221
226
  // ── Operation-registry tools (plan-005) ────────────────────────
222
227
  // Standalone tools are registered from the shared operation registry for
@@ -0,0 +1,47 @@
1
+ import { type McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
3
+ import { z } from 'zod';
4
+ import { createBundledSkillCatalog } from '../server/bundled-skills.js';
5
+
6
+ /** SEP-2640 methods are protocol requests, not model-facing canvas tools. */
7
+ export function registerSkillExtension(server: McpServer, catalog = createBundledSkillCatalog()): void {
8
+ // The v1 SDK accepts custom methods and transmits capabilities unchanged,
9
+ // but its ServerCapabilities type predates the extensions field.
10
+ const capabilities = { resources: {}, extensions: { 'io.modelcontextprotocol/skills': {} } };
11
+ server.server.registerCapabilities(capabilities);
12
+ server.server.setRequestHandler(
13
+ z.object({ method: z.literal('skills/list'), params: z.object({ cursor: z.string().optional() }).optional() }),
14
+ async ({ params }) => {
15
+ // The bounded package catalog is a single page, so no cursor is issued.
16
+ if (params?.cursor !== undefined) throw new McpError(ErrorCode.InvalidParams, 'Unknown skills cursor');
17
+ return { resultType: 'complete', skills: catalog.skills };
18
+ },
19
+ );
20
+ server.server.setRequestHandler(
21
+ z.object({ method: z.literal('skills/get'), params: z.object({ uri: z.string() }) }),
22
+ async ({ params }) => {
23
+ const skill = catalog.skills.find((entry) => entry.uri === params.uri);
24
+ if (!skill) throw new McpError(ErrorCode.InvalidParams, `No skill is served at ${params.uri}`);
25
+ return { resultType: 'complete', skill };
26
+ },
27
+ );
28
+ server.registerResource(
29
+ 'skill-files',
30
+ new ResourceTemplate('skill://{name}/{+path}', { list: undefined }),
31
+ { description: 'Packaged skill files. Discover complete manifests through skills/list or skills/get.' },
32
+ async (uri) => {
33
+ // Exact catalog lookup: a URI never becomes a filesystem path.
34
+ const content = catalog.files.get(uri.href);
35
+ if (!content) throw new McpError(ErrorCode.InvalidParams, `No skill file is served at ${uri.href}`);
36
+ return { contents: [content] };
37
+ },
38
+ );
39
+ for (const skill of catalog.skills) {
40
+ server.registerResource(
41
+ skill.frontmatter.name,
42
+ skill.uri,
43
+ { mimeType: 'text/markdown', description: skill.frontmatter.description },
44
+ async () => ({ contents: [catalog.files.get(skill.uri)!] }),
45
+ );
46
+ }
47
+ }
@@ -17,15 +17,29 @@
17
17
  * - `canvas://skills/<name>` → full markdown content
18
18
  */
19
19
 
20
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
20
+ import { createHash } from 'node:crypto';
21
+ import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
21
22
  import { dirname, join, resolve } from 'node:path';
22
23
  import { fileURLToPath } from 'node:url';
24
+ import { z } from 'zod';
25
+
26
+ const skillFrontmatterSchema = z
27
+ .object({
28
+ name: z
29
+ .string()
30
+ .min(1)
31
+ .max(64)
32
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
33
+ description: z.string().min(1).max(1024),
34
+ })
35
+ .passthrough();
23
36
 
24
37
  export interface BundledSkill {
25
38
  name: string;
26
39
  description: string;
27
40
  uri: string;
28
41
  filePath: string;
42
+ frontmatter: z.infer<typeof skillFrontmatterSchema>;
29
43
  }
30
44
 
31
45
  const MAX_DESCRIPTION_LENGTH = 400;
@@ -65,52 +79,18 @@ export function findBundledSkillsRoot(): string | null {
65
79
  return null;
66
80
  }
67
81
 
68
- function parseFrontmatterDescription(rawMarkdown: string): string {
69
- // Normalize CRLF first: a git checkout with autocrlf (the Windows default)
70
- // puts a trailing \r on every line, which `(.*)$` cannot match (JS `.` and
71
- // `$` both exclude \r), silently emptying every description.
72
- const markdown = rawMarkdown.replace(/\r\n/g, '\n');
73
- // YAML frontmatter lives between two `---` fences at the very top.
74
- if (!markdown.startsWith('---')) return '';
75
- const end = markdown.indexOf('\n---', 3);
76
- if (end === -1) return '';
77
- const frontmatter = markdown.slice(3, end);
78
- const lines = frontmatter.split('\n');
79
-
80
- // Support both single-line `description: ...` and block-scalar `description: >` /
81
- // `description: |` forms (indented continuation lines).
82
- for (let i = 0; i < lines.length; i++) {
83
- const line = lines[i] ?? '';
84
- const match = /^description:\s*(.*)$/.exec(line);
85
- if (!match) continue;
86
- const first = (match[1] ?? '').trim();
87
- if (first && first !== '>' && first !== '|' && first !== '>-' && first !== '|-') {
88
- return first.slice(0, MAX_DESCRIPTION_LENGTH);
89
- }
90
- // Block scalar — concatenate indented follow-on lines.
91
- const parts: string[] = [];
92
- for (let j = i + 1; j < lines.length; j++) {
93
- const follow = lines[j] ?? '';
94
- if (follow.length === 0) {
95
- parts.push('');
96
- continue;
97
- }
98
- if (!/^\s/.test(follow)) break;
99
- parts.push(follow.trim());
100
- }
101
- return parts.join(' ').replace(/\s+/g, ' ').trim().slice(0, MAX_DESCRIPTION_LENGTH);
102
- }
103
- return '';
82
+ function parseFrontmatter(markdown: string): BundledSkill['frontmatter'] {
83
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown);
84
+ if (!match) throw new Error('Bundled skill is missing YAML frontmatter');
85
+ return skillFrontmatterSchema.parse(Bun.YAML.parse(match[1]!));
104
86
  }
105
87
 
106
88
  /**
107
89
  * Enumerate every `<name>/SKILL.md` under the bundled skills root and return
108
- * a compact index. Hidden directories (dotfolders) and files that don't parse
109
- * are skipped silently rather than throwing — missing metadata should never
110
- * break the MCP server's resource listing.
90
+ * a compact index. Invalid packaged skills fail explicitly rather than being
91
+ * silently omitted from the Skills extension's integrity manifest.
111
92
  */
112
- export function listBundledSkills(): BundledSkill[] {
113
- const root = findBundledSkillsRoot();
93
+ export function listBundledSkills(root = findBundledSkillsRoot()): BundledSkill[] {
114
94
  if (!root) return [];
115
95
  const entries = readdirSync(root);
116
96
  const skills: BundledSkill[] = [];
@@ -118,18 +98,19 @@ export function listBundledSkills(): BundledSkill[] {
118
98
  if (entry.startsWith('.')) continue;
119
99
  const skillFile = join(root, entry, 'SKILL.md');
120
100
  if (!existsSync(skillFile)) continue;
121
- try {
122
- const markdown = readFileSync(skillFile, 'utf-8');
123
- const description = parseFrontmatterDescription(markdown);
124
- skills.push({
125
- name: entry,
126
- description,
127
- uri: `canvas://skills/${entry}`,
128
- filePath: skillFile,
129
- });
130
- } catch {
131
- // skip unreadable files
101
+ if (lstatSync(join(root, entry)).isSymbolicLink() || lstatSync(skillFile).isSymbolicLink()) {
102
+ throw new Error(`Bundled skill contains a symbolic link: ${entry}`);
132
103
  }
104
+ if (!lstatSync(skillFile).isFile()) throw new Error(`Bundled skill contains a non-regular file: ${skillFile}`);
105
+ const frontmatter = parseFrontmatter(readFileSync(skillFile, 'utf-8'));
106
+ if (frontmatter.name !== entry) throw new Error(`Bundled skill name does not match directory: ${entry}`);
107
+ skills.push({
108
+ name: entry,
109
+ description: frontmatter.description.slice(0, MAX_DESCRIPTION_LENGTH),
110
+ uri: `canvas://skills/${entry}`,
111
+ filePath: skillFile,
112
+ frontmatter,
113
+ });
133
114
  }
134
115
  skills.sort((a, b) => a.name.localeCompare(b.name));
135
116
  return skills;
@@ -145,3 +126,61 @@ export function readBundledSkill(name: string): string | null {
145
126
  return null;
146
127
  }
147
128
  }
129
+
130
+ export interface SkillEntry {
131
+ uri: string;
132
+ frontmatter: BundledSkill['frontmatter'];
133
+ resources: Array<{ uri: string; digest: string; size: number }>;
134
+ }
135
+
136
+ type SkillContent = { uri: string; mimeType: string } & ({ text: string } | { blob: string });
137
+
138
+ /** Capture immutable package bytes once per MCP process, never read caller-supplied paths. */
139
+ export function createBundledSkillCatalog(root = findBundledSkillsRoot()) {
140
+ const skills: SkillEntry[] = [];
141
+ const files = new Map<string, SkillContent>();
142
+ for (const skill of listBundledSkills(root)) {
143
+ const entry: SkillEntry = { uri: `skill://${skill.name}/SKILL.md`, frontmatter: skill.frontmatter, resources: [] };
144
+ let totalBytes = 0;
145
+ function walk(directory: string, prefix = ''): void {
146
+ for (const child of readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
147
+ a.name.localeCompare(b.name),
148
+ )) {
149
+ const filePath = join(directory, child.name);
150
+ const relativePath = prefix + encodeURIComponent(child.name);
151
+ if (child.isSymbolicLink()) throw new Error(`Bundled skill contains a symbolic link: ${filePath}`);
152
+ if (child.isDirectory()) {
153
+ walk(filePath, `${relativePath}/`);
154
+ continue;
155
+ }
156
+ if (!child.isFile()) throw new Error(`Bundled skill contains a non-regular file: ${filePath}`);
157
+ totalBytes += lstatSync(filePath).size;
158
+ if (entry.resources.length >= 512 || totalBytes > 16 * 1024 * 1024) {
159
+ throw new Error(`Bundled skill exceeds SEP-2640 file/byte limits: ${skill.name}`);
160
+ }
161
+ const bytes = readFileSync(filePath);
162
+ const uri = `skill://${skill.name}/${relativePath}`;
163
+ const mimeType = child.name.endsWith('.md') ? 'text/markdown' : Bun.file(filePath).type;
164
+ // Round-trip detection preserves invalid UTF-8 and binary assets as blobs.
165
+ const text = bytes.toString('utf8');
166
+ const content =
167
+ !bytes.includes(0) && Buffer.from(text).equals(bytes)
168
+ ? { uri, mimeType, text }
169
+ : { uri, mimeType, blob: bytes.toString('base64') };
170
+ files.set(uri, content);
171
+ entry.resources.push({
172
+ uri,
173
+ size: bytes.length,
174
+ digest: `sha256:${createHash('sha256').update(bytes).digest('hex')}`,
175
+ });
176
+ if (uri === entry.uri) entry.frontmatter = parseFrontmatter(text);
177
+ }
178
+ }
179
+ walk(dirname(skill.filePath));
180
+ if (entry.frontmatter.name !== skill.name)
181
+ throw new Error(`Bundled skill name changed during loading: ${skill.name}`);
182
+ entry.resources.sort((a, b) => (a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0));
183
+ skills.push(entry);
184
+ }
185
+ return { skills, files };
186
+ }
@@ -726,7 +726,7 @@ function shouldTreatFileContentAsPath(input: CanvasAddNodeInput): boolean {
726
726
  const content = input.content?.trim() ?? '';
727
727
  if (!content || content.includes('\n') || content.includes('\r')) return false;
728
728
  if (typeof input.data?.path === 'string' && input.data.path.length > 0) return true;
729
- if (existsSync(resolve(content))) return true;
729
+ if (existsSync(resolve(canvasState.getWorkspaceRoot(), content))) return true;
730
730
  if (!input.title) return true;
731
731
  return content.startsWith('/') || content.startsWith('./') || content.startsWith('../') || content.includes('/');
732
732
  }
@@ -748,7 +748,7 @@ function buildFileNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
748
748
 
749
749
  const rawPath =
750
750
  typeof input.data?.path === 'string' && input.data.path.length > 0 ? input.data.path : (input.content ?? '');
751
- const resolved = resolve(rawPath);
751
+ const resolved = resolve(canvasState.getWorkspaceRoot(), rawPath);
752
752
  const fileName = basename(resolved) || rawPath;
753
753
  const data: Record<string, unknown> = {
754
754
  ...(input.data ?? {}),
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * This module must never import server.ts or index.ts.
10
10
  */
11
+ import { resolve } from 'node:path';
11
12
  import { z } from 'zod';
12
13
  import { canvasState, type CanvasAnnotation, type CanvasLayout, type CanvasNodeState } from '../../canvas-state.js';
13
14
  import {
@@ -1145,7 +1146,7 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
1145
1146
  },
1146
1147
  },
1147
1148
  handler: async (input, ctx) => {
1148
- const body: Record<string, unknown> = input;
1149
+ let body: Record<string, unknown> = input;
1149
1150
  const id = input.id;
1150
1151
  const existing = canvasState.getNode(id);
1151
1152
  if (!existing) throw new OperationError(`Node "${id}" not found.`, 404);
@@ -1162,7 +1163,48 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
1162
1163
  const result = await refreshCanvasWebpageNode(id, { ...(url ? { url } : {}) });
1163
1164
  return result as unknown as Record<string, unknown>;
1164
1165
  }
1166
+
1167
+ let nextFilePath: string | null = null;
1168
+ let nextFileContent: ReturnType<typeof readFileNodeContent> | null = null;
1169
+ if (existing.type === 'file') {
1170
+ const nestedData = isRecord(body.data) ? body.data : undefined;
1171
+ const hasTopLevelPath = body.path !== undefined;
1172
+ const hasNestedPath = nestedData?.path !== undefined;
1173
+ if (hasTopLevelPath || hasNestedPath) {
1174
+ const normalizePath = (value: unknown): string => {
1175
+ if (typeof value !== 'string' || !value.trim()) {
1176
+ throw new OperationError('File node "path" must be a non-empty string.');
1177
+ }
1178
+ return resolve(canvasState.getWorkspaceRoot(), value.trim());
1179
+ };
1180
+ const normalizedTopLevelPath = hasTopLevelPath ? normalizePath(body.path) : undefined;
1181
+ const normalizedNestedPath = hasNestedPath ? normalizePath(nestedData?.path) : undefined;
1182
+ body = {
1183
+ ...body,
1184
+ ...(normalizedTopLevelPath ? { path: normalizedTopLevelPath } : {}),
1185
+ ...(nestedData
1186
+ ? { data: { ...nestedData, ...(normalizedNestedPath ? { path: normalizedNestedPath } : {}) } }
1187
+ : {}),
1188
+ };
1189
+ nextFilePath = normalizedNestedPath ?? normalizedTopLevelPath ?? null;
1190
+ nextFileContent = nextFilePath ? readFileNodeContent(nextFilePath) : null;
1191
+ if (!nextFileContent || nextFileContent.kind === 'unavailable') {
1192
+ throw new OperationError(`File node target "${nextFilePath}" is missing or unreadable.`);
1193
+ }
1194
+ }
1195
+ }
1196
+
1165
1197
  const { patch, groupChildIds } = buildNodePatch(existing, body);
1198
+ const prevPath = existing.type === 'file' && typeof existing.data.path === 'string' ? existing.data.path : null;
1199
+ if (nextFilePath && nextFileContent && patch.data) {
1200
+ const data = { ...patch.data };
1201
+ const prevBase = prevPath?.split(/[\\/]/).pop();
1202
+ if (typeof body.title !== 'string' && (!data.title || data.title === prevBase)) {
1203
+ data.title = nextFilePath.split(/[\\/]/).pop();
1204
+ }
1205
+ applyFileContentToNodeData(data, nextFileContent);
1206
+ patch.data = data;
1207
+ }
1166
1208
  canvasState.updateNode(id, patch);
1167
1209
  if (groupChildIds !== undefined && !setGroupChildrenFromApi(id, groupChildIds)) {
1168
1210
  throw new OperationError(`Group "${id}" not found.`, 404);
@@ -1171,20 +1213,10 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
1171
1213
  // watcher IN PLACE — before this, pointing a file node elsewhere meant
1172
1214
  // recreating it, which lost its edges, pins, and position.
1173
1215
  if (existing.type === 'file') {
1174
- const prevPath = typeof existing.data.path === 'string' ? existing.data.path : null;
1175
1216
  const repointed = canvasState.getNode(id);
1176
1217
  const nextPath = repointed && typeof repointed.data.path === 'string' ? repointed.data.path : null;
1177
1218
  if (nextPath && nextPath !== prevPath) {
1178
1219
  if (prevPath) unwatchFileForNode(id, prevPath);
1179
- const data = { ...repointed!.data };
1180
- // The title follows the file unless the caller pinned one in this
1181
- // patch or had renamed the node away from the old filename.
1182
- const prevBase = prevPath?.split(/[\\/]/).pop();
1183
- if (typeof body.title !== 'string' && (!data.title || data.title === prevBase)) {
1184
- data.title = nextPath.split(/[\\/]/).pop();
1185
- }
1186
- applyFileContentToNodeData(data, readFileNodeContent(nextPath));
1187
- canvasState.updateNode(id, { data });
1188
1220
  watchFileForNode(id, nextPath);
1189
1221
  scheduleCodeGraphRecompute(() => {
1190
1222
  ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });