pmx-canvas 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +591 -0
  2. package/CHANGELOG.md +140 -0
  3. package/Readme.md +40 -8
  4. package/dist/canvas/global.css +36 -3
  5. package/dist/canvas/index.js +54 -54
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -0
  7. package/dist/types/client/nodes/iframe-document-url.d.ts +8 -0
  8. package/dist/types/client/state/intent-bridge.d.ts +4 -0
  9. package/dist/types/client/types.d.ts +1 -0
  10. package/dist/types/json-render/catalog.d.ts +1 -1
  11. package/dist/types/mcp/canvas-access.d.ts +9 -0
  12. package/dist/types/server/ax-context.d.ts +3 -0
  13. package/dist/types/server/ax-state.d.ts +43 -0
  14. package/dist/types/server/canvas-db.d.ts +38 -0
  15. package/dist/types/server/canvas-state.d.ts +36 -16
  16. package/dist/types/server/index.d.ts +6 -0
  17. package/dist/types/server/mutation-history.d.ts +1 -1
  18. package/docs/cli.md +13 -0
  19. package/docs/http-api.md +24 -0
  20. package/docs/mcp.md +20 -2
  21. package/docs/plans/plan-004-pmx-ax-primitives.md +463 -0
  22. package/docs/screenshot.png +0 -0
  23. package/docs/sdk.md +5 -0
  24. package/package.json +3 -2
  25. package/skills/pmx-canvas/SKILL.md +22 -4
  26. package/skills/pmx-canvas/references/codex-app-adapter.md +107 -0
  27. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +111 -0
  28. package/src/cli/agent.ts +34 -0
  29. package/src/cli/index.ts +2 -1
  30. package/src/client/App.tsx +2 -0
  31. package/src/client/canvas/CanvasNode.tsx +7 -0
  32. package/src/client/canvas/CommandPalette.tsx +2 -1
  33. package/src/client/canvas/use-node-drag.ts +29 -7
  34. package/src/client/canvas/use-node-resize.ts +27 -7
  35. package/src/client/nodes/ExtAppFrame.tsx +51 -10
  36. package/src/client/nodes/HtmlNode.tsx +5 -2
  37. package/src/client/nodes/iframe-document-url.ts +58 -0
  38. package/src/client/state/intent-bridge.ts +8 -0
  39. package/src/client/state/sse-bridge.ts +2 -2
  40. package/src/client/theme/global.css +36 -3
  41. package/src/client/types.ts +1 -0
  42. package/src/mcp/canvas-access.ts +38 -0
  43. package/src/mcp/server.ts +113 -4
  44. package/src/server/ax-context.ts +38 -0
  45. package/src/server/ax-state.ts +130 -0
  46. package/src/server/canvas-db.ts +745 -0
  47. package/src/server/canvas-operations.ts +80 -1
  48. package/src/server/canvas-schema.ts +3 -3
  49. package/src/server/canvas-state.ts +390 -50
  50. package/src/server/canvas-validation.ts +6 -0
  51. package/src/server/index.ts +18 -0
  52. package/src/server/mutation-history.ts +1 -0
  53. package/src/server/server.ts +197 -11
