pmx-canvas 0.2.1 → 0.2.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 (51) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/Readme.md +2 -2
  3. package/dist/canvas/global.css +260 -0
  4. package/dist/canvas/index.js +76 -76
  5. package/dist/json-render/index.js +2 -2
  6. package/dist/types/client/canvas/IntentLayer.d.ts +1 -0
  7. package/dist/types/client/state/intent-bridge.d.ts +10 -0
  8. package/dist/types/client/state/intent-store.d.ts +25 -0
  9. package/dist/types/json-render/server.d.ts +1 -1
  10. package/dist/types/server/index.d.ts +34 -4
  11. package/dist/types/server/intent-registry.d.ts +45 -0
  12. package/dist/types/server/operations/ops/intent.d.ts +2 -0
  13. package/dist/types/shared/ax-intent.d.ts +58 -0
  14. package/docs/mcp.md +21 -2
  15. package/docs/screenshot.png +0 -0
  16. package/package.json +1 -1
  17. package/skills/pmx-canvas/SKILL.md +200 -1305
  18. package/skills/pmx-canvas/evals/evals.json +255 -1
  19. package/skills/pmx-canvas/evals/fixtures/code-exploration/src/auth/jwt.ts +17 -0
  20. package/skills/pmx-canvas/evals/fixtures/code-exploration/src/auth/login.ts +12 -0
  21. package/skills/pmx-canvas/evals/fixtures/code-exploration/src/auth/middleware.ts +13 -0
  22. package/skills/pmx-canvas/evals/fixtures/code-exploration/src/routes/auth.ts +13 -0
  23. package/skills/pmx-canvas/evals/fixtures/investigation-board/src/handlers/users.ts +27 -0
  24. package/skills/pmx-canvas/references/full-reference.md +1445 -0
  25. package/src/cli/index.ts +21 -4
  26. package/src/client/canvas/CanvasNode.tsx +13 -13
  27. package/src/client/canvas/CanvasViewport.tsx +2 -0
  28. package/src/client/canvas/ContextMenu.tsx +25 -19
  29. package/src/client/canvas/IntentLayer.tsx +278 -0
  30. package/src/client/nodes/ExtAppFrame.tsx +32 -23
  31. package/src/client/state/intent-bridge.ts +31 -0
  32. package/src/client/state/intent-store.ts +107 -0
  33. package/src/client/state/sse-bridge.ts +31 -0
  34. package/src/client/theme/global.css +260 -0
  35. package/src/json-render/charts/components.tsx +18 -4
  36. package/src/json-render/renderer/index.tsx +11 -2
  37. package/src/json-render/server.ts +1 -1
  38. package/src/server/index.ts +240 -158
  39. package/src/server/intent-registry.ts +324 -0
  40. package/src/server/operations/composites.ts +11 -0
  41. package/src/server/operations/index.ts +2 -0
  42. package/src/server/operations/ops/edges.ts +1 -0
  43. package/src/server/operations/ops/groups.ts +3 -0
  44. package/src/server/operations/ops/intent.ts +132 -0
  45. package/src/server/operations/ops/json-render.ts +3 -0
  46. package/src/server/operations/ops/nodes.ts +3 -0
  47. package/src/server/operations/ops/webview.ts +15 -4
  48. package/src/server/operations/registry.ts +68 -3
  49. package/src/server/server.ts +40 -12
  50. package/src/shared/ax-intent.ts +64 -0
  51. package/src/shared/surface.ts +5 -1
@@ -6,6 +6,9 @@
6
6
  "name": "investigation-board",
7
7
  "prompt": "I'm debugging a memory leak in our Node.js API. The /api/users endpoint is leaking memory on every request. I found a suspicious closure in src/handlers/users.ts that captures the entire request object, and the heap snapshot shows growing EventEmitter listeners. Can you set up an investigation board on the canvas so I can see the full picture?",
8
8
  "expected_output": "Creates multiple nodes (bug description, code file, heap findings, hypothesis) connected with edges, arranged in a tree layout. Uses appropriate node types (markdown for findings, file for source, status for investigation progress).",
