pmx-canvas 0.6.2 → 0.6.4

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 (39) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/Readme.md +30 -3
  3. package/dist/canvas/global.css +32 -0
  4. package/dist/canvas/index.js +68 -66
  5. package/dist/json-render/index.js +57 -57
  6. package/dist/types/client/canvas/FeedbackDialog.d.ts +4 -0
  7. package/dist/types/client/canvas/auto-fit.d.ts +3 -0
  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/cli.md +7 -2
  15. package/docs/http-api.md +5 -1
  16. package/docs/mcp.md +35 -1
  17. package/docs/node-types.md +43 -1
  18. package/docs/product-context-vision-2026-09.md +17 -6
  19. package/docs/product-vision-2026-09.md +136 -41
  20. package/docs/product-vision-review-2026-09.md +277 -0
  21. package/package.json +11 -11
  22. package/skills/json-render-mcp/SKILL.md +118 -2
  23. package/skills/pmx-canvas/SKILL.md +43 -13
  24. package/skills/pmx-canvas/evals/evals.json +46 -0
  25. package/skills/pmx-canvas/references/installing-pmx-canvas.md +5 -5
  26. package/src/client/canvas/FeedbackDialog.tsx +86 -0
  27. package/src/client/canvas/ToolRail.tsx +225 -189
  28. package/src/client/canvas/auto-fit.ts +20 -1
  29. package/src/client/nodes/use-iframe-content-height.ts +44 -24
  30. package/src/client/state/human-store.ts +6 -6
  31. package/src/client/theme/global.css +32 -0
  32. package/src/json-render/catalog.ts +6 -0
  33. package/src/json-render/renderer/index.tsx +17 -0
  34. package/src/json-render/schema.ts +4 -0
  35. package/src/mcp/server.ts +9 -4
  36. package/src/mcp/skills.ts +47 -0
  37. package/src/server/bundled-skills.ts +92 -53
  38. package/src/server/operations/ops/nodes.ts +43 -11
  39. package/src/shared/content-height-reporter.ts +4 -2
@@ -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
  }
@@ -4474,6 +4474,38 @@ html.is-node-resizing .ext-app-preview-catcher {
4474
4474
  font-weight: 600;
4475
4475
  }
4476
4476
 
4477
+ .feedback-backdrop {
4478
+ position: fixed;
4479
+ padding: 24px 0;
4480
+ align-items: center;
4481
+ }
4482
+ .feedback-dialog {
4483
+ width: min(480px, calc(100% - 32px));
4484
+ max-height: calc(100dvh - 48px);
4485
+ overflow-y: auto;
4486
+ color: var(--c-text);
4487
+ }
4488
+ .feedback-dialog label {
4489
+ display: flex;
4490
+ flex-direction: column;
4491
+ gap: 6px;
4492
+ font-size: 12px;
4493
+ }
4494
+ .feedback-note {
4495
+ margin: 0;
4496
+ color: var(--c-muted);
4497
+ font-size: 12px;
4498
+ line-height: 1.5;
4499
+ }
4500
+ .feedback-dialog textarea {
4501
+ resize: vertical;
4502
+ min-height: 100px;
4503
+ }
4504
+ .feedback-dialog button:disabled {
4505
+ opacity: 0.5;
4506
+ cursor: not-allowed;
4507
+ }
4508
+
4477
4509
  /* Selection action bar — floating bottom-center of the canvas region
4478
4510
  (rail-chrome-v2 phase 7, item 13). */
4479
4511
  /* In a session the command bar owns the bottom-center slot — lift the
@@ -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
+ }
@@ -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() });
@@ -26,8 +26,10 @@ export function contentHeightReporterSource(frameToken: string): string {
26
26
  `function m(){var d=document.documentElement;return Math.max(d?d.scrollHeight:0,document.body?document.body.scrollHeight:0);}` +
27
27
  `function r(){var h=m();if(Math.abs(h-last)<=4)return;last=h;window.parent.postMessage({source:'pmx-canvas-frame',type:'content-height',token:T,height:h},'*');}` +
28
28
  `function s(){if(timer)return;timer=setTimeout(function(){timer=null;r();},100);}` +
29
- `if(document.readyState!=='loading')s();window.addEventListener('load',s);` +
30
- `try{new ResizeObserver(s).observe(document.documentElement);}catch(e){}setTimeout(s,60);})();`
29
+ `if(document.readyState!=='loading')s();window.addEventListener('load',s,true);` +
30
+ `try{new ResizeObserver(s).observe(document.documentElement);}catch(e){}` +
31
+ `new MutationObserver(s).observe(document.documentElement,{subtree:true,childList:true,characterData:true,attributes:true});` +
32
+ `if(document.fonts)document.fonts.ready.then(s);setTimeout(s,60);})();`
31
33
  );
32
34
  }
33
35