@@ -108,6 +108,7 @@ export function isExcalidrawNode(node: CanvasNodeState): boolean {
108
108
 
109
109
  export interface CanvasLayout {
110
110
  viewport: ViewportState;
111
+ theme?: 'dark' | 'light' | 'high-contrast';
111
112
  nodes: CanvasNodeState[];
112
113
  edges: CanvasEdge[];
113
114
  annotations?: CanvasAnnotation[];
@@ -9,6 +9,7 @@ import {
9
9
  type CanvasSnapshot,
10
10
  type PmxCanvas,
11
11
  } from '../server/index.js';
12
+ import type { PmxAxSource } from '../server/ax-state.js';
12
13
 
13
14
  type AddNodeInput = Parameters<PmxCanvas['addNode']>[0];
14
15
  type AddWebpageNodeInput = Parameters<PmxCanvas['addWebpageNode']>[0];
@@ -31,6 +32,9 @@ type ArrangeLayout = Parameters<PmxCanvas['arrange']>[0];
31
32
  type FocusNodeResult = ReturnType<PmxCanvas['focusNode']>;
32
33
  type FitViewOptions = Parameters<PmxCanvas['fitView']>[0];
33
34
  type FitViewResult = ReturnType<PmxCanvas['fitView']>;
35
+ type AxStateResult = ReturnType<PmxCanvas['getAxState']>;
36
+ type AxContextResult = ReturnType<PmxCanvas['getAxContext']>;
37
+ type SetAxFocusResult = ReturnType<PmxCanvas['setAxFocus']>;
34
38
  type SearchResult = ReturnType<PmxCanvas['search']>;
35
39
  type UndoRedoResult = Awaited<ReturnType<PmxCanvas['undo']>>;
36
40
  type HistoryResult = ReturnType<PmxCanvas['getHistory']>;
@@ -118,6 +122,9 @@ export interface CanvasAccess {
118
122
  arrange(layout?: ArrangeLayout): Promise<void>;
119
123
  focusNode(id: string, options?: { noPan?: boolean }): Promise<FocusNodeResult>;
120
124
  fitView(options?: FitViewOptions): Promise<FitViewResult>;
125
+ getAxState(): Promise<AxStateResult>;
126
+ getAxContext(): Promise<AxContextResult>;
127
+ setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): Promise<SetAxFocusResult>;
121
128
  clear(): Promise<void>;
122
129
  search(query: string): Promise<SearchResult>;
123
130
  undo(): Promise<UndoRedoResult>;
@@ -247,6 +254,18 @@ class LocalCanvasAccess implements CanvasAccess {
247
254
  return this.canvas.fitView(options);
248
255
  }
249
256
 
257
+ async getAxState(): Promise<AxStateResult> {
258
+ return this.canvas.getAxState();
259
+ }
260
+
261
+ async getAxContext(): Promise<AxContextResult> {
262
+ return this.canvas.getAxContext();
263
+ }
264
+
265
+ async setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): Promise<SetAxFocusResult> {
266
+ return this.canvas.setAxFocus(nodeIds, { source: options?.source ?? 'mcp' });
267
+ }
268
+
250
269
  async clear(): Promise<void> {
251
270
  this.canvas.clear();
252
271
  }
@@ -587,6 +606,25 @@ class RemoteCanvasAccess implements CanvasAccess {
587
606
  return await this.requestJson<HistoryResult>('GET', '/api/canvas/history');
588
607
  }
589
608
 
609
+ async getAxState(): Promise<AxStateResult> {
610
+ const response = await this.requestJson<{ state?: AxStateResult }>('GET', '/api/canvas/ax');
611
+ if (!response.state) throw new Error('Remote canvas did not return AX state.');
612
+ return response.state;
613
+ }
614
+
615
+ async getAxContext(): Promise<AxContextResult> {
616
+ return await this.requestJson<AxContextResult>('GET', '/api/canvas/ax/context');
617
+ }
618
+
619
+ async setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): Promise<SetAxFocusResult> {
620
+ const response = await this.requestJson<{ focus?: SetAxFocusResult }>('POST', '/api/canvas/ax/focus', {
621
+ nodeIds,
622
+ source: options?.source ?? 'mcp',
623
+ });
624
+ if (!response.focus) throw new Error('Remote canvas did not return AX focus.');
625
+ return response.focus;
626
+ }
627
+
590
628
  async setContextPins(nodeIds: string[], mode: 'set' | 'add' | 'remove' = 'set'): Promise<SetContextPinsResult> {
591
629
  const existing = mode === 'set' ? [] : await this.getPinnedNodeIds();
592
630
  const requested = new Set(nodeIds);
package/src/mcp/server.ts CHANGED
@@ -100,13 +100,17 @@ function sleep(ms: number): Promise<void> {
100
100
  return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
101
101
  }
102
102
 
103
- function sendCanvasResourceNotifications(type: 'nodes' | 'pins' = 'nodes'): void {
103
+ function sendCanvasResourceNotifications(type: 'nodes' | 'pins' | 'ax' = 'nodes'): void {
104
104
  const server = resourceNotificationServer;
105
105
  if (!server) return;
106
106
  try {
107
107
  if (type === 'pins') {
108
108
  server.server.sendResourceUpdated({ uri: 'canvas://pinned-context' });
109
109
  }
110
+ if (type === 'pins' || type === 'ax') {
111
+ server.server.sendResourceUpdated({ uri: 'canvas://ax' });
112
+ server.server.sendResourceUpdated({ uri: 'canvas://ax-context' });
113
+ }
110
114
  server.server.sendResourceUpdated({ uri: 'canvas://layout' });
111
115
  server.server.sendResourceUpdated({ uri: 'canvas://summary' });
112
116
  server.server.sendResourceUpdated({ uri: 'canvas://spatial-context' });
@@ -121,7 +125,9 @@ function handleRemoteSseFrame(frame: string): void {
121
125
  const eventLine = frame.split('\n').find((line) => line.startsWith('event: '));
122
126
  const event = eventLine?.slice('event: '.length).trim() ?? '';
123
127
  if (!event || event === 'connected' || event === 'ping') return;
124
- sendCanvasResourceNotifications(event === 'context-pins-changed' ? 'pins' : 'nodes');
128
+ sendCanvasResourceNotifications(
129
+ event === 'context-pins-changed' ? 'pins' : event === 'ax-state-changed' ? 'ax' : 'nodes',
130
+ );
125
131
  }
126
132
 
127
133
  async function watchRemoteCanvasEvents(baseUrl: string): Promise<void> {
@@ -904,7 +910,7 @@ export async function startMcpServer(): Promise<void> {
904
910
  // ── canvas_update_node ─────────────────────────────────────────
905
911
  server.tool(
906
912
  'canvas_update_node',
907
- 'Update an existing node. You can change its content, title, position, size, or data.',
913
+ 'Update an existing node. You can change its content, title, position, size, dock placement, or data.',
908
914
  {
909
915
  id: z.string().describe('Node ID to update'),
910
916
  title: z.string().optional().describe('New title'),
@@ -926,12 +932,14 @@ export async function startMcpServer(): Promise<void> {
926
932
  resultSummary: z.string().optional().describe('Trace node result summary'),
927
933
  error: z.string().optional().describe('Trace node error message'),
928
934
  collapsed: z.boolean().optional().describe('Collapse or expand the node'),
935
+ dockPosition: z.enum(['left', 'right']).nullable().optional().describe('Dock the node to the left/right HUD column, or pass null to return it to the canvas'),
936
+ pinned: z.boolean().optional().describe('Pin or unpin the node to exclude it from auto-arrange'),
929
937
  arrangeLocked: z.boolean().optional().describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
930
938
  full: z.boolean().optional().describe('Return the full updated node payload. Default false returns compact metadata.'),
931
939
  verbose: z.boolean().optional().describe('Alias for full:true.'),
932
940
  },
933
941
  async (input) => {
934
- const { id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, arrangeLocked, toolName, category, status, duration, resultSummary, error } = input;
942
+ const { id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, dockPosition, pinned, arrangeLocked, toolName, category, status, duration, resultSummary, error } = input;
935
943
  const c = await ensureCanvas();
936
944
  const node = await c.getNode(id);
937
945
  if (!node) {
@@ -950,6 +958,12 @@ export async function startMcpServer(): Promise<void> {
950
958
  if (collapsed !== undefined) {
951
959
  patch.collapsed = collapsed;
952
960
  }
961
+ if (dockPosition !== undefined) {
962
+ patch.dockPosition = dockPosition;
963
+ }
964
+ if (pinned !== undefined) {
965
+ patch.pinned = pinned;
966
+ }
953
967
  if (title !== undefined) patch.title = title;
954
968
  if (content !== undefined) patch.content = content;
955
969
  if (spec !== undefined) patch.spec = spec;
@@ -1120,6 +1134,55 @@ export async function startMcpServer(): Promise<void> {
1120
1134
  },
1121
1135
  );
1122
1136
 
1137
+ // ── AX context and focus ───────────────────────────────────────
1138
+ server.tool(
1139
+ 'canvas_get_ax',
1140
+ 'Read the host-agnostic PMX AX state and agent-ready AX context. Use this when you need pinned context plus the current focus field.',
1141
+ {
1142
+ includeContext: z.boolean().optional().describe('Include serialized agent-ready AX context. Default true.'),
1143
+ },
1144
+ async ({ includeContext }) => {
1145
+ const c = await ensureCanvas();
1146
+ const state = await c.getAxState();
1147
+ const context = includeContext === false ? undefined : await c.getAxContext();
1148
+ return {
1149
+ content: [
1150
+ {
1151
+ type: 'text',
1152
+ text: JSON.stringify({
1153
+ ok: true,
1154
+ state,
1155
+ ...(context ? { context } : {}),
1156
+ }),
1157
+ },
1158
+ ],
1159
+ };
1160
+ },
1161
+ );
1162
+
1163
+ server.tool(
1164
+ 'canvas_set_ax_focus',
1165
+ 'Set the PMX AX focus field without requiring viewport movement. Focus is persisted and available through canvas://ax-context.',
1166
+ {
1167
+ nodeIds: z.array(z.string()).describe('Node IDs to place in the AX focus field. Missing nodes are ignored.'),
1168
+ source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
1169
+ .optional()
1170
+ .describe('Optional host/source label for adapter-originated focus. Defaults to mcp. Use codex from the Codex app adapter.'),
1171
+ },
1172
+ async ({ nodeIds, source }) => {
1173
+ const c = await ensureCanvas();
1174
+ const focus = await c.setAxFocus(nodeIds, { source: source ?? 'mcp' });
1175
+ return {
1176
+ content: [
1177
+ {
1178
+ type: 'text',
1179
+ text: JSON.stringify({ ok: true, focus }),
1180
+ },
1181
+ ],
1182
+ };
1183
+ },
1184
+ );
1185
+
1123
1186
  server.tool(
1124
1187
  'canvas_fit_view',
1125
1188
  'Fit the canvas viewport to all nodes or a selected subset. Useful before screenshots and whole-board review.',
@@ -1497,6 +1560,52 @@ export async function startMcpServer(): Promise<void> {
1497
1560
  },
1498
1561
  );
1499
1562
 
1563
+ server.resource(
1564
+ 'ax-state',
1565
+ 'canvas://ax',
1566
+ {
1567
+ description:
1568
+ 'Host-agnostic PMX AX state. This includes canvas-bound collaboration primitives such as the current AX focus.',
1569
+ mimeType: 'application/json',
1570
+ },
1571
+ async () => {
1572
+ const c = await ensureCanvas();
1573
+ const state = await c.getAxState();
1574
+ return {
1575
+ contents: [
1576
+ {
1577
+ uri: 'canvas://ax',
1578
+ mimeType: 'application/json',
1579
+ text: JSON.stringify({ state }, null, 2),
1580
+ },
1581
+ ],
1582
+ };
1583
+ },
1584
+ );
1585
+
1586
+ server.resource(
1587
+ 'ax-context',
1588
+ 'canvas://ax-context',
1589
+ {
1590
+ description:
1591
+ 'Agent-ready PMX AX context combining pinned context, focus, and surface metadata.',
1592
+ mimeType: 'application/json',
1593
+ },
1594
+ async () => {
1595
+ const c = await ensureCanvas();
1596
+ const context = await c.getAxContext();
1597
+ return {
1598
+ contents: [
1599
+ {
1600
+ uri: 'canvas://ax-context',
1601
+ mimeType: 'application/json',
1602
+ text: JSON.stringify(context, null, 2),
1603
+ },
1604
+ ],
1605
+ };
1606
+ },
1607
+ );
1608
+
1500
1609
  server.resource(
1501
1610
  'canvas-layout',
1502
1611
  'canvas://layout',
@@ -0,0 +1,38 @@
1
+ import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
2
+ import { buildAxContext, type PmxAxContext, type PmxAxPinnedContext } from './ax-state.js';
3
+ import { canvasState, type CanvasNodeState } from './canvas-state.js';
4
+
5
+ function serializeNodes(nodes: CanvasNodeState[]) {
6
+ return nodes.map((node) => serializeNodeForAgentContext(node, {
7
+ defaultTextLength: 700,
8
+ webpageTextLength: 1600,
9
+ includePosition: true,
10
+ }));
11
+ }
12
+
13
+ export function buildCanvasAxPinnedContext(): PmxAxPinnedContext {
14
+ const nodeIds = Array.from(canvasState.contextPinnedNodeIds);
15
+ const nodes = nodeIds
16
+ .map((id) => canvasState.getNode(id))
17
+ .filter((node): node is CanvasNodeState => node !== undefined);
18
+ return {
19
+ preamble: nodes.length > 0 ? buildAgentContextPreamble(nodes) : '',
20
+ nodeIds,
21
+ count: nodeIds.length,
22
+ nodes: serializeNodes(nodes),
23
+ };
24
+ }
25
+
26
+ export function buildCanvasAxContext(): PmxAxContext {
27
+ const layout = canvasState.getLayout();
28
+ const focus = canvasState.getAxFocus();
29
+ const focusNodes = focus.nodeIds
30
+ .map((id) => canvasState.getNode(id))
31
+ .filter((node): node is CanvasNodeState => node !== undefined);
32
+ return buildAxContext({
33
+ layout,
34
+ pinned: buildCanvasAxPinnedContext(),
35
+ focus,
36
+ focusNodes: serializeNodes(focusNodes),
37
+ });
38
+ }
@@ -0,0 +1,130 @@
1
+ import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
2
+ import type { AgentContextNode } from './agent-context.js';
3
+
4
+ export type PmxAxSource = 'agent' | 'api' | 'browser' | 'cli' | 'codex' | 'copilot' | 'mcp' | 'sdk' | 'system';
5
+
6
+ export interface PmxAxFocusState {
7
+ nodeIds: string[];
8
+ primaryNodeId: string | null;
9
+ updatedAt: string | null;
10
+ source: PmxAxSource | null;
11
+ }
12
+
13
+ export interface PmxAxState {
14
+ version: 1;
15
+ focus: PmxAxFocusState;
16
+ }
17
+
18
+ export interface PmxAxPinnedContext {
19
+ preamble: string;
20
+ nodeIds: string[];
21
+ count: number;
22
+ nodes: AgentContextNode[];
23
+ }
24
+
25
+ export interface PmxAxFocusContext extends PmxAxFocusState {
26
+ nodes: AgentContextNode[];
27
+ }
28
+
29
+ export interface PmxAxContext {
30
+ version: 1;
31
+ generatedAt: string;
32
+ surface: {
33
+ nodeCount: number;
34
+ edgeCount: number;
35
+ };
36
+ pinned: PmxAxPinnedContext;
37
+ focus: PmxAxFocusContext;
38
+ }
39
+
40
+ const AX_SOURCES = new Set<PmxAxSource>(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']);
41
+
42
+ function isRecord(value: unknown): value is Record<string, unknown> {
43
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
44
+ }
45
+
46
+ function normalizeSource(value: unknown): PmxAxSource | null {
47
+ return typeof value === 'string' && AX_SOURCES.has(value as PmxAxSource)
48
+ ? value as PmxAxSource
49
+ : null;
50
+ }
51
+
52
+ function normalizeTimestamp(value: unknown): string | null {
53
+ if (typeof value !== 'string') return null;
54
+ const parsed = Date.parse(value);
55
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
56
+ }
57
+
58
+ function normalizeNodeIds(value: unknown, validNodeIds?: Set<string>): string[] {
59
+ if (!Array.isArray(value)) return [];
60
+ const ids: string[] = [];
61
+ for (const item of value) {
62
+ if (typeof item !== 'string') continue;
63
+ if (validNodeIds && !validNodeIds.has(item)) continue;
64
+ if (!ids.includes(item)) ids.push(item);
65
+ }
66
+ return ids;
67
+ }
68
+
69
+ export function createEmptyAxFocusState(): PmxAxFocusState {
70
+ return {
71
+ nodeIds: [],
72
+ primaryNodeId: null,
73
+ updatedAt: null,
74
+ source: null,
75
+ };
76
+ }
77
+
78
+ export function createEmptyAxState(): PmxAxState {
79
+ return {
80
+ version: 1,
81
+ focus: createEmptyAxFocusState(),
82
+ };
83
+ }
84
+
85
+ export function normalizeAxFocusState(input: unknown, validNodeIds?: Set<string>): PmxAxFocusState {
86
+ if (!isRecord(input)) return createEmptyAxFocusState();
87
+ const nodeIds = normalizeNodeIds(input.nodeIds, validNodeIds);
88
+ const primaryNodeId = typeof input.primaryNodeId === 'string' && nodeIds.includes(input.primaryNodeId)
89
+ ? input.primaryNodeId
90
+ : nodeIds[0] ?? null;
91
+ return {
92
+ nodeIds,
93
+ primaryNodeId,
94
+ updatedAt: normalizeTimestamp(input.updatedAt),
95
+ source: normalizeSource(input.source),
96
+ };
97
+ }
98
+
99
+ export function normalizeAxState(input: unknown, validNodeIds?: Set<string>): PmxAxState {
100
+ if (!isRecord(input)) return createEmptyAxState();
101
+ return {
102
+ version: 1,
103
+ focus: normalizeAxFocusState(input.focus, validNodeIds),
104
+ };
105
+ }
106
+
107
+ export function buildAxContext(input: {
108
+ layout: CanvasLayout;
109
+ pinned: PmxAxPinnedContext;
110
+ focus: PmxAxFocusState;
111
+ focusNodes: AgentContextNode[];
112
+ }): PmxAxContext {
113
+ return {
114
+ version: 1,
115
+ generatedAt: new Date().toISOString(),
116
+ surface: {
117
+ nodeCount: input.layout.nodes.length,
118
+ edgeCount: input.layout.edges.length,
119
+ },
120
+ pinned: input.pinned,
121
+ focus: {
122
+ ...input.focus,
123
+ nodes: input.focusNodes,
124
+ },
125
+ };
126
+ }
127
+
128
+ export function nodeSetFromLayout(nodes: CanvasNodeState[]): Set<string> {
129
+ return new Set(nodes.map((node) => node.id));
130
+ }