9
+ "files": [
10
+ "evals/fixtures/investigation-board/src/handlers/users.ts"
11
+ ],
9
12
  "assertions": [
10
13
  {
11
14
  "name": "creates-multiple-nodes",
@@ -93,7 +96,7 @@
93
96
  "assertions": [
94
97
  {
95
98
  "name": "reads-pinned-context",
96
- "description": "Reads the canvas://pinned-context MCP resource (not just canvas_query action:layout)",
99
+ "description": "Reads the curated pin set via canvas://pinned-context or the host-equivalent (canvas_ax_state / the HTTP pinned-context endpoint) where a direct resource tool is unavailable — not just canvas_query action:layout",
97
100
  "type": "output_check"
98
101
  },
99
102
  {
@@ -113,6 +116,12 @@
113
116
  "name": "code-exploration-files",
114
117
  "prompt": "I'm trying to understand how the authentication flow works in this project. Can you put the relevant auth files on the canvas so I can see how they connect? The main files are src/auth/login.ts, src/auth/middleware.ts, src/auth/jwt.ts, and src/routes/auth.ts.",
115
118
  "expected_output": "Creates file nodes for each mentioned file (content auto-loads), relies on code graph to auto-detect import dependencies, groups auth files together, and reads canvas://code-graph for dependency analysis.",
119
+ "files": [
120
+ "evals/fixtures/code-exploration/src/auth/jwt.ts",
121
+ "evals/fixtures/code-exploration/src/auth/login.ts",
122
+ "evals/fixtures/code-exploration/src/auth/middleware.ts",
123
+ "evals/fixtures/code-exploration/src/routes/auth.ts"
124
+ ],
116
125
  "assertions": [
117
126
  {
118
127
  "name": "uses-file-nodes",
@@ -181,6 +190,251 @@
181
190
  "type": "output_check"
182
191
  }
183
192
  ]
193
+ },
194
+ {
195
+ "id": 8,
196
+ "name": "workspace-identity-preflight",
197
+ "prompt": "Attach to the running PMX canvas and add a status node for our deploy. There's already a daemon on port 4313.",
198
+ "expected_output": "Before mutating, reads GET /health (or serve status) and confirms the returned workspace matches the intended workspace root; refuses to mutate a mismatched/stale listener and starts an isolated server on an explicit --port instead.",
199
+ "assertions": [
200
+ {
201
+ "name": "reads-health",
202
+ "description": "Reads /health or serve status before any mutation",
203
+ "type": "output_check"
204
+ },
205
+ {
206
+ "name": "checks-workspace",
207
+ "description": "Compares the returned workspace to the intended workspace root",
208
+ "type": "output_check"
209
+ },
210
+ {
211
+ "name": "handles-mismatch",
212
+ "description": "On workspace mismatch or responsive+pidRunning:false, does NOT mutate; isolates via explicit --port and re-checks /health",
213
+ "type": "output_check"
214
+ }
215
+ ]
216
+ },
217
+ {
218
+ "id": 9,
219
+ "name": "current-composite-routing",
220
+ "prompt": "Create a markdown note, a bar chart, and an Excalidraw diagram on the canvas.",
221
+ "expected_output": "Uses current composites: canvas_node {action:add} for markdown, canvas_render {action:add-graph} for the chart, canvas_app {action:diagram} for Excalidraw. Avoids deprecated single-purpose tools (canvas_add_node/canvas_add_diagram/canvas_build_web_artifact/canvas_open_mcp_app).",
222
+ "assertions": [
223
+ {
224
+ "name": "uses-canvas-node",
225
+ "description": "Creates the markdown node via canvas_node {action:add}",
226
+ "type": "output_check"
227
+ },
228
+ {
229
+ "name": "uses-canvas-render",
230
+ "description": "Creates the chart via canvas_render {action:add-graph}",
231
+ "type": "output_check"
232
+ },
233
+ {
234
+ "name": "uses-canvas-app",
235
+ "description": "Creates the Excalidraw diagram via canvas_app {action:diagram}",
236
+ "type": "output_check"
237
+ },
238
+ {
239
+ "name": "avoids-deprecated",
240
+ "description": "Does not call deprecated standalones (canvas_add_node/canvas_add_diagram/etc.)",
241
+ "type": "output_check"
242
+ }
243
+ ]
244
+ },
245
+ {
246
+ "id": 10,
247
+ "name": "existing-board-extension",
248
+ "prompt": "Add two more findings to the investigation board that's already on the canvas.",
249
+ "expected_output": "Searches the existing board first, snapshots before extending, adds new nodes without duplicating existing titles, and validates the final layout for collisions.",
250
+ "assertions": [
251
+ {
252
+ "name": "searches-first",
253
+ "description": "Searches existing nodes (canvas_query {action:search}) before adding",
254
+ "type": "output_check"
255
+ },
256
+ {
257
+ "name": "snapshots-before",
258
+ "description": "Snapshots before extending the board",
259
+ "type": "output_check"
260
+ },
261
+ {
262
+ "name": "no-duplicates",
263
+ "description": "Adds new nodes without duplicating existing node titles",
264
+ "type": "output_check"
265
+ },
266
+ {
267
+ "name": "validates-layout",
268
+ "description": "Runs canvas_query {action:validate} (or arrange+validate) on the result",
269
+ "type": "output_check"
270
+ }
271
+ ]
272
+ },
273
+ {
274
+ "id": 11,
275
+ "name": "context-pin-workflow",
276
+ "prompt": "Pin the auth design node so I (the agent) keep it in context, then confirm it's pinned.",
277
+ "expected_output": "Pins via a verified path (canvas_pin_nodes, the CLI pin, or browser/right-click 'Pin as context'), then reads canvas://pinned-context to confirm; does not claim a broken UI action succeeded.",
278
+ "assertions": [
279
+ {
280
+ "name": "pins-via-verified-path",
281
+ "description": "Pins via canvas_pin_nodes / CLI pin / 'Pin as context' — not an unverified action",
282
+ "type": "output_check"
283
+ },
284
+ {
285
+ "name": "reads-pinned-context",
286
+ "description": "Reads canvas://pinned-context (or canvas_ax_state) to confirm the pin",
287
+ "type": "output_check"
288
+ },
289
+ {
290
+ "name": "confirms-count",
291
+ "description": "Confirms the pin is reflected (context count / pinned set) rather than assuming success",
292
+ "type": "output_check"
293
+ }
294
+ ]
295
+ },
296
+ {
297
+ "id": 12,
298
+ "name": "status-node-cleanup",
299
+ "prompt": "Remove the obsolete 'Deploy: pending' status node from the board.",
300
+ "expected_output": "Removes the status node via canvas_node {action:remove} (status nodes are removable like any other type) and verifies removal; undo remains available.",
301
+ "assertions": [
302
+ {
303
+ "name": "removes-status-node",
304
+ "description": "Removes the status node via canvas_node {action:remove}",
305
+ "type": "output_check"
306
+ },
307
+ {
308
+ "name": "verifies-removal",
309
+ "description": "Verifies the node is gone from the layout",
310
+ "type": "output_check"
311
+ },
312
+ {
313
+ "name": "preserves-undo",
314
+ "description": "Notes/relies on undo (canvas_history) remaining available",
315
+ "type": "output_check"
316
+ }
317
+ ]
318
+ },
319
+ {
320
+ "id": 13,
321
+ "name": "ax-html-control-surface",
322
+ "prompt": "Build an AX control surface as an HTML node with a button that creates a work item and a button that steers me.",
323
+ "expected_output": "Creates an opted-in html node (axCapabilities.enabled) using the blessed recipe: awaits window.PMX_AX.emit, reflects live state via pmx-ax-update, avoids sandbox-blocked localStorage, and labels the steer button as queued for the next turn (not immediate wake).",
324
+ "assertions": [
325
+ {
326
+ "name": "opts-in-ax",
327
+ "description": "Creates the html node with axCapabilities.enabled = true",
328
+ "type": "output_check"
329
+ },
330
+ {
331
+ "name": "awaits-emit",
332
+ "description": "Uses window.PMX_AX.emit with await (not fire-and-forget)",
333
+ "type": "output_check"
334
+ },
335
+ {
336
+ "name": "sandbox-safe",
337
+ "description": "Avoids localStorage/sessionStorage/cookies in the sandboxed iframe",
338
+ "type": "output_check"
339
+ },
340
+ {
341
+ "name": "labels-steer-queued",
342
+ "description": "Labels steering as queued for the next turn, not immediate agent wake",
343
+ "type": "output_check"
344
+ }
345
+ ]
346
+ },
347
+ {
348
+ "id": 14,
349
+ "name": "ax-delivery-lifecycle",
350
+ "prompt": "There are pending steering messages from the board. Pick them up and act on them.",
351
+ "expected_output": "Reads compact context (newest-first pendingSteering + totalPending/omittedPending) and/or claims via canvas_ax_delivery {action:claim}; acts, then marks delivered. Understands direct delivery stays FIFO oldest-first.",
352
+ "assertions": [
353
+ {
354
+ "name": "reads-newest-first",
355
+ "description": "Uses compact context (newest-first + counts) or canvas_ax_delivery claim to find fresh steering",
356
+ "type": "output_check"
357
+ },
358
+ {
359
+ "name": "acts-then-marks",
360
+ "description": "Acts on the steering then marks it delivered (canvas_ax_delivery {action:mark})",
361
+ "type": "output_check"
362
+ },
363
+ {
364
+ "name": "understands-fifo",
365
+ "description": "Recognizes the direct delivery queue is FIFO oldest-first, distinct from compact context",
366
+ "type": "output_check"
367
+ }
368
+ ]
369
+ },
370
+ {
371
+ "id": 15,
372
+ "name": "final-cleanup-and-validate",
373
+ "prompt": "We're done with the test board. Clean up the temporary nodes and leave the canvas as we found it.",
374
+ "expected_output": "Removes retry/test fixtures, runs canvas_query {action:validate} to confirm no accidental collisions/dangling edges, and restores the baseline snapshot.",
375
+ "assertions": [
376
+ {
377
+ "name": "removes-fixtures",
378
+ "description": "Removes the temporary/retry fixture nodes",
379
+ "type": "output_check"
380
+ },
381
+ {
382
+ "name": "validates-final",
383
+ "description": "Validates the final layout (canvas_query {action:validate})",
384
+ "type": "output_check"
385
+ },
386
+ {
387
+ "name": "restores-baseline",
388
+ "description": "Restores the baseline snapshot to leave the canvas as found",
389
+ "type": "output_check"
390
+ }
391
+ ]
392
+ },
393
+ {
394
+ "id": 16,
395
+ "name": "ghost-cursor-intent",
396
+ "prompt": "Add an 'Auth design' status node to the review area. Before adding it, signal what you're about to do so I can veto it if I disagree, then make the change.",
397
+ "expected_output": "Signals a create intent first with canvas_intent {action:signal, kind:\"create\", position, nodeType:\"status\", label, reason, confidence}, then creates the 'Auth design' node at that position with canvas_node {action:\"add\", type:\"status\", x, y, intentId}. Understands that a vetoed intent rejects its linked mutation. Does not silently mutate without signalling when steering would help.",
398
+ "assertions": [
399
+ {
400
+ "name": "signals-intent-first",
401
+ "description": "Calls canvas_intent {action:signal} with label/reason and a confidence in [0,1] before the visible mutation",
402
+ "type": "output_check"
403
+ },
404
+ {
405
+ "name": "links-mutation-to-intent",
406
+ "description": "Passes the returned intentId on canvas_node {action:add, type:\"status\", x, y, intentId} so the ghost settles into the real node",
407
+ "type": "output_check"
408
+ },
409
+ {
410
+ "name": "respects-veto",
411
+ "description": "Understands a vetoed intent rejects its linked mutation (\"Intent <id> was vetoed\") rather than treating signal as cosmetic, and clears/updates intent appropriately",
412
+ "type": "output_check"
413
+ }
414
+ ]
415
+ },
416
+ {
417
+ "id": 17,
418
+ "name": "standalone-surface-open-as-site",
419
+ "prompt": "Open the bar-chart graph node as a standalone full-page site for a screenshot, and also try to open the Excalidraw diagram node the same way. Tell me the correct surface URL for each.",
420
+ "expected_output": "For the graph/json-render node, uses the stable surface URL /api/canvas/surface/<id> which redirects to the full-viewport display=site viewer (reads surfaceUrl from the node, does not fabricate a path). Recognizes the hosted Excalidraw/ext-app node is NOT an open-as-site target (its surface route returns a clean 404 — it renders only with the in-canvas host bridge) and does not claim a standalone tab will work. Notes that some host browsers (e.g. the Codex in-app single-tab browser) don't deliver live-resize events, so a system browser is recommended for separate full-page viewing.",
421
+ "assertions": [
422
+ {
423
+ "name": "graph-site-surface-url",
424
+ "description": "Gives the graph node's /api/canvas/surface/<id> URL (which redirects to display=site, full viewport) from the node payload rather than fabricating one",
425
+ "type": "output_check"
426
+ },
427
+ {
428
+ "name": "extapp-not-open-as-site",
429
+ "description": "States the hosted Excalidraw/ext-app node is not an open-as-site target (clean 404; renders in-canvas only) instead of claiming a standalone tab works",
430
+ "type": "output_check"
431
+ },
432
+ {
433
+ "name": "host-browser-caveat",
434
+ "description": "Recommends a system browser for separate full-page viewing and/or notes single-tab host browsers may not reflow on live resize",
435
+ "type": "output_check"
436
+ }
437
+ ]
184
438
  }
185
439
  ]
186
440
  }
@@ -0,0 +1,17 @@
1
+ const SECRET = process.env.JWT_SECRET ?? 'dev-secret';
2
+
3
+ export interface JwtClaims {
4
+ sub: string;
5
+ exp: number;
6
+ }
7
+
8
+ export function signJwt(claims: JwtClaims): string {
9
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
10
+ return `${payload}.${SECRET}`;
11
+ }
12
+
13
+ export function verifyJwt(token: string): JwtClaims | null {
14
+ const [payload, signature] = token.split('.');
15
+ if (signature !== SECRET || !payload) return null;
16
+ return JSON.parse(Buffer.from(payload, 'base64url').toString()) as JwtClaims;
17
+ }
@@ -0,0 +1,12 @@
1
+ import { signJwt, type JwtClaims } from './jwt';
2
+
3
+ export async function login(username: string, password: string): Promise<string | null> {
4
+ const ok = await checkCredentials(username, password);
5
+ if (!ok) return null;
6
+ const claims: JwtClaims = { sub: username, exp: Date.now() + 3_600_000 };
7
+ return signJwt(claims);
8
+ }
9
+
10
+ async function checkCredentials(username: string, password: string): Promise<boolean> {
11
+ return Boolean(username) && password.length >= 8;
12
+ }
@@ -0,0 +1,13 @@
1
+ import { verifyJwt } from './jwt';
2
+
3
+ export interface AuthedRequest {
4
+ headers: Record<string, string>;
5
+ userId?: string;
6
+ }
7
+
8
+ export function authMiddleware(req: AuthedRequest, next: () => void): void {
9
+ const token = (req.headers.authorization ?? '').replace(/^Bearer /, '');
10
+ const claims = verifyJwt(token);
11
+ if (claims) req.userId = claims.sub;
12
+ next();
13
+ }
@@ -0,0 +1,13 @@
1
+ import { login } from '../auth/login';
2
+ import { authMiddleware, type AuthedRequest } from '../auth/middleware';
3
+
4
+ export function registerAuthRoutes(router: {
5
+ post(path: string, handler: (req: AuthedRequest) => Promise<unknown>): void;
6
+ use(handler: (req: AuthedRequest, next: () => void) => void): void;
7
+ }): void {
8
+ router.use(authMiddleware);
9
+ router.post('/login', async (req) => {
10
+ const token = await login(req.headers.username ?? '', req.headers.password ?? '');
11
+ return token ? { token } : { error: 'invalid credentials' };
12
+ });
13
+ }
@@ -0,0 +1,27 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ const refreshBus = new EventEmitter();
4
+
5
+ interface UserRequest {
6
+ query: Record<string, string>;
7
+ headers: Record<string, string>;
8
+ }
9
+
10
+ interface UserResponse {
11
+ json(body: unknown): void;
12
+ }
13
+
14
+ /**
15
+ * GET /api/users
16
+ *
17
+ * Memory leak: every request registers a `refresh` listener whose closure
18
+ * captures the entire `req` object and is never removed. The heap retains one
19
+ * request per call and the EventEmitter listener count grows without bound.
20
+ */
21
+ export function getUsers(req: UserRequest, res: UserResponse): void {
22
+ refreshBus.on('refresh', () => {
23
+ // Captures `req` for the lifetime of the process — the leak.
24
+ void req.headers;
25
+ });
26
+ res.json({ users: [] });
27
+ }